diff --git a/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/MEMORY.md b/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/MEMORY.md deleted file mode 100644 index 4ec74f63..00000000 --- a/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/MEMORY.md +++ /dev/null @@ -1,5 +0,0 @@ -# Memory Index - -## Project Direction -- [Agent-First Pivot](project_agent_first_pivot.md) — CodeVetter pivoting to AI-agent-first: reviewing agent-generated PRs, agent-to-agent interaction on GitHub -- [Shelved: Playwright Testing](project_shelved_playwright_testing.md) — AI E2E testing idea explored and shelved, possible v2+ "verify the fix" feature diff --git a/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/project_agent_first_pivot.md b/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/project_agent_first_pivot.md deleted file mode 100644 index 379792fd..00000000 --- a/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/project_agent_first_pivot.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: Product Pivot - AI Agent-First Code Review -description: CodeVetter is pivoting to be an AI-agent-first product — reviewing PRs generated by AI coding agents and interacting with agents directly on GitHub PR pages. -type: project ---- - -## Product Direction: AI Agent-First (2026-03-11) - -**Core pivot:** CodeVetter should be an AI-agent-first product, not human-first. - -**What this means:** -- Primary audience: AI coding agents (Claude Code, Cursor, Devin, Copilot Workspace, etc.) that generate PRs -- CodeVetter reviews all PRs generated by agents automatically -- AI agents can talk to CodeVetter directly on the GitHub PR page itself (agent-to-agent conversation) -- The product is a quality gate / reviewer for AI-generated code - -**Why this matters:** -- AI agents are generating an increasing volume of PRs -- Human reviewers can't keep up with the volume -- Agent-generated code has different failure modes than human code (hallucinated APIs, subtle logic errors, over-engineering) -- Agent-to-agent review loop on GitHub is a novel interaction model diff --git a/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/project_shelved_playwright_testing.md b/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/project_shelved_playwright_testing.md deleted file mode 100644 index 05113298..00000000 --- a/.claude/projects/-Users-sarthakagrawal-Desktop-code-reviewer/memory/project_shelved_playwright_testing.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Shelved - AI Playwright Testing Feature -description: Idea to add AI-driven Playwright E2E testing was explored and shelved. Could revisit as a "verify the fix" feature in v2+. -type: project ---- - -## Shelved Idea: AI-Powered Playwright Testing (2026-03-11) - -**Concept:** Users provide auth + instructions, AI uses Playwright to run E2E tests on their app. - -**Decision:** Shelved. Not pursuing now. - -**Reasons:** -- Market already crowded (Browserbase, Momentic, QA Wolf, Shortest, Drizzle AI) -- Tangential to core code review value prop -- Expensive to run on every PR; running only before releases makes it an afterthought -- Would split focus from core product - -**Potential future angle:** "Verify the fix" step triggered by review intelligence — differentiated because it's driven by CodeVetter's review findings, not generic E2E. Only revisit if users explicitly ask for it. diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 9ffcd90a..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(tail:*)", - "Bash(which npm:*)", - "Bash(git:*)", - "Bash(which tsc:*)", - "Bash(/Users/sarthakagrawal/Desktop/code-reviewer/node_modules/.bin/tsc:*)", - "Bash(head:*)", - "Bash(node:*)", - "Bash(pkill:*)", - "Bash(sleep 2:*)", - "Bash(sleep 3:*)", - "mcp__plugin_playwright_playwright__browser_navigate", - "Bash(whois:*)", - "Bash(which vercel 2>/dev/null && vercel --version 2>/dev/null; which wrangler 2>/dev/null && wrangler --version 2>/dev/null)", - "Bash(wrangler whoami:*)", - "Bash(vercel teams:*)", - "Bash(wrangler:*)", - "Bash(cd /Users/sarthakagrawal/Desktop/code-reviewer/apps/landing-page && vercel --prod --yes 2>&1)", - "Bash(cd /Users/sarthakagrawal/Desktop/code-reviewer/apps/landing-page && vercel link --yes --scope sarthak-agrawals-projects-5d4953f8 2>&1 && vercel --prod --yes --scope sarthak-agrawals-projects-5d4953f8 2>&1)", - "Bash(vercel project:*)", - "Bash(vercel link:*)", - "Bash(vercel:*)", - "mcp__plugin_playwright_playwright__browser_install", - "mcp__plugin_playwright_playwright__browser_take_screenshot", - "Bash(find:*)", - "Bash(grep:*)", - "Bash(fd:*)", - "Bash(wc:*)", - "Bash(ls:*)", - "Bash(for f:*)", - "Read(//Users/sarthakagrawal/Desktop/code-reviewer/**)", - "Bash(bash /tmp/count_dashboard.sh)", - "Bash(cat:*)", - "Bash(cd:*)", - "Bash(npx tsc:*)", - "Bash(npx vite:*)", - "Bash(npm install:*)", - "Bash(lsof:*)", - "Bash(curl:*)", - "Bash(python3:*)", - "WebSearch", - "WebFetch(domain:www.greptile.com)", - "WebFetch(domain:www.coderabbit.ai)", - "WebFetch(domain:www.conductor.build)", - "WebFetch(domain:www.ellipsis.dev)", - "WebFetch(domain:bito.ai)", - "WebFetch(domain:whatthediff.ai)", - "WebFetch(domain:superset.sh)", - "WebFetch(domain:github.com)", - "WebFetch(domain:news.ycombinator.com)", - "WebFetch(domain:www.ycombinator.com)", - "WebFetch(domain:makerstack.co)", - "WebFetch(domain:launchllama.co)", - "WebFetch(domain:docs.superset.sh)", - "WebFetch(domain:www.producthunt.com)", - "Bash(cargo check:*)", - "WebFetch(domain:docs.dodopayments.com)", - "Bash(pnpm tauri:*)", - "Bash(npx @tauri-apps/cli build)", - "Bash(gh run:*)", - "mcp__claude_ai_Vercel__list_projects", - "mcp__claude_ai_Vercel__get_deployment_build_logs", - "mcp__claude_ai_Vercel__list_deployments", - "Bash(gh release:*)", - "Bash(gh api:*)", - "Bash(xargs -I {} sh -c 'git push origin :refs/tags/{} 2>&1; git tag -d {}')", - "Bash(npm test:*)", - "Bash(npm run:*)", - "Bash(/bin/ls -la /Users/sarthakagrawal/Desktop/code-reviewer/)", - "Bash(/bin/ls -la /Users/sarthakagrawal/Desktop/code-reviewer/apps/)", - "Bash(/bin/ls -la /Users/sarthakagrawal/Desktop/code-reviewer/packages/)", - "Bash(/bin/ls -la /Users/sarthakagrawal/Desktop/code-reviewer/workers/)", - "Bash(/bin/ls -la /Users/sarthakagrawal/Desktop/code-reviewer/tests/)", - "Bash(for pkg:*)", - "Bash(do echo:*)", - "Bash(/usr/bin/python3 -c \"import json; d=json.load\\(open\\(''/Users/sarthakagrawal/Desktop/code-reviewer/$pkg/package.json''\\)\\); scripts=d.get\\(''scripts'',{}\\); [print\\(f'' {k}: {v}''\\) for k,v in scripts.items\\(\\)]\")", - "Bash(done)", - "Bash(/bin/ls /Users/sarthakagrawal/Desktop/code-reviewer/packages/review-core/dist/)", - "Bash(npx eslint:*)", - "Read(//Library/Logs/DiagnosticReports/**)", - "Read(//Users/sarthakagrawal/Library/Logs/DiagnosticReports/**)", - "Bash(pmset -g log)", - "Bash(/bin/ls /Users/sarthakagrawal/Desktop/code-reviewer/packages/db/src/)", - "Bash(sysctl hw.model hw.ncpu kern.version)", - "Bash(/bin/ls /Users/sarthakagrawal/Desktop/code-reviewer/tests/)", - "Bash(ps aux:*)", - "Bash(sudo mdutil:*)", - "Bash(mdutil -s /)", - "Bash(mdutil:*)", - "Read(//Applications/**)", - "Bash(brew list:*)", - "Bash(xargs kill:*)" - ] - } -} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..545dde8f --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +# Actionlint 1.7.12 predates GitHub's official Xcode 27 preview image label. +# Treat it as declared until the pinned validator includes the hosted label. +self-hosted-runner: + labels: + - xcode-27 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dc150637..9b8b11d2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,10 @@ updates: directory: / schedule: interval: weekly + # Let ecosystem fixes settle before routine version PRs. Dependabot + # security updates are explicitly not delayed by this setting. + cooldown: + default-days: 7 groups: ccusage-runtime: patterns: diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 224e1f98..2163a2b1 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -18,9 +18,7 @@ on: - "apps/desktop/src-tauri/tauri.conf.json" workflow_dispatch: -permissions: - contents: write - actions: write +permissions: {} concurrency: group: auto-release-${{ github.ref }} @@ -28,11 +26,16 @@ concurrency: jobs: publish: + name: Publish release and dispatch build runs-on: ubuntu-latest + permissions: + contents: write # Create the versioned GitHub release. + actions: write # Dispatch the separate signed-build workflow. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false - name: Read version from tauri.conf.json id: ver @@ -50,12 +53,13 @@ jobs: id: check env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail - if gh release view "${{ steps.ver.outputs.tag }}" \ + if gh release view "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" - echo "Release ${{ steps.ver.outputs.tag }} already exists — skipping." + echo "Release $RELEASE_TAG already exists — skipping." else echo "exists=false" >> "$GITHUB_OUTPUT" fi @@ -64,21 +68,23 @@ jobs: if: steps.check.outputs.exists != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail - gh release create "${{ steps.ver.outputs.tag }}" \ + gh release create "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ - --title "CodeVetter ${{ steps.ver.outputs.tag }}" \ + --title "CodeVetter $RELEASE_TAG" \ --generate-notes - name: Dispatch release build workflow if: steps.check.outputs.exists != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail gh workflow run release.yml \ --repo "$GITHUB_REPOSITORY" \ --ref main \ - -f tag="${{ steps.ver.outputs.tag }}" + -f tag="$RELEASE_TAG" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea6d5de5..b69368e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,19 +4,64 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + native_qualification: + description: "Run isolated native macOS qualification" + required: true + default: false + type: boolean + native_interaction: + description: "Include XCUITest on the isolated hosted desktop" + required: true + default: false + type: boolean + native_production_qualification: + description: "Run protected signing, notarization, and migration qualification" + required: true + default: false + type: boolean +permissions: {} + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + native-qualification: + name: Native macOS qualification + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.native_qualification) + uses: ./.github/workflows/native-qualification.yml + with: + run_interaction: ${{ github.event_name == 'pull_request' || inputs.native_interaction }} + permissions: + contents: read + + native-production-qualification: + name: Native macOS production-candidate qualification + if: github.event_name == 'workflow_dispatch' && inputs.native_production_qualification + uses: ./.github/workflows/native-production-qualification.yml + secrets: inherit + permissions: + contents: read + lint-and-typecheck: + name: Lint, test, and build runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 2 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' cache: 'pnpm' - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - name: Install Tauri Linux dependencies run: | sudo apt-get update @@ -25,7 +70,7 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ libxdo-dev - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: apps/desktop/src-tauri - name: Install Dependencies @@ -35,7 +80,9 @@ jobs: run: pnpm run lint - name: Fetch code-health base if: github.event_name != 'workflow_dispatch' - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + env: + CODE_HEALTH_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: git fetch --depth=1 origin "$CODE_HEALTH_BASE" - name: Code health env: CODE_HEALTH_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} @@ -83,7 +130,8 @@ jobs: run: | pnpm run test:ccusage-sidecar pnpm run prepare:ccusage-sidecar - src-tauri/binaries/ccusage-$(rustc -vV | sed -n 's/^host: //p') --version + CCUSAGE_TARGET="$(rustc -vV | sed -n 's/^host: //p')" + "src-tauri/binaries/ccusage-$CCUSAGE_TARGET" --version - name: Qualify CLI artifact working-directory: apps/desktop run: | diff --git a/.github/workflows/deploy-landing.yml b/.github/workflows/deploy-landing.yml index 629d8339..787eff44 100644 --- a/.github/workflows/deploy-landing.yml +++ b/.github/workflows/deploy-landing.yml @@ -7,9 +7,8 @@ name: Deploy Landing Page # apps/landing-page-astro/. on: workflow_dispatch: -permissions: - contents: read - deployments: write + +permissions: {} concurrency: group: deploy-landing-${{ github.ref }} @@ -17,16 +16,22 @@ concurrency: jobs: deploy: + name: Build and deploy landing page runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read + deployments: write # Record the Cloudflare Pages deployment. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" - cache: pnpm + package-manager-cache: false - name: Install dependencies run: pnpm install --frozen-lockfile @@ -58,7 +63,7 @@ jobs: - name: Deploy to Cloudflare Pages if: steps.cloudflare.outputs.deploy_enabled == 'true' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 21d75599..a59fc946 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,12 +5,19 @@ on: [push, pull_request] permissions: contents: read +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: validate: + name: Validate documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" - name: Validate docs (links, frontmatter, structure) diff --git a/.github/workflows/native-production-qualification.yml b/.github/workflows/native-production-qualification.yml new file mode 100644 index 00000000..62a65c2b --- /dev/null +++ b/.github/workflows/native-production-qualification.yml @@ -0,0 +1,257 @@ +name: Native macOS production-candidate qualification + +on: + workflow_call: + workflow_dispatch: + +permissions: {} + +concurrency: + group: native-production-qualification-${{ github.ref }} + cancel-in-progress: false + +jobs: + qualify: + name: Sign, notarize, migrate, and qualify native candidate + runs-on: xcode-27 + timeout-minutes: 180 + permissions: + contents: read + env: + CODEVETTER_NATIVE_CHANNEL: production + CODEVETTER_NATIVE_BUNDLE_IDENTIFIER: com.codevetter.desktop + CODEVETTER_NATIVE_SPARKLE_FEED_URL: https://github.com/Codevetter/codevetter/releases/latest/download/appcast.xml + CODEVETTER_NATIVE_SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }} + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + package-manager-cache: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + no-cache: true + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Require protected production inputs + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }} + SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }} + shell: bash + run: | + set -euo pipefail + missing=() + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID SPARKLE_EDDSA_PRIVATE_KEY SPARKLE_EDDSA_PUBLIC_KEY; do + if [[ -z "${!name:-}" ]]; then missing+=("$name"); fi + done + if (( ${#missing[@]} > 0 )); then + echo "Missing protected inputs: ${missing[*]}" >&2 + exit 1 + fi + + - name: Import Developer ID certificate into ephemeral keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + shell: bash + run: | + set -euo pipefail + KEYCHAIN_PATH="$RUNNER_TEMP/codevetter-signing.keychain-db" + CERTIFICATE_PATH="$RUNNER_TEMP/codevetter-developer-id.p12" + KEYCHAIN_PASSWORD="$(openssl rand -hex 32)" + printf '%s' "$APPLE_CERTIFICATE" | /usr/bin/base64 --decode > "$CERTIFICATE_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + rm -f "$CERTIFICATE_PATH" + echo "CODEVETTER_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Build production-identity Release app through XcodeBuildMCP + run: pnpm native:build:release + + - name: Build and Developer ID-sign exact native package + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + shell: bash + run: | + set -euo pipefail + pnpm native:package:qualify -- \ + --channel production \ + --identity "$APPLE_SIGNING_IDENTITY" \ + --out-root artifacts/native-production-ci + QUALIFICATION="$(find artifacts/native-production-ci -name qualification.json -print -quit)" + test -n "$QUALIFICATION" + PACKAGE_DIR="$(dirname "$QUALIFICATION")" + echo "NATIVE_QUALIFICATION=$QUALIFICATION" >> "$GITHUB_ENV" + echo "NATIVE_PACKAGE_DIR=$PACKAGE_DIR" >> "$GITHUB_ENV" + + - name: Submit signed app to Apple and staple ticket + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + shell: bash + run: | + set -euo pipefail + DEVELOPER_DIR="$(xcode-select -p)" + NOTARYTOOL="$DEVELOPER_DIR/usr/bin/notarytool" + STAPLER="$DEVELOPER_DIR/usr/bin/stapler" + ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)" + "$NOTARYTOOL" submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json > artifacts/native-production-ci/notary-initial.json + jq -e '.status == "Accepted"' artifacts/native-production-ci/notary-initial.json >/dev/null + "$STAPLER" staple "$NATIVE_PACKAGE_DIR/CodeVetter.app" + "$STAPLER" validate "$NATIVE_PACKAGE_DIR/CodeVetter.app" + + - name: Rebuild archives around the stapled application + run: pnpm native:package:finalize -- --qualification "$NATIVE_QUALIFICATION" + + - name: Bind final archive to Apple notarization + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + shell: bash + run: | + set -euo pipefail + NOTARYTOOL="$(xcode-select -p)/usr/bin/notarytool" + ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)" + "$NOTARYTOOL" submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json > artifacts/native-production-ci/notary-final.json + jq -e '.status == "Accepted"' artifacts/native-production-ci/notary-final.json >/dev/null + pnpm native:notarization:prove -- \ + --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --archive "$ZIP_PATH" \ + --qualification "$NATIVE_QUALIFICATION" \ + --submission artifacts/native-production-ci/notary-final.json \ + --out artifacts/native-production-ci/notarization-proof.json + + - name: Generate and cryptographically inspect Sparkle appcast + env: + SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }} + shell: bash + run: | + set -euo pipefail + APPCAST_DIR="artifacts/native-production-ci/appcast" + mkdir -p "$APPCAST_DIR" + ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)" + cp "$ZIP_PATH" "$APPCAST_DIR/" + GENERATE_APPCAST="$(find artifacts/native-build/DerivedData/SourcePackages/artifacts -path '*/Sparkle/bin/generate_appcast' -type f -print -quit)" + test -x "$GENERATE_APPCAST" + printf '%s' "$SPARKLE_EDDSA_PRIVATE_KEY" | "$GENERATE_APPCAST" \ + --ed-key-file - \ + --download-url-prefix "https://github.com/Codevetter/codevetter/releases/latest/download/" \ + "$APPCAST_DIR" + test -f "$APPCAST_DIR/appcast.xml" + pnpm native:appcast:inspect -- \ + --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --appcast "$APPCAST_DIR/appcast.xml" \ + --qualification "$NATIVE_QUALIFICATION" \ + --out artifacts/native-production-ci/appcast-proof.json + + - name: Download retained Tauri release for isolated migration proof + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + INCUMBENT_ROOT="$RUNNER_TEMP/codevetter-incumbent" + mkdir -p "$INCUMBENT_ROOT" + gh release download v1.11.0 \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'CodeVetter_aarch64.app.tar.gz' \ + --dir "$INCUMBENT_ROOT" + tar -xzf "$INCUMBENT_ROOT/CodeVetter_aarch64.app.tar.gz" -C "$INCUMBENT_ROOT" + INCUMBENT_APP="$(find "$INCUMBENT_ROOT" -maxdepth 2 -name CodeVetter.app -type d -print -quit)" + test -n "$INCUMBENT_APP" + echo "NATIVE_INCUMBENT_APP=$INCUMBENT_APP" >> "$GITHUB_ENV" + + - name: Qualify isolated upgrade, relaunch, custom rubric, data, and rollback + run: | + pnpm native:installed-upgrade:qualify -- \ + --incumbent-app "$NATIVE_INCUMBENT_APP" \ + --native-app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --qualification "$NATIVE_QUALIFICATION" \ + --run-root "$RUNNER_TEMP/codevetter-native-upgrade" \ + --out artifacts/native-production-ci/installed-upgrade-proof.json \ + --foreground \ + --hosted-ephemeral + + - name: Require every production-readiness check + shell: bash + run: | + set -euo pipefail + pnpm native:release:inspect -- \ + --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --qualification "$NATIVE_QUALIFICATION" \ + --appcast-proof artifacts/native-production-ci/appcast-proof.json \ + --notarization-proof artifacts/native-production-ci/notarization-proof.json \ + --installed-proof artifacts/native-production-ci/installed-upgrade-proof.json \ + --out artifacts/native-production-ci/release-readiness.json + jq -e '.shipping_ready == true and (.checks | all(.passed == true))' \ + artifacts/native-production-ci/release-readiness.json >/dev/null + + - name: Test all production qualification contracts + run: | + pnpm test:native-runner + pnpm test:native-package + pnpm test:native-package-finalize + pnpm test:native-appcast + pnpm test:native-notarization + pnpm test:native-data-continuity + pnpm test:native-installed-upgrade + pnpm test:native-release + + - name: Remove ephemeral credential material + if: always() + shell: bash + run: | + if [[ -n "${CODEVETTER_SIGNING_KEYCHAIN:-}" ]]; then + security delete-keychain "$CODEVETTER_SIGNING_KEYCHAIN" || true + fi + rm -f "$RUNNER_TEMP/codevetter-developer-id.p12" + + - name: Upload protected production-candidate evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: native-production-qualification-${{ github.run_id }} + path: | + artifacts/native-production-ci + artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app.dSYM + if-no-files-found: warn + retention-days: 7 + compression-level: 0 diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml new file mode 100644 index 00000000..f5d436df --- /dev/null +++ b/.github/workflows/native-qualification.yml @@ -0,0 +1,126 @@ +name: Native macOS qualification + +on: + workflow_call: + inputs: + run_interaction: + description: "Run XCUITest on the isolated hosted desktop" + required: true + type: boolean + workflow_dispatch: + inputs: + run_interaction: + description: "Run XCUITest on the isolated hosted desktop" + required: true + default: false + type: boolean + +permissions: {} + +concurrency: + group: native-qualification-${{ github.ref }} + cancel-in-progress: false + +jobs: + qualify: + name: Build and qualify native preview + runs-on: xcode-27 + timeout-minutes: 120 + permissions: + contents: read + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + package-manager-cache: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + no-cache: true + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Prove desktop-control guard + run: pnpm test:native-runner + + - name: Prepare deterministic owner-review outputs + shell: bash + run: | + node scripts/render-native-owner-review.mjs env \ + --out-root artifacts/native-owner-review-ci >> "$GITHUB_ENV" + + - name: Run background-safe native qualification + run: pnpm test:native:background + + - name: Finalize deterministic owner-review packet + run: | + node scripts/render-native-owner-review.mjs finalize \ + --out-root artifacts/native-owner-review-ci + pnpm test:native-review-render + + - name: Run interaction qualification on isolated desktop + if: inputs.run_interaction + run: pnpm test:native:ui -- --foreground --desktop-idle + + - name: Build coverage-free Release application + run: pnpm native:build:release + + - name: Build and qualify local preview package + run: pnpm native:package:qualify -- --out-root artifacts/native-package-ci + + - name: Inspect exact preview release boundaries + id: readiness + shell: bash + run: | + set -euo pipefail + QUALIFICATION="$(find artifacts/native-package-ci -name qualification.json -print -quit)" + test -n "$QUALIFICATION" + PACKAGE_DIR="$(dirname "$QUALIFICATION")" + node scripts/inspect-native-release-readiness.mjs \ + --app "$PACKAGE_DIR/CodeVetter.app" \ + --qualification "$QUALIFICATION" \ + --out artifacts/native-package-ci/release-readiness.json + echo "qualification=$QUALIFICATION" >> "$GITHUB_OUTPUT" + + - name: Test package and readiness inspectors + run: | + pnpm test:native-package + pnpm test:native-release + + - name: Preserve native tool logs + if: always() + shell: bash + run: | + mkdir -p artifacts/native-tool-logs + find "$HOME/Library/Developer/XcodeBuildMCP" \ + -path '*/logs/*' -type f -name '*.log' \ + -exec cp {} artifacts/native-tool-logs/ \; + + - name: Upload unsigned qualification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: native-qualification-${{ github.run_id }} + path: | + artifacts/native-package-ci + artifacts/native-owner-review-ci + artifacts/native-tool-logs + artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app.dSYM + if-no-files-found: warn + retention-days: 7 + compression-level: 0 diff --git a/.github/workflows/osv-offline.yml b/.github/workflows/osv-offline.yml new file mode 100644 index 00000000..26e263d0 --- /dev/null +++ b/.github/workflows/osv-offline.yml @@ -0,0 +1,84 @@ +name: OSV Offline Scan + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: osv-offline-${{ github.ref }} + cancel-in-progress: false + +jobs: + refresh-databases: + name: Refresh OSV databases + runs-on: ubuntu-latest + steps: + - name: Download ecosystem databases + env: + OSV_CACHE_ROOT: ${{ runner.temp }}/osv-db/osv-scalibr + run: | + mkdir -p "$OSV_CACHE_ROOT/npm" "$OSV_CACHE_ROOT/crates.io" "$OSV_CACHE_ROOT/Go" + curl --fail --silent --show-error --location \ + https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip \ + --output "$OSV_CACHE_ROOT/npm/all.zip" + curl --fail --silent --show-error --location \ + https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip \ + --output "$OSV_CACHE_ROOT/crates.io/all.zip" + curl --fail --silent --show-error --location \ + https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip \ + --output "$OSV_CACHE_ROOT/Go/all.zip" + cd "$OSV_CACHE_ROOT" + sha256sum npm/all.zip crates.io/all.zip Go/all.zip > SHA256SUMS + - name: Upload database snapshot + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: osv-databases-${{ github.run_id }} + path: ${{ runner.temp }}/osv-db + retention-days: 7 + + offline-scan: + name: Scan with network-disabled mode + needs: refresh-databases + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download database snapshot + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: osv-databases-${{ github.run_id }} + path: ${{ runner.temp }}/osv-db + - name: Install checksum-pinned OSV-Scanner + env: + OSV_BINARY: osv-scanner_linux_amd64 + OSV_SHA256: f9f25499a2c8cc367b3af45df2ea7eeca7fbccceab9c35079968f4b3652194be + OSV_URL: https://github.com/google/osv-scanner/releases/download/v2.5.1/osv-scanner_linux_amd64 + run: | + curl --fail --silent --show-error --location "$OSV_URL" --output "$RUNNER_TEMP/$OSV_BINARY" + echo "$OSV_SHA256 $RUNNER_TEMP/$OSV_BINARY" | sha256sum --check --strict + chmod 0755 "$RUNNER_TEMP/$OSV_BINARY" + mkdir -p "$RUNNER_TEMP/osv-bin" + mv "$RUNNER_TEMP/$OSV_BINARY" "$RUNNER_TEMP/osv-bin/osv-scanner" + echo "$RUNNER_TEMP/osv-bin" >> "$GITHUB_PATH" + - name: Run offline scan + id: scan + continue-on-error: true + env: + XDG_CACHE_HOME: ${{ runner.temp }}/osv-db + run: node scripts/run-osv-offline.mjs + - name: Upload scan evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: osv-offline-evidence-${{ github.run_id }} + path: artifacts/tooling/osv + if-no-files-found: error + retention-days: 30 + - name: Enforce scan result + if: always() && steps.scan.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 235c30d4..fd596879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,47 +10,51 @@ on: required: true type: string -permissions: - contents: write +permissions: {} + +concurrency: + group: release-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: false jobs: build: + name: Build, sign, and upload desktop artifacts strategy: matrix: platform: [macos-latest] runs-on: ${{ matrix.platform }} + permissions: + contents: write # Upload signed artifacts and updater metadata. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: # On workflow_dispatch we want the commit the tag points at, not # the head of main — checkout the tag explicitly. ref: ${{ github.event.release.tag_name || inputs.tag }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 # Don't specify version here — it reads "packageManager" from # package.json (pnpm@10.33.2). Specifying both causes # "Multiple versions of pnpm specified" error. - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 - cache: pnpm + package-manager-cache: false - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: aarch64-apple-darwin - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Cache Rust - uses: Swatinem/rust-cache@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - workspaces: apps/desktop/src-tauri + no-cache: true - name: Install dependencies run: pnpm install --ignore-scripts @@ -102,7 +106,7 @@ jobs: - name: Build Tauri app id: tauri - uses: tauri-apps/tauri-action@v0 + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_ENV_TARGET_TRIPLE: aarch64-apple-darwin diff --git a/.github/workflows/repository-security.yml b/.github/workflows/repository-security.yml new file mode 100644 index 00000000..9386106f --- /dev/null +++ b/.github/workflows/repository-security.yml @@ -0,0 +1,169 @@ +name: Repository Security + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: {} + +concurrency: + group: repository-security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: GitHub Actions semantics + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned workflow analyzers + env: + ACTIONLINT_ARCHIVE: actionlint_1.7.12_linux_amd64.tar.gz + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ACTIONLINT_URL: https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz + SHELLCHECK_ARCHIVE: shellcheck-v0.11.0.linux.x86_64.tar.gz + SHELLCHECK_SHA256: b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6 + SHELLCHECK_URL: https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.gz + run: | + mkdir -p "$RUNNER_TEMP/workflow-tools" + curl --fail --silent --show-error --location "$ACTIONLINT_URL" --output "$RUNNER_TEMP/$ACTIONLINT_ARCHIVE" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/$ACTIONLINT_ARCHIVE" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$ACTIONLINT_ARCHIVE" -C "$RUNNER_TEMP/workflow-tools" actionlint + curl --fail --silent --show-error --location "$SHELLCHECK_URL" --output "$RUNNER_TEMP/$SHELLCHECK_ARCHIVE" + echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/$SHELLCHECK_ARCHIVE" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$SHELLCHECK_ARCHIVE" -C "$RUNNER_TEMP" + mv "$RUNNER_TEMP/shellcheck-v0.11.0/shellcheck" "$RUNNER_TEMP/workflow-tools/shellcheck" + echo "$RUNNER_TEMP/workflow-tools" >> "$GITHUB_PATH" + - name: Validate workflow syntax and shell fragments + run: | + actionlint -version + shellcheck --version + actionlint -color + + biome-sarif: + name: Biome SARIF + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Publish the generated Biome report. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Generate Biome SARIF + env: + BIOME_SARIF_PATH: artifacts/tooling/biome.sarif + run: pnpm run quality:sarif + - name: Upload Biome SARIF + if: always() + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + with: + sarif_file: artifacts/tooling/biome.sarif + category: biome + + cargo-deny: + name: Rust dependency policy + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Publish license and source-policy findings. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned cargo-deny binary + env: + CARGO_DENY_ARCHIVE: cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz + CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f + CARGO_DENY_URL: https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz + run: | + curl --fail --silent --show-error --location "$CARGO_DENY_URL" --output "$RUNNER_TEMP/$CARGO_DENY_ARCHIVE" + echo "$CARGO_DENY_SHA256 $RUNNER_TEMP/$CARGO_DENY_ARCHIVE" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$CARGO_DENY_ARCHIVE" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/cargo-deny-0.20.2-x86_64-unknown-linux-musl" >> "$GITHUB_PATH" + - name: Check licenses, sources, and wildcard requirements + id: policy + continue-on-error: true + run: | + mkdir -p artifacts/tooling + cargo-deny --format sarif --manifest-path apps/desktop/src-tauri/Cargo.toml --config apps/desktop/src-tauri/deny.toml --frozen check licenses sources > artifacts/tooling/cargo-deny.sarif + cargo-deny --manifest-path apps/desktop/src-tauri/Cargo.toml --config apps/desktop/src-tauri/deny.toml --frozen check --hide-inclusion-graph bans + - name: Upload cargo-deny SARIF + if: always() + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + with: + sarif_file: artifacts/tooling/cargo-deny.sarif + category: cargo-deny + - name: Enforce cargo-deny result + if: always() && steps.policy.outcome == 'failure' + run: exit 1 + + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Publish the redacted Gitleaks report. + steps: + - name: Checkout complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install pinned Gitleaks binary + env: + GITLEAKS_ARCHIVE: gitleaks_8.30.1_linux_x64.tar.gz + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + GITLEAKS_URL: https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + run: | + curl --fail --silent --show-error --location "$GITLEAKS_URL" --output "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" + echo "$GITLEAKS_SHA256 $RUNNER_TEMP/$GITLEAKS_ARCHIVE" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/gitleaks-bin" + tar -xzf "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks + echo "$RUNNER_TEMP/gitleaks-bin" >> "$GITHUB_PATH" + - name: Scan repository history + id: scan + continue-on-error: true + run: >- + gitleaks git --no-banner --redact=100 --report-format sarif + --report-path "$RUNNER_TEMP/gitleaks.sarif" . + - name: Upload Gitleaks SARIF + if: always() + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + with: + sarif_file: ${{ runner.temp }}/gitleaks.sarif + category: gitleaks + - name: Enforce Gitleaks result + if: always() && steps.scan.outcome == 'failure' + run: exit 1 + + zizmor: + name: zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # zizmor-action uploads its SARIF report. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Audit GitHub Actions + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index c0f7cecb..cb36ed8e 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -4,24 +4,35 @@ on: - cron: '0 9 * * 1' workflow_dispatch: +permissions: {} + +concurrency: + group: weekly-quality-${{ github.ref }} + cancel-in-progress: true + jobs: quality: + name: Run weekly quality canary runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Record source revision id: rev run: | - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + { + echo "sha=$(git rev-parse HEAD)" + echo "short=$(git rev-parse --short HEAD)" + echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' @@ -72,24 +83,29 @@ jobs: - name: Emit canary evidence if: always() + env: + JOB_STATUS: ${{ job.status }} + SOURCE_REVISION: ${{ steps.rev.outputs.sha }} + SOURCE_REVISION_SHORT: ${{ steps.rev.outputs.short }} + STARTED_AT: ${{ steps.rev.outputs.ts }} run: | set -euo pipefail mkdir -p canary-out # The conclusion is only known after the quality step; read it # from the job status env that GitHub sets for `if: always()` steps. # We treat any non-success quality step as a failure. - CONCLUSION="${{ job.status }}" + CONCLUSION="$JOB_STATUS" # `job.status` is the *job* status at the point this step starts; # because this step runs with `if: always()`, the prior step's # failure has already propagated to the job status. cat > canary-out/canary-evidence.json <> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY" - echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" - echo "| Revision | \`${{ steps.rev.outputs.short }}\` |" >> "$GITHUB_STEP_SUMMARY" - echo "| Started | ${{ steps.rev.outputs.ts }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Conclusion | ${CONCLUSION} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Timeout | 20 minutes |" >> "$GITHUB_STEP_SUMMARY" - echo "| Run | [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "If this run failed, the previous failed run's conclusion + URL are the unresolved failure evidence. Foundry reads this artifact to compute freshness against the 8-day window." >> "$GITHUB_STEP_SUMMARY" + { + echo "### Weekly canary evidence" + echo "" + echo "| Field | Value |" + echo "|---|---|" + echo "| Revision | \`$SOURCE_REVISION_SHORT\` |" + echo "| Started | $STARTED_AT |" + echo "| Conclusion | ${CONCLUSION} |" + echo "| Timeout | 20 minutes |" + echo "| Run | [$GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) |" + echo "" + echo "If this run failed, the previous failed run's conclusion + URL are the unresolved failure evidence. Foundry reads this artifact to compute freshness against the 8-day window." + } >> "$GITHUB_STEP_SUMMARY" cat canary-out/canary-evidence.json - name: Upload canary evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: canary-evidence path: canary-out/canary-evidence.json diff --git a/.gitignore b/.gitignore index 562233e3..0a4b1c31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,13 @@ node_modules/ -# Action runtime bundle must stay versioned. -dist/* -!dist/index.js - # Generated build artifacts should not be tracked. -packages/*/dist -workers/*/dist -workers/*/dist-cjs -workers/*/.wrangler -.vercel +dist/ out/ .next/ target/ *.tsbuildinfo +.build/ +default.profraw # Desktop app build apps/desktop/src-tauri/target/ @@ -35,6 +29,11 @@ apps/desktop/native/AgentIsland/.build/ # Secrets .claude/settings.local.json +# Local agent/tool state and output — not shared repo content. +.claude/projects/ +.fleet/ +.impeccable/ + # IDE .vscode/ .idea/ @@ -50,7 +49,10 @@ apps/desktop/synthetic-qa-artifacts/ .playwright-mcp/ # Public benchmark reviewer output (drop tool outputs here, not tracked) -benchmark/reviews/ +benchmarks/public-catch-rate/reviews/ + +# Scratch output from CLI runs (--out artifacts/...). Committed evidence lives in evidence/. +artifacts/ # Coverage coverage/ diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..2bd3815f --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,10 @@ +# Exact historical false positives retained for auditability. Each entry is +# scoped to one commit, path, rule, and line; future findings in the same files +# remain visible. +# PostHog browser ingestion key, public by design (see tooling docs). +930c8952a3c80edd61c06c2f49106e616c8dde9a:apps/desktop/src/lib/analytics.ts:generic-api-key:25 +39f414d063cac9ebe9258c69a81c05348f3e4b89:apps/desktop/src/lib/analytics.ts:generic-api-key:25 +# Historical Foundry public project identifier. +81df29eebf1d724d2939cbfd8123b86e9848a940:foundry.json:generic-api-key:4 +# Historical database migration fixture value. +ac5d18ee9b33811accec2df5d7bc33b69f9bb6c3:apps/desktop/src-tauri/src/db/schema.rs:generic-api-key:23 diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..f46c0171 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,7 @@ -npx lint-staged +pnpm exec lint-staged + +if command -v gitleaks >/dev/null 2>&1; then + pnpm run quality:secrets:staged +else + echo "gitleaks is not installed; repository security CI will enforce the history scan" >&2 +fi diff --git a/.husky/pre-push b/.husky/pre-push index 60232096..f7bb930f 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,14 +1,22 @@ -# Abort push if lint fails or a known secret pattern leaks into tracked files. +# Abort push if lint fails or Gitleaks finds a secret in repository history. +# Keep the narrow regex fallback for contributors who do not have Gitleaks. set -e if [ -f package.json ] && grep -q '"lint"' package.json; then - npm run lint || { echo "lint failed — fix before pushing" >&2; exit 1; } + pnpm run lint || { echo "lint failed — fix before pushing" >&2; exit 1; } fi +if command -v gitleaks >/dev/null 2>&1; then + pnpm run quality:secrets + exit 0 +fi + +echo "gitleaks is not installed; using the limited tracked-file fallback" >&2 + SECRETS=$(git ls-files -z 2>/dev/null \ | xargs -0 grep -lE \ - 'sk-(proj-|ant-)?[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|AIzaSy[A-Za-z0-9_-]{33}|xoxb-[A-Za-z0-9-]+|-----BEGIN (RSA |EC )?PRIVATE KEY-----' 2>/dev/null \ - | grep -vE '(\.example$|\.sample$|/tests?/|/__tests__/|/fixtures?/|/mocks?/|/vendor/|/\.tmp-|^benchmark/|^apps/landing-page-astro/public/benchmark/|src/commands/secret_policy\.rs$)' \ + 'sk-(proj-|ant-)?[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|AIzaSy[A-Za-z0-9_-]{33}|xoxb-[A-Za-z0-9-]+|pk_[A-Za-z0-9]{32,}|-----BEGIN (RSA |EC )?PRIVATE KEY-----' 2>/dev/null \ + | grep -vE '(\.example$|\.sample$|/tests?/|/__tests__/|/fixtures?/|/mocks?/|/vendor/|/\.tmp-|^benchmarks/public-catch-rate/|^apps/landing-page-astro/public/benchmark/|src/commands/secret_policy\.rs$|^foundry\.json$)' \ || true) if [ -n "$SECRETS" ]; then diff --git a/.impeccable/critique/2026-07-27T05-06-55Z__scripts-run-structural-context-evaluation-mjs.md b/.impeccable/critique/2026-07-27T05-06-55Z__scripts-run-structural-context-evaluation-mjs.md deleted file mode 100644 index c94c42c3..00000000 --- a/.impeccable/critique/2026-07-27T05-06-55Z__scripts-run-structural-context-evaluation-mjs.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -target: structural-context evaluation HTML report -total_score: 33 -max_score: 40 -na_heuristics: -p0_count: 0 -p1_count: 0 -timestamp: 2026-07-27T05-06-55Z -slug: scripts-run-structural-context-evaluation-mjs ---- -Method: dual-agent (A: impeccable_assessment_a · B: impeccable_assessment_b) - -## Design Health Score - -| # | Heuristic | Score | Key finding | -|---|---|---:|---| -| 1 | Visibility of System Status | 4 | Qualification, pair counts, gates, source, and read-only state are explicit. | -| 2 | Match System / Real World | 3 | A/A and discordance still assume evaluation fluency. | -| 3 | User Control and Freedom | 2 | A static report has no filtering or bulk disclosure controls. | -| 4 | Consistency and Standards | 4 | Evidence hierarchy, arm naming, and CodeVetter tokens are cohesive. | -| 5 | Error Prevention | 4 | Claim boundaries and neutral diagnostic deltas prevent overstatement. | -| 6 | Recognition Rather Than Recall | 4 | Mobile diagnostics now expose every comparison value in each metric card. | -| 7 | Flexibility and Efficiency | 2 | Large experiments will eventually need anchors, filters, or condensed rows. | -| 8 | Aesthetic and Minimalist Design | 4 | The report remains focused and qualification-first across all widths. | -| 9 | Error Recovery | 3 | Invalid pairs explain concrete exclusion reasons; generation errors remain CLI-only. | -| 10 | Help and Documentation | 3 | Inline caveats are strong; evaluation terms have no compact glossary. | -| **Total** | | **33/40** | **Good, above the Fleet floor after polish.** | - -## Design Specificity Verdict - -The report is authored for CodeVetter rather than a generic analytics -dashboard. Its sequence is the product's evidence model: claim boundary, -paired executable outcome, changed checks and graph traces, qualification, -activity diagnostics, and limitations. Amber remains the evidence accent and -cyan is reserved for graph provenance. - -The CLI detector returned zero findings. The rendered detector found 15 -advisory issues before polish: eight small-text or line-length findings and -seven cyan-palette findings. The cyan findings were false positives because the -color has a stable graph-provenance meaning. The small text, touch target, copy -measure, and mobile diagnostics issues were fixed. - -## Overall Impression - -The opening creates curiosity, then immediately constrains interpretation with -an unqualified claim. The paired corridor is the visual peak. The closing -authorized-claim block now restores that same boundary after the evidence -detail, so a long read ends with the correct decision. - -## What's Working - -- Qualification appears before the favorable synthetic percentage. -- Paired outcomes and hidden-check changes are readable without decorative - metric cards. -- Native details, semantic regions, a real data table, textual PASS/FAIL - labels, visible focus, and high contrast support accessible inspection. - -## Priority Issues - -### [P2] Large-run navigation - -The schema permits much larger experiments than the two-pair sample. A future -real corpus may need outcome filters, section anchors, or condensed tie rows. -This does not block the bounded local report. - -### [P3] Evaluation terminology - -A/A, discordance, and coverage are correct but assume statistical fluency. A -compact glossary may help less experienced product owners when real receipts -arrive. - -### [P3] Fixed dark presentation - -The tokenized fixed-dark report is coherent with CodeVetter and includes print -rules, but it does not offer an alternate light screen theme. - -## Persona Red Flags - -**Alex, power user:** The two-pair report is fast to scan, but dozens of pairs -would require outcome filtering and condensed ties. - -**Sam, accessibility-dependent user:** Semantic structure, contrast, keyboard -disclosures, 44px summary targets, and stacked mobile diagnostics now support -the core reading path. A future large corpus needs skip links or section -navigation. - -**Priya, technical product owner:** The synthetic and unqualified boundary is -now tied to both the comparison corridor and the closing verdict. Activity -deltas are neutral and explicitly mean less, not better. - -## Minor Observations - -- Long identities and source paths wrap safely. -- A zero-valid-pair state withholds the percentage corridor. -- Print semantic colors use darker values while retaining text labels. -- Missing optional diagnostics remain missing rather than becoming zero. - -## Questions to Consider - -- At what corpus size should the evidence brief become a navigable - investigation tool? -- Should real-trial reports define a tiny inline glossary for A/A noise and - qualification policy? diff --git a/.impeccable/critique/2026-07-30T06-34-52Z__apps-desktop-src-components-sidebar-tsx.md b/.impeccable/critique/2026-07-30T06-34-52Z__apps-desktop-src-components-sidebar-tsx.md deleted file mode 100644 index 4f4d8232..00000000 --- a/.impeccable/critique/2026-07-30T06-34-52Z__apps-desktop-src-components-sidebar-tsx.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -target: CodeVetter desktop sidebar -total_score: 33 -max_score: 40 -na_heuristics: -p0_count: 0 -p1_count: 0 -timestamp: 2026-07-30T06-34-52Z -slug: apps-desktop-src-components-sidebar-tsx ---- -Method: dual-agent (A: sidebar_critique_a · B: sidebar_critique_b) - -## Design Health Score - -| # | Heuristic | Score | Key issue | -|---|---|---:|---| -| 1 | Visibility of system status | 3 | Active location is clear; the transient G chord remains intentionally quiet. | -| 2 | Match system / real world | 3 | Product labels are established but assume some CodeVetter familiarity. | -| 3 | User control and freedom | 4 | Navigation is reversible and the palette now restores focus to its trigger. | -| 4 | Consistency and standards | 4 | Rows, grouping, focus, active state, and spacing follow the Evidence Bench system. | -| 5 | Error prevention | 3 | Shortcut handling protects form controls; contenteditable remains a narrow edge case. | -| 6 | Recognition rather than recall | 3 | Every destination is labeled; detailed descriptions remain in accessible tooltips. | -| 7 | Flexibility and efficiency | 4 | Search, Cmd-K, and G chords provide strong expert acceleration. | -| 8 | Aesthetic and minimalist design | 4 | The rail is calm, compact, and free of decorative feature noise. | -| 9 | Error recovery | 3 | Search dismisses cleanly and restores focus; mistimed G chords remain silent. | -| 10 | Help and documentation | 2 | Tooltips explain destinations, but the rail intentionally carries no dedicated help surface. | -| **Total** | | **33/40** | **Good; no blocking or major issues remain.** | - -## Design Specificity Verdict - -The sidebar is clearly adapted to CodeVetter through its Context and -Verification grouping, Evidence Workbench identity, warm verification accent, -real product routes, resource utility, and keyboard model. Its basic rail -composition is conventional, but the content and state grammar are not a -generic mockup. - -The deterministic scan returned zero findings across `App.tsx`, `sidebar.tsx`, -`ResourceChip.tsx`, and `command-palette.tsx`. Browser evidence confirmed AA -contrast, one accessible active destination, no overflow at supported desktop -sizes, a working Search trigger, and keyboard focus restoration. No reliable -browser overlay was available because the exposed evaluation surface was -read-only; live screenshots, computed styles, geometry, axe, and Playwright -interaction checks were used instead. - -## Overall Impression - -The new rail feels like a quiet native instrument and carries the reference's -search-first hierarchy without importing an unrelated cream visual system. The -main opportunity was finishing keyboard and control-size details, both of which -were corrected during the pass. - -## What's Working - -- The active state uses position, icon treatment, text, and `aria-current`, so - it is legible without color alone. -- Context, Verification, and bottom utilities produce a clear three-part - information hierarchy. -- Cmd-K, visible G chords, and direct search make the compact shell efficient - for repeat users. - -## Priority Issues - -- **[P1, fixed] Command palette dialog naming:** Opening Search exposed a Radix - accessibility error because the dialog had no screen-reader title. The - palette now includes a visually hidden `DialogTitle`, and the interaction - test asserts that opening and closing it emits no console error. -- **[P2, fixed] Palette focus restoration:** Closing Search initially returned - focus to the document body. The shell now remembers the invoking element and - restores focus after Radix closes. -- **[P2, fixed] Control sizing:** Root font sizing made Tailwind rem-based - 40px controls render at 35px. Search and navigation rows now use explicit - 40px dimensions and full 13–14px labels. -- **[P3] Silent G-chord timeout:** A mistimed chord has no feedback. This is - acceptable for a secondary expert accelerator, but could gain a tiny - transient key hint if real usage shows failures. -- **[P3] Destination descriptions rely on tooltips:** First-time users may - need a little exploration to distinguish Work, Board, Review, and Testing. - Existing product labels were preserved deliberately. - -## Persona Red Flags - -- **Power user:** Search and G chords are fast, but the 500ms G timeout may - feel unforgiving until learned. -- **First-timer:** The grouping helps, though the differences among Work, - Board, Review, and Testing are learned through tooltips and page content. -- **Keyboard or low-vision user:** The final build has a global amber focus - ring, 40px controls, AA contrast, semantic groups, text labels, and focus - restoration. No major barrier remains in the rail. - -## Minor Observations - -- The 224px rail stays proportionate at the configured 900px minimum window. -- The warm ambient wash respects the single-accent rule. -- The resource chip is absent in browser fallback because Tauri resource data - is unavailable; it remains present in the desktop runtime. - -## Questions to Consider - -- Should future usage evidence show the current repository or verification run - in this rail, or should project context stay inside the owning workspaces? -- If users do not discover G chords, would one compact shortcuts hint be more - useful than permanent suffixes? diff --git a/.impeccable/critique/2026-08-01T17-55-01Z__apps-desktop-src-app-tsx.md b/.impeccable/critique/2026-08-01T17-55-01Z__apps-desktop-src-app-tsx.md deleted file mode 100644 index 2a6b1ba4..00000000 --- a/.impeccable/critique/2026-08-01T17-55-01Z__apps-desktop-src-app-tsx.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -target: current CodeVetter desktop product and UI -total_score: 23 -max_score: 40 -na_heuristics: -p0_count: 1 -p1_count: 4 -timestamp: 2026-08-01T17-55-01Z -slug: apps-desktop-src-app-tsx ---- -# CodeVetter product and desktop critique - -## Strategic verdict - -CodeVetter is impressive engineering but not yet a coherent product. The repository contains a substantial local verification stack: bounded execution, runtime receipts, structural and historical evidence, deterministic scoring, a qualified synthetic task corpus, CLI/MCP boundaries, and unusually honest failure states. But that core is buried beneath an older AI-review workbench, repository-intelligence suite, usage dashboard, agent workspace, board, and native agent presentation layer. - -The July pivot exists in product documentation and newer harness work. It does not yet exist as the user's product. The desktop's default object is still a dashboard or repository; it should be a verification case. - -The focused job should be: - -> Given a task and an agent-authored change, did it actually work? Show the executable evidence, state what remains unverified, and make the result reproducible. - -Comparative agent and context experiments are the second job, powered by the same receipts. Graph context is an experimental input, not the product. - -## Competition - -The tools initially identified are several different markets: - -- pgGraph and HydraDB are graph infrastructure. They are not meaningful product competitors. -- CodeGraph, Graphify, and RepoWise are agent-readable context engines. RepoWise also spans human wiki, history, decisions, and code health, creating direct overlap with Repo Unpack. -- DeepWiki is primarily human-readable generated documentation and grounded Q&A. -- Sourcegraph is enterprise code search and multi-repository context. -- CodeRabbit and Qodo compete with the legacy Review proposition and have much stronger pull-request distribution. -- Harbor/Terminal-Bench and SWE-bench occupy coding-agent benchmark infrastructure. -- Braintrust and LangSmith occupy general experiment, dataset, scoring, tracing, and comparison infrastructure. - -CodeVetter should not try to beat focused context providers at indexing, established review vendors at PR distribution, or general evaluation platforms at horizontal breadth. Its credible wedge is local, software-specific, execution-backed verification with hidden checks, immutable evidence identities, contamination detection, and reproducible comparisons. - -## Design Health Score - -| # | Heuristic | Score | Key issue | -|---|---|---:|---| -| 1 | Visibility of System Status | 3 | Strong local states, but no unified verification-run status across surfaces. | -| 2 | Match System / Real World | 2 | Repo Unpack, T-Rex, warm verification, and Review with Claude obscure the core job. | -| 3 | User Control and Freedom | 3 | Good cancellation, retry, persistence, and reversible actions; deeper exits and undo vary. | -| 4 | Consistency and Standards | 2 | Coherent tokens, inconsistent page structures and navigation documentation. | -| 5 | Error Prevention | 3 | Strong validation and confirmations, but advanced forms expose too many paths. | -| 6 | Recognition Rather Than Recall | 2 | Users must remember how Repo, Review, Testing, and Work compose. | -| 7 | Flexibility and Efficiency | 3 | Strong shortcuts, persistent state, history, and expert affordances. | -| 8 | Aesthetic and Minimalist Design | 2 | Visually disciplined but functionally overloaded. | -| 9 | Error Recovery | 2 | Several actionable errors, but no consistent guided recovery model. | -| 10 | Help and Documentation | 1 | Onboarding teaches the outdated review product rather than verification evidence. | -| **Total** | | **23/40** | **Acceptable craft; substantial product simplification required.** | - -## Design Specificity Verdict - -### Design assessment - -Visually authored, structurally unfocused. The dark ink and warm amber Evidence Bench language is coherent and appropriate. The app feels technically serious. But the shell presents several historical products as peers, so it reads as a consolidated suite rather than one verification instrument. - -### Deterministic scan - -The detector reported 10 `gray-on-color` findings: five in Home, three in AgentPanel, and two in QuickReview. Source inspection makes six definite false positives and the remaining four likely false positives because the backgrounds are mutually exclusive branches or very low-opacity tints over dark surfaces. The scan did not reveal a systemic mechanical design defect. - -This reinforces the main conclusion: the highest-impact UI problems are information architecture, terminology, and hierarchy—not Tailwind color cleanup. - -### Visual overlays - -No reliable visual overlay is available. Browser control reported no connected browser, so mutable injection and screenshots could not be performed. Five representative Vite routes returned HTTP 200, which confirms routing only, not rendered quality. - -## Overall Impression - -The strongest moments are the honest receipt and no-confidence states in Testing and Review. The weakest moment is the product entrance: onboarding teaches model selection and AI review, then the app opens on usage telemetry. A user must cross several legacy concepts before reaching the differentiated product. - -The biggest opportunity is not a redesign of each page. It is choosing one canonical object—`verification case`—and reorganizing everything around it. - -## What's Working - -- Honest semantic states such as partial coverage, passed with limits, and no confidence are unusually good. -- Persistent routes, cancellation, retries, bounded output, and history show excellent operational care. -- The ink/amber system, evidence typography, focus treatment, and written status labels are a solid craft foundation worth preserving. - -## Priority Issues - -### P0 — The visible product contradicts the stated product - -**Why it matters:** The repo says CLI/MCP verification is primary and desktop is a receipt viewer. The app leads with Usage, Repo Unpack, Work, Board, Review, and Testing. The landing page still sells a desktop AI reviewer and makes claims about vulnerability classes and offline behavior. Users cannot form a stable expectation. - -**Fix:** Pick the verification product explicitly. Rewrite landing, onboarding, navigation, and the default route around one verification case. Remove unsupported claims and demote unrelated surfaces. - -**Suggested command:** `$impeccable shape` - -### P1 — The shell contradicts the core loop - -**Why it matters:** Launching into usage telemetry makes administration feel more important than determining whether a change is correct. Work and Board are agent-control products placed inside Verification. - -**Fix:** Use a minimal shell such as Verify, Runs, Experiments, and Settings. Put repository context inside a case; move Usage, Work, Board, and Agent Island to Labs/Legacy or remove them from primary navigation. - -**Suggested command:** `$impeccable distill` - -### P1 — Review and Testing split one user question across two products - -**Why it matters:** A user asks whether a change is correct. Review emphasizes model findings; Testing owns the strongest executable receipts. The user must mentally merge them. - -**Fix:** Model a verification case with stages: target and intent, checks, findings, runtime evidence, verdict, limitations, and next action. - -**Suggested command:** `$impeccable shape` - -### P1 — Results bury the verdict beneath accumulated features - -**Why it matters:** Review's sidebar contains roughly a dozen evidence, graph, QA, export, and audience systems. Equal visual weight makes source-backed limitations and next actions hard to locate. - -**Fix:** Pin verdict, evidence strength, limitations, and next action. Move graphs, audience simulation, X-Ray, synthetic QA, and exports behind secondary disclosure. - -**Suggested command:** `$impeccable distill` - -### P1 — Onboarding installs the wrong mental model - -**Why it matters:** It teaches model selection, usage stats, and AI review instead of task completion and executable proof. - -**Fix:** First run should select a repository/change, run one bounded check, and teach how to read a receipt, failure, and limitation. - -**Suggested command:** `$impeccable onboard` - -### P2 — Dense evidence presentation strains accessibility - -**Why it matters:** Critical context is often 9–11px and muted; dense sidebars create long keyboard paths. - -**Fix:** Increase essential evidence metadata size and contrast, simplify result order, and confirm effective runtime contrast visually. - -**Suggested command:** `$impeccable audit` - -## Cognitive Load - -High: seven of eight checklist areas fail. Grouping is generally good, but single focus, chunking, hierarchy, one-thing-at-a-time flow, minimal choices, working-memory burden, and progressive disclosure do not. - -Decision points above four include: - -- six primary destinations plus Settings and command search; -- up to eight Repo Unpack sections; -- eleven Settings categories; -- roughly a dozen Review result-side modules; and -- seven setup concepts inside expanded Review context. - -## Emotional Journey - -The user expects verification, encounters usage administration, becomes uncertain about which surface owns the task, then finally reaches excellent evidence language in Testing. The product peaks late and ends without one calm closure: verified, failed, or no confidence, followed by the next safe action. - -## Persona Red Flags - -**Alex, power user:** Strong shortcuts and persistent state do not answer whether the same change belongs in Repo, Review, or Testing. A trustworthy evaluation in under a minute is unlikely. - -**Jordan, first-timer:** Usage telemetry and AI-review onboarding create the wrong model before they encounter Repo Unpack, T-Rex, warm verification, and scenario compilation. - -**Sam, keyboard/low-vision user:** Focus and reduced-motion support are positive, but tiny muted evidence text and the long Review sidebar journey reduce practical accessibility. - -## Minor Observations - -- Design and surface documentation describe a top rail while implementation uses a fixed left rail. -- Board has a keyboard shortcut but is absent from the command palette. -- Page-title structures differ substantially by route. -- T-Rex is internal-history branding, not self-explanatory product language. -- The sidebar subtitle Evidence workbench is good; the rest of the IA does not yet fulfill it. -- The four largest page files total roughly 14,900 lines, mirroring feature and state accumulation in the user experience. - -## Questions to Consider - -- If Usage, Work, Board, Agent Island, and most Repo Unpack sections disappeared from primary navigation, would the actual verification product lose anything essential? -- Why are Review and Testing separate when the user asks one question: is this change correct? -- Does a panel change the verdict or explain its confidence? If not, why is it in the primary result view? -- Is CodeVetter a daily verification tool, an evaluation research lab, or a broad agent workbench? It cannot lead with all three. diff --git a/.impeccable/critique/2026-08-10T18-31-57Z__apps-desktop-src-pages-home-tsx.md b/.impeccable/critique/2026-08-10T18-31-57Z__apps-desktop-src-pages-home-tsx.md deleted file mode 100644 index 6e31755f..00000000 --- a/.impeccable/critique/2026-08-10T18-31-57Z__apps-desktop-src-pages-home-tsx.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -target: Usage telemetry evidence tiers -total_score: 36 -max_score: 40 -na_heuristics: -p0_count: 0 -p1_count: 0 -timestamp: 2026-08-10T18-31-57Z -slug: apps-desktop-src-pages-home-tsx ---- -## Design Health Score - -| # | Heuristic | Score | Key issue | -|---|---|---:|---| -| 1 | Visibility of system status | 4 | Verified, partial, stale, pending, and loading states are written explicitly. | -| 2 | Match system / real world | 3 | API-equivalent remains specialist language, now explained as not subscription spend. | -| 3 | User control and freedom | 4 | Reconcile and recovery settings are available at the diagnosis. | -| 4 | Consistency and standards | 4 | One reconciliation verb now owns the refresh path. | -| 5 | Error prevention | 4 | Legacy, ambiguous, stale, and unpriced data cannot masquerade as verified. | -| 6 | Recognition rather than recall | 4 | Recovery settings are linked in context. | -| 7 | Flexibility and efficiency | 3 | Aggregate categories are not yet drillable to individual sources. | -| 8 | Aesthetic and minimalist design | 4 | Evidence hierarchy is compact and uses the incumbent workbench language. | -| 9 | Error recovery | 3 | Recovery is complete, but source-level diagnostics remain aggregate. | -| 10 | Help and documentation | 3 | Inline pricing and recovery explanations cover the main uncertainty model. | -| **Total** | | **36/40** | **Excellent** | - -## Design Specificity Verdict - -The result is authored for CodeVetter's Evidence Bench. Accepted transcript observations, -scanner revision, observation watermark, exact/ranged/unpriced pricing, and explicit legacy -exclusion make the surface an evidence instrument rather than a generic analytics card. - -The deterministic detector returned five `gray-on-color` warnings in Home.tsx and none in -Settings.tsx. All five are contextual false positives: the background is translucent over ink or -the slate text classes are mutually exclusive with the cyan active state. Verified detector issue -count: zero. - -## Overall Impression - -The trusted number leads, uncertainty is written rather than hidden, and recovery is attached to -the diagnosis. The remaining opportunity is source/session drill-down, not another visual layer. - -## What's Working - -- Verified totals and legacy estimates are structurally separated. -- Cost bounds explain unknown service tier and disclaim subscription spend. -- Recovery is one bounded flow: import roots, then re-index and reconcile. - -## Priority Issues - -- **P2 — Aggregate diagnostics are not drillable.** Users can see affected counts but not the - source identities. Add a source-detail disclosure after the read cutover is qualified. -- **P3 — Narrow screenshots compress below the product contract.** The Tauri app enforces a 900px - minimum; 390px is retained as evidence but is not a supported window state. - -## Persona Red Flags - -- **Alex:** source-level evidence is not yet inspectable from the aggregate. -- **Sam:** the cost range is now explicitly API-equivalent and not subscription spend; written - partial coverage does not rely on color. -- **Riley:** import persistence failures are announced and the recovery action returns to a single - reconciliation path. - -## Minor Observations - -- Legacy period estimates remain expanded for continuity; a later release may collapse them once - users have migrated to verified reads. -- The app's documented and configured minimum width is 900px, so mobile-shell adaptation is out of - scope for this macOS desktop viewer. - -## Questions to Consider - -- Should the next qualified iteration expose the exact sessions behind each unresolved tier? -- Once verified coverage stabilizes, should the legacy blended summary become collapsed by default? diff --git a/.impeccable/critique/2026-08-15T20-33-20Z__apps-desktop-src-components-app-error-boundary-tsx.md b/.impeccable/critique/2026-08-15T20-33-20Z__apps-desktop-src-components-app-error-boundary-tsx.md deleted file mode 100644 index 3c800eac..00000000 --- a/.impeccable/critique/2026-08-15T20-33-20Z__apps-desktop-src-components-app-error-boundary-tsx.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -target: apps/desktop/src/components/app-error-boundary.tsx -total_score: 35 -maximum: 40 -p0: 0 -p1: 0 -p2: 1 -method: dual-agent -timestamp: 2026-08-15T20-33-20Z -slug: apps-desktop-src-components-app-error-boundary-tsx ---- -# CodeVetter crash recovery critique - -## Method - -Dual-agent review: a detector-blind visual/heuristic assessment plus an independent detector and responsive-browser evidence pass. The final state was then rechecked at 390, 768, and 1440 px after resolving the review findings. - -## Nielsen assessment — 35/40 - -| Heuristic | Score | Final assessment | -| --- | ---: | --- | -| Visibility of system status | 3 | The interruption, local receipt, and copy status are explicit; repeated retry has no attempt counter. | -| Match to the real world | 4 | Scope-aware language and plain recovery actions describe what happened and what each action does. | -| User control and freedom | 3 | Retry, reload, and Usage escape cover the common exits; Usage remains a best-effort app route. | -| Consistency and standards | 4 | Uses the established ink surface, amber action, semantic rose state, type, buttons, and focus treatment. | -| Error prevention | 3 | The boundary contains the failure and avoids unsupported safety claims; it does not add a repeated-failure safe mode. | -| Recognition over recall | 4 | Actions are visible and retry/reload behavior is stated directly. | -| Flexibility and efficiency | 3 | Keyboard recovery and copyable diagnostics are available without exposing raw details by default. | -| Aesthetic and minimalist design | 4 | The hierarchy stays focused: interruption, recovery, then local evidence. | -| Error recognition and recovery | 4 | Scope, three recovery routes, incident identity, and technical evidence are all visible. | -| Help and documentation | 3 | Technical details support reporting, but no dedicated troubleshooting route is present. | - -## Cognitive load — 8/8 - -The surface has one focus, three clearly grouped recovery choices, a short behavioral explanation, and progressive disclosure for diagnostics. No decision point exceeds four choices. - -## Accessibility and responsive evidence - -- Focus moves to the recovery heading on mount; the next Tab reaches the primary recovery action. -- The full-page alert was narrowed to the interruption announcement, leaving controls outside the live alert. -- Muted metadata uses the higher-contrast zinc-400 token. -- Axe reported no critical or serious violations in the focused Playwright check. -- Document scroll width matched client width at 390, 768, and 1440 px. - -## Findings resolved - -- **P1 resolved:** removed the categorical claim that the repository was unmodified. The UI now states that repository state was not checked. -- **P1 resolved:** application-shell failures now always expose a Return to Usage action in addition to retry and reload. -- **P2 resolved:** recovery takes focus, metadata contrast was raised, and retry versus reload behavior is explained. - -## Remaining advisory item - -- **P2:** if the same render failure repeats, the surface does not yet count attempts or escalate to a dedicated safe mode. This is a future reliability enhancement, not a blocker for the bounded recovery layer. - -## Detector and integrity - -The advisory detector returned an empty result (`[]`) across the recovery component and entry point. No production dependency was added, raw error messages and stacks are not persisted, and repository/query data is excluded from the local incident receipt. diff --git a/.impeccable/critique/2026-08-15T21-39-48Z__apps-desktop-src-pages-performance-tsx.md b/.impeccable/critique/2026-08-15T21-39-48Z__apps-desktop-src-pages-performance-tsx.md deleted file mode 100644 index 3e1f0590..00000000 --- a/.impeccable/critique/2026-08-15T21-39-48Z__apps-desktop-src-pages-performance-tsx.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -timestamp: 2026-08-15T21-39-48Z -slug: apps-desktop-src-pages-performance-tsx ---- -# Performance workbench critique - -Target: `apps/desktop/src/pages/Performance.tsx` - -## Outcome - -- Design heuristic score: 35/40 (good, near excellent). -- Automated detector: 0 findings. -- Responsive qualification: no horizontal overflow at 390, 768, or 1440 px. -- Accessibility structure: one main landmark, labelled workload controls, labelled evidence region, and accessible form names. -- Final severity: 0 P0, 0 P1. - -## Resolved during critique - -- Added a real same-scope paired-verification action and verdict-driven campaign states. -- Invalidated stale evidence when scope fields or the selected repository change. -- Cancelled and discarded late receipts from a prior repository generation. -- Added truthful blocked, failed, and no-confidence recovery states. -- Separated observed, inferred, and unverified evidence without truncating captured rows. -- Raised low-contrast operational copy and removed empty machine-detail rows. - -## Evidence - -- `artifacts/design/product-surfaces-after-390.jpg` -- `artifacts/design/product-surfaces-after-768.jpg` -- `artifacts/design/product-surfaces-after-1440.jpg` diff --git a/.impeccable/design.json b/.impeccable/design.json deleted file mode 100644 index ad45e6cb..00000000 --- a/.impeccable/design.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "schemaVersion": 2, - "generatedAt": "2026-07-29T00:00:00.000Z", - "title": "Design System: CodeVetter", - "extensions": { - "colorMeta": { - "canvas-ink": { - "role": "neutral", - "displayName": "Canvas Ink", - "canonical": "#060708", - "tonalRamp": [ - "#060708", - "#0c0d0f", - "#111316", - "#17191d", - "#35383e", - "#6c7078", - "#a1a1aa", - "#f4f4f5" - ] - }, - "action-amber": { - "role": "primary", - "displayName": "Action Amber", - "canonical": "#f3ad3d", - "tonalRamp": [ - "#2a1b05", - "#4b3008", - "#71490d", - "#9b6818", - "#c88728", - "#f3ad3d", - "#ffc75e", - "#fff0c7" - ] - }, - "failure-rose": { - "role": "semantic", - "displayName": "Failure Rose", - "canonical": "#fb7185", - "tonalRamp": [ - "#2e080e", - "#54121d", - "#7f2030", - "#aa3448", - "#d94f65", - "#fb7185", - "#fda4af", - "#ffe4e6" - ] - }, - "verified-green": { - "role": "semantic", - "displayName": "Verified Green", - "canonical": "#4ade80", - "tonalRamp": [ - "#052e16", - "#14532d", - "#166534", - "#15803d", - "#22c55e", - "#4ade80", - "#86efac", - "#dcfce7" - ] - } - }, - "typographyMeta": { - "title": { - "displayName": "Workbench Title", - "purpose": "Page, panel, and evidence-section headings." - }, - "body": { - "displayName": "Operating Body", - "purpose": "Instructions, summaries, and supporting context." - }, - "label": { - "displayName": "Compact Label", - "purpose": "Fields, controls, metrics, and metadata." - }, - "evidence": { - "displayName": "Evidence Mono", - "purpose": "Paths, revisions, commands, and machine identities." - } - }, - "shadows": [ - { - "name": "surface-ambient", - "value": "0 28px 80px -52px rgba(0, 0, 0, 0.92)", - "purpose": "Diffuse depth for major cards and overlays." - }, - { - "name": "action-warm", - "value": "0 12px 30px -18px rgba(243, 173, 61, 0.9)", - "purpose": "Restrained emphasis for primary action controls." - } - ], - "motion": [ - { - "name": "control-state", - "value": "150ms ease", - "purpose": "Color, border, shadow, and pressed-state transitions." - }, - { - "name": "content-enter", - "value": "200ms ease-out", - "purpose": "Short opacity and 4px translate entrance for newly available content." - } - ], - "breakpoints": [ - { - "name": "sm", - "value": "640px" - }, - { - "name": "lg", - "value": "1024px" - }, - { - "name": "desktop-window-min", - "value": "900px" - } - ] - }, - "components": [ - { - "name": "Primary Button", - "kind": "button", - "refersTo": "button-primary", - "description": "The single intentional action within a verification context.", - "html": "", - "css": ".ds-button-primary { height: 40px; padding: 8px 16px; border: 1px solid rgba(253,230,138,.2); border-radius: 10px; background: var(--cv-accent, #f3ad3d); color: #211609; font: 500 14px/1.25 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",sans-serif; box-shadow: 0 12px 30px -18px rgba(243,173,61,.9), inset 0 1px 0 rgba(255,255,255,.3); transition: background-color 150ms ease, transform 150ms ease; } .ds-button-primary:hover { background: var(--cv-accent-strong, #ffc75e); } .ds-button-primary:focus-visible { outline: 2px solid rgba(243,173,61,.88); outline-offset: 2px; } .ds-button-primary:active { transform: translateY(1px); }" - }, - { - "name": "Outline Button", - "kind": "button", - "refersTo": "button-outline", - "description": "A bounded secondary action that does not compete with execution.", - "html": "", - "css": ".ds-button-outline { height: 40px; padding: 8px 16px; border: 1px solid rgba(255,255,255,.11); border-radius: 10px; background: rgba(255,255,255,.035); color: #e4e4e7; font: 500 14px/1.25 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",sans-serif; box-shadow: inset 0 1px 0 rgba(255,255,255,.04); transition: background-color 150ms ease, border-color 150ms ease; } .ds-button-outline:hover { border-color: rgba(255,255,255,.18); background: rgba(255,255,255,.075); color: #fff; } .ds-button-outline:focus-visible { outline: 2px solid rgba(243,173,61,.88); outline-offset: 2px; }" - }, - { - "name": "Evidence Input", - "kind": "input", - "refersTo": "input", - "description": "A compact field for URLs, ranges, and verification parameters.", - "html": "", - "css": ".ds-input { width: 100%; height: 40px; padding: 8px 12px; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; background: rgba(255,255,255,.035); color: #f4f4f5; font: 400 14px/1.5 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",sans-serif; box-shadow: inset 0 1px 0 rgba(255,255,255,.025); transition: background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease; } .ds-input:hover { border-color: rgba(255,255,255,.15); } .ds-input:focus-visible { outline: 2px solid rgba(243,173,61,.15); outline-offset: 2px; border-color: rgba(252,211,77,.35); background: rgba(255,255,255,.05); }" - }, - { - "name": "Verification Card", - "kind": "card", - "refersTo": "card", - "description": "The primary workbench plane for one verification mechanism.", - "html": "

Test change in preview

Resolve exact source identity and return browser evidence.

", - "css": ".ds-card { padding: 20px; border: 1px solid rgba(255,255,255,.075); border-radius: 12px; background: var(--cv-surface, #0c0d0f); color: #f4f4f5; box-shadow: 0 24px 70px -50px rgba(0,0,0,.95), inset 0 1px 0 rgba(255,255,255,.025); } .ds-card h3 { margin: 0; font: 600 18px/1.25 \"SF Pro Display\",-apple-system,sans-serif; letter-spacing: -.018em; } .ds-card p { margin: 6px 0 0; color: #a1a1aa; font: 400 14px/1.5 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",sans-serif; }" - }, - { - "name": "Evidence Badge", - "kind": "chip", - "refersTo": "badge", - "description": "A written status or scope qualifier paired with semantic color.", - "html": "Passed with limits", - "css": ".ds-badge { display: inline-flex; min-height: 24px; align-items: center; padding: 4px 10px; border: 1px solid rgba(252,211,77,.2); border-radius: 9999px; background: rgba(252,211,77,.1); color: #fde68a; font: 500 12px/1 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",sans-serif; transition: background-color 150ms ease; } .ds-badge:hover { background: rgba(252,211,77,.16); } .ds-badge:focus-visible { outline: 2px solid rgba(243,173,61,.88); outline-offset: 2px; }" - } - ], - "narrative": { - "northStar": "The Evidence Bench", - "overview": "CodeVetter feels like a precise local instrument: dark, quiet, dense enough for technical work, and candid about the strength of every claim. Warm amber marks the next intentional action. Semantic colors communicate verified, warning, or failure states only when the same meaning is also written in text or expressed with an icon. The interface should recede behind source identities, runtime results, and limitations.", - "keyCharacteristics": [ - "Ink surfaces separated by restrained tonal steps and hairline borders.", - "Compact native-feeling controls with generous focus treatment.", - "Warm amber used sparingly for action, selection, and verification emphasis.", - "Monospace reserved for paths, revisions, commands, and evidence identities.", - "Every state remains understandable without color alone." - ], - "rules": [ - { - "name": "The One Warm Voice Rule", - "body": "Amber identifies intentional action or active verification context; it is not ambient decoration.", - "section": "colors" - }, - { - "name": "The Written State Rule", - "body": "Green, gold, rose, and blue may reinforce meaning, but a label or icon must communicate the same state.", - "section": "colors" - }, - { - "name": "The Evidence Type Rule", - "body": "Monospace signals data a user may compare, copy, or feed to another tool; prose and actions stay in the system sans.", - "section": "typography" - }, - { - "name": "The Flat Evidence Rule", - "body": "Evidence rows are stable nested planes; hover lift and decorative transform are reserved for actionable controls.", - "section": "elevation" - } - ], - "dos": [ - "Do lead with the action, exact identity, verdict, and limitation.", - "Do reuse the established card, input, button, badge, and focus patterns.", - "Do keep verification forms compact and preserve evidence below the action.", - "Do provide loading, empty, error, limited, failed, and no-confidence states with plain-language labels." - ], - "donts": [ - "Don't present model opinion, topology, or a fixture as executable proof.", - "Don't use amber across large decorative regions or for non-action accents.", - "Don't communicate pass, warning, or failure through color alone.", - "Don't add floating glass cards, hero typography, or agent theater to operating surfaces." - ] - } -} diff --git a/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml new file mode 100644 index 00000000..00f4cde0 --- /dev/null +++ b/.xcodebuildmcp/config.yaml @@ -0,0 +1,15 @@ +schemaVersion: 1 +enabledWorkflows: + - doctor + - macos + - project-discovery + - project-scaffolding + - swift-package + - coverage + - utilities +sessionDefaults: + workspacePath: ./apps/macos/CodeVetter.xcworkspace + scheme: CodeVetter +filePathRenderStyle: tree +showTestTiming: true +sentryDisabled: true diff --git a/DESIGN.md b/DESIGN.md index 22188127..a7080ad9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,10 +2,10 @@ name: CodeVetter description: An evidence-first verification workbench in ink, warm amber, and explicit semantic state. colors: - canvas-ink: "#060708" - surface-ink: "#0c0d0f" - raised-ink: "#111316" - elevated-ink: "#17191d" + canvas-ink: "#000000" + surface-ink: "#050506" + raised-ink: "#09090b" + elevated-ink: "#0d0d10" evidence-white: "#f4f4f5" secondary-gray: "#a1a1aa" muted-gray: "#8a8a93" @@ -100,7 +100,8 @@ oversized presentation typography on operating surfaces. **Key Characteristics:** -- Ink surfaces separated by restrained tonal steps and hairline borders. +- A true-black canvas with near-black working planes separated by restrained + hairline borders. - Compact native-feeling controls with generous focus treatment. - Warm amber used sparingly for action, selection, and verification emphasis. - Monospace reserved for paths, revisions, commands, and evidence identities. @@ -108,8 +109,20 @@ oversized presentation typography on operating surfaces. ## Colors -The palette is near-black ink with a single warm action voice and explicit -semantic evidence colors. +The palette is true-black ink with a single warm action voice and explicit +semantic evidence colors. Chrome remains true black; working planes rise only +1–5% above it so hierarchy survives without turning the product charcoal. +Hairline borders carry the remaining separation instead of grey fill. + +Light appearance is a native counterpart, not an inverted dark skin. It uses a +warm `#f7f6f3` canvas, quieter `#f1f0ed` chrome, white evidence planes, and a +soft `#f3f2ef` inspector. Amber keeps the same action and selection meaning; +amber text and icons deepen independently of the bright action fill, and +success, warning, and failure colors use darker light-mode counterparts so +normal-size evidence text retains at least 4.5:1 contrast. Semantic status +colors retain their written labels. Review, Testing, +Performance, and Runs must preserve the same evidence hierarchy and control +priority in both appearances. ### Primary @@ -119,10 +132,10 @@ semantic evidence colors. ### Neutral -- **Canvas Ink:** the application background and deepest visual plane. -- **Surface Ink:** the default card and panel plane. -- **Raised Ink:** controls and nested evidence tiles. -- **Elevated Ink:** overlays or deliberately elevated sub-surfaces. +- **Canvas Ink:** the true-black application background and deepest plane. +- **Surface Ink:** the almost-black default card and panel plane. +- **Raised Ink:** a subtly lighter control and nested-evidence plane. +- **Elevated Ink:** the highest near-black overlay or inspector plane. - **Evidence White:** primary text and decisive result labels. - **Secondary Gray:** supporting explanations and metadata. - **Muted Gray:** placeholders and low-priority context. @@ -159,8 +172,8 @@ feed to another tool; prose and actions stay in the system sans. ## Layout The desktop shell has a persistent top navigation and repository sidebar, with -a configured minimum window width of 900px. Operating pages use a centered, -wide workbench column and stack compact bordered panels vertically. +a configured minimum window size of 980 x 640 points. Operating pages use a +centered, wide workbench column and stack compact bordered panels vertically. Cards use 20px internal padding and 24px section rhythm by default. Dense form rows may align horizontally when space permits, then stack without changing diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 2275663f..809baf10 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -1,6 +1,6 @@ # Project Status -Last updated: 2026-08-24 +Last updated: 2026-09-02 ## Why / What @@ -39,7 +39,6 @@ separately approved safety design justify renewed investment. External: - Bundled `ccusage` 20.0.20 sidecar — local, offline Claude/Codex/Grok usage accounting; exact updates are opened weekly and remain qualification-gated. -- User-supplied LLM API keys (Anthropic / OpenAI / OpenRouter) stored in user settings — no server-side auth. - Installed and authenticated Codex or Claude CLI for Work conversations; provider account policy remains external to CodeVetter. - GitHub Releases + GitHub Actions — `auto-release.yml` cuts a `v` release on `tauri.conf.json` version bumps, dispatching `release.yml` to build/sign/upload Tauri binaries; `@tauri-apps/plugin-updater` consumes the `latest.json` manifest. - Cloudflare Pages — hosts the landing page (`codevetter` project, codevetter.com). @@ -56,6 +55,263 @@ Internal (fleet): ## Timeline +- **2026-09-02 — Independent Claude and Codex review (unreleased source):** + native Review and `codevetter check --agent cross` now request two sequential, + independent passes against the same immutable Rust-owned target and context. + The second reviewer receives no first-review output. Deterministic + reconciliation uses exact source-qualified path, line, and anchor identity; + title similarity never merges findings, a unique high-risk finding remains + actionable, and severity disagreement remains visible. Missing executors, + target drift, incomplete coverage, cancellation, or an unqualified candidate + fail closed without a composite finding claim. Persisted receipts preserve + reviewer-specific manifests, qualified candidates, readiness, duration, and + explicit usage/raw-candidate availability limits; repository-scoped MCP can + inspect the same receipt but cannot execute or cancel it. The 35-state native + packet includes dark/light cross-review evidence. Provider-backed caught-bug + 27-case provider-backed corpus run found 29/29 labels with Claude, 28/29 with + Codex, and 29/29 with the union. Cross-review recovered one low-severity + unused-helper label over Codex, but emitted 99 findings versus 46 and took + 187.5 seconds per case versus 99.1 seconds; usage/cost remained unavailable. + Cross-review is therefore qualified as an optional high-recall strategy and + is not the default. + +- **2026-09-02 — Native owner-quality refinement (unreleased source):** the + Evidence Workbench preserves a true-black canvas and chrome while restoring + restrained 1--4% near-black separation across working planes. Standalone + Review proof-map and intent renders now own an opaque canvas, and the owner + packet deterministically captures both search-only and rich repository-query + states, plus light-appearance Review, Testing, Performance, Runs, history + recovery, memory inspection, Agent Island configuration, and read-only Ops + status. All 35 + current-tree image hashes match the manifest. The same pass + reproduced and fixed a repository-query cancellation hang by closing stdin, + granting a bounded 200 ms termination grace, and using a final kill only for + the exclusively owned read-only worker; its test requires settlement within + one second. Appearance-aware amber and semantic evidence foregrounds now + meet a checked 4.5:1 normal-text contrast floor in dark and light modes. The + native Usage settings and CLI now share a bounded Rust history-root receipt: + selected Codex session folders normalize to their canonical home, unrelated + directories fail closed, and add/remove never reads or deletes transcripts. + Agent and MCP authority remain unavailable. Native Settings and + `codevetter memories` now share a read-only `codevetter.memories/v1` receipt: + Rust discovers bounded known locations, exposes only existing sources through + opaque identities and non-absolute display paths, caps reads/output, and + redacts secret-like content and Git-diff lines heuristically. Memory editing + and agent/MCP projections remain unavailable. The same Rust settings receipt + now preserves all 12 non-secret Agent Island preferences across native UI, + CLI, and the retained helper. Native labels configuration as live and the + supervised runtime as pending; the helper remains off by default and is not + launched by the Evidence Workbench. Native Settings and `codevetter ops` + now share a fixed-window `codevetter.ops-status/v1` receipt for local + configuration presence and aggregate run evidence. It excludes credentials, + webhook URLs, provider calls, webhook sends, writes, and agent/MCP authority. + The retained Tauri shell now attempts the sanitized one-time custom-rubric + transfer on every startup until Rust owns a canonical preference. Existing + Rust state wins, invalid legacy state writes nothing, browser-only mode does + not invoke Tauri, and the Rubrics surface remains a visible retry path. + Isolated frontend and Rust tests pass; installed WebView-to-native + qualification remains an explicit upgrade gate. + The isolated hosted lane passes 81 Swift tests, all nine XCUITests, Debug and + coverage-free Release macOS builds, and unsigned preview packaging without + using the operator's desktop. A read-only + current-package receipt binds the exact + `qualification-5r7JG4` candidate and measures a 62.4% smaller app bundle and + 92.4% smaller host executable than + the retained Tauri Release bundle; it does not refresh the historical launch + or settled-memory comparison. + Release-only fat LTO and one Rust codegen unit reduce the CLI and MCP + companions by 14.6% and 27.5% versus the prior candidate. Native Release + postprocessing removes test coverage instrumentation, strips the shipped host + while preserving its adjacent dSYM, and is enforced by the package gate. + The exact packaged MCP passes its fully sampled 50-start/200-round + qualification with 8.52 ms cold-initialize p95, 30.11 MiB ending RSS, 28 + strict read-only tools, and no TCP listener. + The complete all-feature Rust lane now passes 1,105 tests with 31 intentional + ignores, strict Clippy, and formatting. The retained frontend passes 680 unit + tests with one intentional skip, its separate 20-scenario live warm gate, + package-scoped TypeScript, and a production Vite build. + The Rust-generated capability glossary now has a deterministic native render + proving the external-collector split: CLI execution is available, a native + collector workflow remains planned, and agent authority remains unavailable. + Stale matched-comparison next steps now point to exact-package foreground, + responsiveness, energy, and long-session evidence instead of asking for an + already-completed comparison. + A manifest-locked local gallery exposes all 35 original-pixel dark/light + renders without external assets or network calls; its test fails on missing + or duplicate states. The final audit replaced the active Rubric pack's + washed-out disabled amber action with a high-contrast green `Selected` + receipt in both appearances; only available packs retain the amber action. + This evidence is ready for owner review but does not infer visual acceptance. + +- **2026-09-02 — Native hosted qualification (unreleased source):** GitHub + Actions run 33609288529 passed at exact source commit `824a9e8b`. The existing + Linux product lane remained green, while the isolated arm64 `xcode-27` lane + passed 81 Swift tests, all nine XCUITests, Debug and coverage-free Release + builds, the 33-state owner packet, ad-hoc ZIP/DMG packaging, and read-only + release inspection. Hosted large-receipt render p95s were 96.536 ms Repo + Unpack, 105.896 ms Usage, 121.065 ms Performance, 55.087 ms Testing, and + 64.132 ms Runs, all below the unchanged 150 ms gate. The exact package and + evidence identities are recorded in + `evidence/verification/native-hosted-qualification-2026-09-02.md`. The + hosted images are internally manifest-bound but not byte-identical to the + earlier local packet, so owner visual acceptance remains explicit. The + candidate correctly remains `shipping_ready: false`: production identifier + transfer, Developer ID signing, Library Validation, updater inputs, + notarization/Gatekeeper, installed upgrade/rollback, exact-package runtime + evidence, and the Tauri retirement decision remain open. + +- **2026-09-02 — Native release preflight (unreleased source):** a read-only + `codevetter.native-release-readiness/v1` inspector now binds the exact staged + app to its local qualification, verifies package/signature/runtime/updater + boundaries, and accepts only archive-bound notarization plus + production-identity installed-upgrade proofs. The current preview passes 7 + of 17 checks but correctly remains `shipping_ready: false` on ten production + gates: bundle transfer, Developer ID host/companion signing and one team, + Library Validation, HTTPS appcast, EdDSA key, archive-bound appcast, + Gatekeeper, notarization, and + installed upgrade/relaunch/data/rollback evidence. The installed proof now + binds the exact archive and build and requires a non-empty stable-record + fingerprint across native relaunch and rollback. A dependency-free, + read-only SQLite probe produces that content-free continuity projection and + fails on any missing incumbent identity. Its qualification used isolated + fixtures only. The inspection did not read credentials, sign, notarize, + install, publish, or change the installed Tauri application. + +- **2026-09-01 — Native macOS package candidate (unreleased source):** the + native AppKit/SwiftUI Evidence Workbench now builds as a hardened, + intentionally non-sandboxed Release app with exact Sparkle 2.9.6 wiring that + remains disabled for the preview identifier. A repository-owned qualifier + reuses the existing Rust and ccusage sidecar builders, packages the canonical + `codevetter`, `codevetter-mcp`, `ccusage`, and performance runtime capsule, + preserves Sparkle framework symlinks, smoke-tests every companion, verifies + deep signatures and runpaths, and produces local ZIP/DMG artifacts. The + staged package passed five alternating, surface-confirmed launches on the + populated Performance workspace at 117,424 KiB median process-tree RSS and + has checked true-black visual evidence. A matched five-by-five Release + comparison records startup parity (435.120 ms native versus 419.018 ms Tauri + first-visible-window median), 30.5% lower native settled RSS, and a 51.5% + smaller qualified native bundle. Native Review now enters one + Tauri-independent Rust application service: a bounded request id correlates + `codevetter.verification-command/v1`, ordered `codevetter.progress/v2` + events, request-scoped `codevetter.verification-cancel/v1`, and the distinct + preflight or final canonical receipt. Foreign progress, cancellation, and + terminal receipts fail closed. One shared no-confidence fixture now proves + equivalent request, stage, limitation, verdict, and exit semantics through + Rust, CLI, and native; the repository-scoped MCP + `verification_get_receipt` projection reads that same persisted canonical + receipt without gaining start or cancellation authority. The final packaged + sidecar exposes 28 strict read-only tools with no TCP listener. Native Review, the CLI, and local agent + invocation also share a Rust-owned, explicit-consent isolated-fix contract: + one detached worktree, bounded diff, recorded correctness rerun, + source-qualified re-review, per-finding fixed/reproduced/unchecked status, + retained owner inspection, and separately confirmed discard. There is no + commit, merge, or push action. Repo Unpack can now create a model-free local + snapshot from native UI or `codevetter unpack --operation scan` through one + Rust-owned scan/persistence boundary, then inspect stored Overview, Brief, + Activity, Inventory, and bounded Graph evidence in the native workspace. + The versioned receipt removes the raw file list from the client projection, + reports scan and persistence profiles separately, and continues to label + topology, history, and health as non-executable evidence. The native Graph + desk and `codevetter unpack --operation query` share the versioned + `codevetter.repo-query/v2` projection over the canonical structural and + temporal query services already used by MCP. Search, node explanation, + bounded impact, directed path, and causal trace retain index freshness, + trust, source anchors, and explicit unavailable coverage; Swift performs no + ranking or traversal. A scoped read-only worker prepares one search-only + canonical snapshot in the background, then upgrades it in place with compact + traversal edges only when requested while rechecking snapshot identity and + live-Git freshness. On the qualified 115,884-node graph, warm Release medians + measured 36.07 ms search, 35.15 ms explain, 116.55 ms impact, 68.38 ms path, + 32.46 ms history search, and 32.21 ms causal trace. Search-only RSS measured + 242.6 MiB and rich traversal RSS 307.6 MiB after rejecting a 511.9 MiB full + snapshot prototype. Contract/parser/render tests, 76 Swift tests, inspected + true-black evidence, and the quiet native compile gate pass. Native repository + selection now restores one + security-scoped bookmark across launches, and Usage applies 1w/30d/90d/all + windows consistently to ccusage charts, totals, models, and sessions while + the separate indexed Devin desk follows the same window for sessions, + generated/cache tokens, cost, and model rows. Live quota telemetry remains a + separate credential-sensitive migration. Review receipts now add a + Rust-owned `codevetter.review-intent-diagnostic/v1` projection across native, + CLI JSON, and local-agent output: it preserves the stated goal, deterministic + changed-surface classes, source-review and recorded-QA signals, gaps, and a + human-only closure boundary. Native Review gives this diagnostic a dedicated + true-black evidence desk and can reveal validated recorded QA artifacts in + Finder without promoting legacy QA into revision-exact proof. Its execution + action hands the exact repository and range or pull request to Testing, + clears stale proof and consent, and requires a preview plus fresh explicit + browser-run confirmation there. Native Testing, `codevetter qa`, and the + scoped read-only `qa_workspace_inspect` MCP tool now consume one Rust-owned + `codevetter.qa-workspace/v1` receipt for saved workflows and targets, + repository Playwright spec discovery, and deterministic post-fix rerun + setup. Legacy fields are projected into a separate native preference without + storage-state paths or arbitrary external commands; a selected route and goal + enter the canonical T-REX receipt, while preview consent is always reset. + Review will not regain a second browser execution authority. A real-agent fix + plus real saved-flow rerun smoke remains open. The populated + native app now also consumes `codevetter.onboarding/v1` for first-run state: + it honors the incumbent completion preference, checks executable presence + without inspecting authentication or credentials, transactionally saves + only the declared default adapter plus completion, and renders four + true-black Purpose, Readiness, Agent, and Workbench states. The same receipt + is available through `codevetter onboarding`, while About can reopen the tour + without changing completion state. The populated + native Performance receipt now lazily renders visible evidence rows and + repeated its 100-row gate three times at 41.867, 46.009, and 35.226 ms render + p95. Qualification conservatively uses the 46.009 ms worst run against the + unchanged 150 ms gate. This is not a release: Developer ID signing, + notarization, production appcast and EdDSA inputs, installed update/rollback, + workload/energy/long-session comparison, remaining feature/accessibility + parity, and owner retirement approval are still open. + +- **2026-08-31 — External performance, testing, and MCP evidence adapters + (unreleased source):** verification-receipt ingestion now accepts Playwright + JSON, JUnit XML, LCOV, Cobertura XML, Lighthouse JSON, and Chrome trace JSON + while preserving raw artifact identity, hashing failure text, rejecting XML + entities, and keeping observation-only formats at `no_confidence`. A three-run + Lighthouse CI trial passed the proposed landing-page gates, but the dependency + was removed after the high-severity audit exposed an unpatched transitive + archive traversal. Upstream Size Limit now follows rather than replaces the + desktop's Tauri-aware bundle budgets. `pnpm verification:dogfood` now runs the + active Playwright and c8 suites, emits Playwright JSON, JUnit, LCOV, and + Cobertura through their built-in reporters, and successfully ingests all four + while retaining `no_confidence` outside their proof. The run also removed the + retired Work/Board E2E inventory and exposed then fixed a duplicate React key + in Testing. The maintainer Codex client has pinned + isolated Chrome DevTools and Playwright MCPs plus named CodeVetter runtime and + receipt MCPs. GitHub's checksum-verified official v1.11.0 local server replaces + the PAT-dependent remote registration, with read-only relevant toolsets and + narrow in-memory browser OAuth. Live repository, pull-request, Actions, issue, + and CodeQL reads verify the connection without exposing a write tool. The + packaged CodeVetter graph/history MCP is also enabled for the canonical + checkout from the app-generated opaque configuration. Its history and + tree-sitter structural indexes are current at the checked-out HEAD, and direct + protocol smoke tests returned bounded `history_search` and `graph_query` + results with local access-audit rows. No product release or deployment is + claimed; work is tracked in issue #200. + +- **2026-08-31 — Bounded external collector foundation (unreleased source):** + Added `codevetter collect` and a shared Rust `codevetter.tool-collection/v1` + receipt for one exact clean checked-out Git range. The first adapter executes + an exact Gitleaks 8.30.1 bundle sibling or explicit debug/test override without a + shell, with a minimal environment, timeout, bounded output, binary/config + identity, repository-relative normalization, and raw secret fields removed + before serialization. cargo-audit and cargo-llvm-cov currently return + explicit `unavailable` evidence after optional exact-version preflight; they + do not install tools, fetch advisory data, or claim coverage. No sidecar is + yet bundled and no release claim is made; packaging and the remaining + collectors stay tracked in issue #198. + +- **2026-08-31 — Apple Container trial qualified with a containment caveat:** + Installed the signed/notarized 1.3.1 CLI after owner authorization and + exercised a 1-CPU/256-MB, internal-network, no-DNS, read-only-root sandbox on + the supported arm64 macOS 27 host. The cached no-op run took 0.61 seconds and + teardown left zero containers. A controlled traversal fixture proved the CLI + does not enforce CodeVetter's workspace-root boundary, so any adapter must + canonicalize and reject out-of-root mounts itself. This is external-prerequisite + qualification, not a bundled dependency or architecture approval; issue #197 + remains open. + - **2026-08-24 — Unified local change check (unreleased source):** the packaged `codevetter` CLI now accepts one clean checked-out PR head or Git range plus task intent and emits `codevetter.local-check/v1`. The runner resolves exact @@ -397,7 +653,7 @@ Internal (fleet): - **2026-07-03 — Surface consolidation + finishes (multi-agent pass):** removed redundant standalone pages QaReplay (`/qa-replay`) and IntentDebugger (`/intent-debugger`) — their functionality lives in Review. Finished Rubrics (review↔pack linkage via `local_reviews.standards_pack`, exact prompt preview, per-pack usage stats, pack cloning), T-Rex (per-watcher error recovery + retry, run drill-down dialog with persisted findings/log excerpt, pre-flight gh/token validation, per-PR base-branch inference), and AgentMemories (copy-as-markdown export, substring//regex/ line filter, git-diff-vs-HEAD view with secret redaction). Refactored QuickReview.tsx 6,264→3,050 lines into 12 components + 4 lib modules (behavior-preserving, 15 commits). Raw-Claude baseline scored on the 27 public benchmark cases (catch 0.931 / precision 0.397 / F1 0.557); CodeVetter's own comparator slot still needs generation before head-to-head claims. - **2026-07-03 (shipped in v1.2.8) — By-model cost attribution fix:** session-level `model_used` is last-model-wins, so multi-model Claude sessions booked ALL tokens/cost to the final model (a 211MB session with 17k opus-4-7 messages + 1.6k fable-5 messages billed $3.6k entirely to fable). Fix: per-message `session_model_usage` table populated by the indexer + one-time streaming backfill over existing Claude JSONL; by-model panel and per-session costs now sum per-model parts. Also added Fable/Mythos 5 pricing ($10/$50; was falling to sonnet default), folded `` into "unknown", and removed the Top-projects cost panel from Home (with its query/command/IPC). Verified by replaying the fix over the live DB: opus-4-7 $21,986→$29,473 (was under-credited), fable-5 correctly repriced. Guarded by `multi_model_claude_session_splits_usage_per_model`. - **2026-07-03:** Removed legacy Next.js landing page (`apps/landing-page`) — fully superseded by Astro site; `next-env.d.ts` git-removed, stale doc references cleaned up. -- **2026-07-03:** Published 27 hand-labeled public benchmark cases (`benchmark/cases/`) covering 7 languages (TypeScript, Python, Go, Rust, JavaScript, Java) and 15+ vulnerability types (SQL injection, XSS, hardcoded secrets, race conditions, path traversal, SSRF, prototype pollution, regex DoS, zip bombs, etc.). Scorer script (`scripts/run-public-benchmark.mjs`) validates labels and computes catch-rate/precision/F1 per reviewer. `pnpm bench:public`. Enterprise claims now backed by external, repeatable proof. +- **2026-07-03:** Published 27 hand-labeled public benchmark cases (`benchmarks/public-catch-rate/cases/`) covering 7 languages (TypeScript, Python, Go, Rust, JavaScript, Java) and 15+ vulnerability types (SQL injection, XSS, hardcoded secrets, race conditions, path traversal, SSRF, prototype pollution, regex DoS, zip bombs, etc.). Scorer script (`scripts/run-public-benchmark.mjs`) validates labels and computes catch-rate/precision/F1 per reviewer. `pnpm bench:public`. Enterprise claims now backed by external, repeatable proof. - **2026-07-02/03:** Streamlined telemetry + fleet navigation, guarded manual deploy command in CI, polished repo intelligence evidence surfaces. - **2026-06-28:** Devin agent indexing, agent hide/show filter, Grok parser improvements; PROJECT_STATUS audited as source of truth. - **2026-06-21 (v1.1.99) — Codex cost over-count fix:** Codex reports session-CUMULATIVE token totals; the incremental indexer was ADDING that running total every pass, inflating one session to 61.5B tokens / $35k (true: 391M / ~$220) and making "today" read ~$12.9k. Fix: `tokens_absolute` flag so cumulative tokens are SET not added, plus a one-time `fix_codex_token_totals` repair re-reading each Codex file. Verified on a live-DB copy: today $12,896→$377, year $82k→$38k (Claude cache-read costs, which are real, dominate the remainder). Guarded by `eval_append_delta_sets_cumulative_tokens_but_adds_per_message`. @@ -471,7 +727,7 @@ Internal (fleet): The corpus passes its contract-readiness publication gates; it does not by itself establish agent quality, provider superiority, or product value. - `--evidence-comparison=with:without` mode compares stored outputs with and without deterministic evidence search. -- 27 hand-labeled public benchmark cases (`benchmark/cases/`) covering 7 languages and 15+ vulnerability types; `pnpm bench:public` scores catch-rate/precision/F1. +- 27 hand-labeled public benchmark cases (`benchmarks/public-catch-rate/cases/`) covering 7 languages and 15+ vulnerability types; `pnpm bench:public` scores catch-rate/precision/F1. ### Evidence Pattern Search - Deterministic risk candidate packets from changed files, sensitive paths, optional `ast-grep` structural matches, blast/history context, and verification signals; top candidates and procedure gates injected into review prompts. @@ -499,7 +755,7 @@ Internal (fleet): ### Queryable codebase history - Repo Unpacked persists a backward-compatible schema-v2 history graph connecting bounded commit files, decisions, verification hints, and co-change leads with citations and trust labels. - Local queries prefer exact file/ID/label matches, rank broader terms, expand one hop, and state confidence, no-match, and truncation explicitly without mutating snapshots or creating findings. -- Settings can expose one explicitly enabled indexed repository through the packaged read-only `codevetter-mcp` stdio sidecar. Twenty-four strict tools cover task-level review preparation, graph queries, releases, search, as-of state, lineage, explanations, causal traces, comparisons, review manifests, business-rule archaeology, and evidence hydration; opaque versioned resources provide paginated discovery without absolute paths or credentials. +- Settings can expose one explicitly enabled indexed repository through the packaged read-only `codevetter-mcp` stdio sidecar. Twenty-six strict tools cover capability and evidence-scope discovery, task-level review preparation, graph queries, releases, search, as-of state, lineage, explanations, causal traces, comparisons, review manifests, business-rule archaeology, and evidence hydration; opaque versioned resources provide paginated discovery without absolute paths or credentials. ### App shell and UX - Home opens to usage dashboard (Today / Week / Month / Year counters); Repo holds repository context and Activity; Settings holds operational tools and preferences. diff --git a/README.md b/README.md index 3c61ff6d..79ff7365 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,10 @@ apps/ desktop/ Tauri 2 + React 19 + Vite desktop app — the core product landing-page-astro/ Astro marketing site (static export, deployed to Cloudflare Pages — codevetter.com) docs/ Canonical knowledge system — see docs/index.md -benchmark/ Public catch-rate benchmark cases + harness -openspec/ Spec-driven workflow (specs + changes/archive) +docs-site/ Blume presentation layer for docs/ (generated output is gitignored) +benchmarks/ Evaluation corpora (public catch-rate, agent PRs, runtime challenges) +evidence/ Committed run evidence (design, performance, reviews) +scripts/ Benchmark + corpus + deploy + doc-validation scripts ``` > The legacy Next.js `apps/landing-page/` was removed on 2026-07-03. The diff --git a/agents.md b/agents.md index 8af494cc..5a3aae44 100644 --- a/agents.md +++ b/agents.md @@ -7,6 +7,14 @@ commands are authoritative; no sibling Fleet checkout is required. Protect production stability, keep changes scoped, verify work with repo-local checks, and record durable follow-up in this repository's GitHub Issues. +Do not run XCUITest, desktop runtime comparison, AppleScript, accessibility +control, or any automation that can launch apps, take focus, type, click, or +manage windows while the operator is using the Mac. Foreground automation +requires fresh authorization for an idle-screen window on that invocation; +never infer or persist approval. Prefer the background-safe native lane and +offscreen render gates. Use a dedicated graphical macOS runner for unattended +interaction qualification. + ## Purpose CodeVetter is an execution-backed verification and evaluation system for coding agents. It determines whether an agent completed a software task correctly @@ -56,14 +64,19 @@ apps/ tests/ # Playwright e2e tests landing-page-astro/ # Astro marketing site → Cloudflare Pages (codevetter.com) docs/ # Canonical knowledge system — see docs/index.md -benchmark/ # Public catch-rate benchmark cases + harness -scripts/ # Benchmark + deploy + doc-validation scripts +docs-site/ # Blume presentation layer for docs/ (NOT the source of truth) +benchmarks/ # Evaluation corpora — public-catch-rate/, agent-prs/, + # agent-tasks/, runtime-challenges/, context-*/, performance-lab/ +evidence/ # Committed run evidence: design/, performance/, reviews/ +scripts/ # Benchmark + corpus + deploy + doc-validation scripts .github/workflows/ # ci, auto-release, release, deploy-landing, weekly, docs -blume.config.ts # Blume presentation layer for docs/ (NOT the source of truth) STATUS.md # Compatibility pointer PROJECT_STATUS.md # Current/shipped product truth (fleet source of truth) ``` +`artifacts/` is gitignored scratch for CLI runs (`--out artifacts/...`). +Committed evidence belongs in `evidence/`. + ## Key commands ```bash # From apps/desktop/ @@ -121,7 +134,7 @@ node scripts/check-docs.mjs # Validate docs (links, frontmatter, structure) The committed Markdown under `docs/` is the **source of truth** for product knowledge, architecture, decisions, workflows, operations, learnings, and -failed approaches. Blume (`blume.config.ts`) is only the presentation/search +failed approaches. Blume (`docs-site/blume.config.ts`) is only the presentation/search layer — generated output (`.blume/`) is gitignored. - **Navigation hub**: `docs/index.md` diff --git a/apps/desktop/.size-limit.json b/apps/desktop/.size-limit.json new file mode 100644 index 00000000..6452c5a9 --- /dev/null +++ b/apps/desktop/.size-limit.json @@ -0,0 +1,8 @@ +[ + { + "name": "Complete lazy JavaScript distribution (raw)", + "path": "out/assets/*.js", + "limit": "1800 kB", + "brotli": false + } +] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b04aa7de..3dbf837f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,11 +22,11 @@ "qualify:agent-island:release": "node scripts/verify-agent-island-release.mjs", "tauri:dev": "pnpm prepare:mcp-sidecar && pnpm prepare:ccusage-sidecar && pnpm prepare:agent-island && tauri dev", "tauri:build": "tauri build", - "test": "npx playwright test", + "test": "playwright test", "test:unit": "node --import tsx --test \"src/**/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", - "test:coverage": "c8 node --import tsx --test \"src/**/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", - "test:e2e": "npx playwright test", - "test:e2e:ui": "npx playwright test --ui", + "test:coverage": "c8 --reporter=text --reporter=lcov --reporter=cobertura node --import tsx --test \"src/**/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test:review-proof": "node --import tsx --test src/lib/review-proof.test.ts", "test:agent-fix-packet": "node --import tsx --test src/lib/agent-fix-packet.test.ts", "test:synthetic-qa": "node --import tsx --test src/lib/synthetic-qa/apply-evidence.test.ts src/lib/synthetic-qa/fixture-runner.test.ts", @@ -38,7 +38,8 @@ "verifyd": "node --import tsx src/lib/warm-verification/daemon-entry.ts", "test:verify": "node --import tsx --test \"src/lib/warm-verification/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", "lint": "biome check .", - "bench:bundle": "node scripts/bundle-budget.mjs", + "bench:bundle": "node scripts/bundle-budget.mjs && size-limit", + "bench:bundle:upstream": "size-limit", "bench:history-ui": "node --import tsx scripts/history-workbench-benchmark.ts", "bench:mcp": "node scripts/mcp-benchmark.mjs", "bench:mcp:smoke": "node scripts/mcp-benchmark.mjs --smoke", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 859fc455..a291e93c 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -7,7 +7,12 @@ export default defineConfig({ timeout: 30_000, retries: 0, workers: 1, - reporter: [['list'], ['html', { open: 'never' }]], + reporter: [ + ['list'], + ['html', { open: 'never' }], + ['json', { outputFile: 'test-results/playwright.json' }], + ['junit', { outputFile: 'test-results/junit.xml', includeProjectInTestName: true }], + ], use: { baseURL: 'http://localhost:1420', viewport: { width: 1280, height: 800 }, diff --git a/apps/desktop/scripts/mcp-benchmark.mjs b/apps/desktop/scripts/mcp-benchmark.mjs index f5316376..cfc6a13f 100644 --- a/apps/desktop/scripts/mcp-benchmark.mjs +++ b/apps/desktop/scripts/mcp-benchmark.mjs @@ -7,7 +7,7 @@ import { createInterface } from 'node:readline'; const PROTOCOL_VERSION = '2025-11-25'; const MAX_STRUCTURED_RESPONSE_BYTES = 256 * 1_024; -const EXPECTED_TOOL_COUNT = 24; +const EXPECTED_TOOL_COUNT = 28; const EXPECTED_RELEASE_COUNT = 64; const EXPECTED_GRAPH_NODE_COUNT = 512; const EXPECTED_GRAPH_EDGE_COUNT = 1_024; @@ -18,12 +18,14 @@ const options = parseOptions(process.argv.slice(2)); const desktopRoot = resolve(import.meta.dirname, '..'); const tauriRoot = join(desktopRoot, 'src-tauri'); const protectedRepo = resolve(desktopRoot, '../..'); -const sidecar = join( - tauriRoot, - 'target', - 'release', - process.platform === 'win32' ? 'codevetter-mcp.exe' : 'codevetter-mcp' -); +const sidecar = process.env.CV_MCP_SIDECAR_PATH + ? resolve(process.env.CV_MCP_SIDECAR_PATH) + : join( + tauriRoot, + 'target', + 'release', + process.platform === 'win32' ? 'codevetter-mcp.exe' : 'codevetter-mcp' + ); const fixtureDir = mkdtempSync(join(tmpdir(), 'codevetter-mcp-bench-')); const database = join(fixtureDir, 'codevetter.db'); const activeSessions = new Set(); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index a605276f..dbb15484 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1278,11 +1278,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 22ba15c3..37867467 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -2,6 +2,7 @@ name = "codevetter-desktop" version = "0.1.0" edition = "2021" +publish = false description = "CodeVetter Desktop — code review + agent management" default-run = "codevetter-desktop" @@ -87,6 +88,12 @@ opt-level = 0 [profile.test] debug = 1 +[profile.release] +# Shipping companions favor whole-program runtime and footprint over compile +# throughput. Keep panic unwinding for diagnosability and behavior parity. +lto = "fat" +codegen-units = 1 + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/apps/desktop/src-tauri/deny.toml b/apps/desktop/src-tauri/deny.toml new file mode 100644 index 00000000..b8813bbe --- /dev/null +++ b/apps/desktop/src-tauri/deny.toml @@ -0,0 +1,40 @@ +[graph] +targets = ["aarch64-apple-darwin"] +all-features = false +no-default-features = false + +[licenses] +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.8 + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "simplest-path" +workspace-default-features = "allow" +external-default-features = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/apps/desktop/src-tauri/src/application/mod.rs b/apps/desktop/src-tauri/src/application/mod.rs new file mode 100644 index 00000000..c9868b1f --- /dev/null +++ b/apps/desktop/src-tauri/src/application/mod.rs @@ -0,0 +1 @@ +pub mod verification_service; diff --git a/apps/desktop/src-tauri/src/application/verification_service.rs b/apps/desktop/src-tauri/src/application/verification_service.rs new file mode 100644 index 00000000..0c8ebb7a --- /dev/null +++ b/apps/desktop/src-tauri/src/application/verification_service.rs @@ -0,0 +1,313 @@ +//! Tauri-independent application service for the native verification loop. +//! +//! Transport adapters supply one stable request identity. The service owns the +//! versioned command contract, correlates every progress event and terminal +//! receipt, and delegates verification semantics to the existing Rust engine. + +use serde::{Deserialize, Serialize}; + +use crate::commands::local_check::{ + preflight_local_check, run_local_check_with_progress, LocalCheckInput, + LocalCheckPreflightReceipt, LocalCheckReceipt, +}; + +pub const VERIFICATION_COMMAND_SCHEMA: &str = "codevetter.verification-command/v1"; +pub const VERIFICATION_PROGRESS_SCHEMA: &str = "codevetter.progress/v2"; +pub const VERIFICATION_CANCELLATION_SCHEMA: &str = "codevetter.verification-cancel/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerificationOperation { + Preflight, + Execute, +} + +#[derive(Debug, Clone)] +pub struct VerificationCommand { + pub schema_version: &'static str, + pub request_id: String, + pub operation: VerificationOperation, + pub input: LocalCheckInput, +} + +impl VerificationCommand { + pub fn new( + request_id: Option, + operation: VerificationOperation, + input: LocalCheckInput, + ) -> Result { + Ok(Self { + schema_version: VERIFICATION_COMMAND_SCHEMA, + request_id: resolve_request_id(request_id.as_deref())?, + operation, + input, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationProgress { + pub schema_version: String, + pub request_id: String, + pub sequence: u32, + pub stage: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationCancellation { + pub schema_version: String, + pub request_id: String, +} + +impl VerificationCancellation { + pub fn new(request_id: &str) -> Result { + validate_request_id(request_id)?; + Ok(Self { + schema_version: VERIFICATION_CANCELLATION_SCHEMA.into(), + request_id: request_id.into(), + }) + } +} + +#[derive(Debug, Clone)] +pub enum VerificationResult { + Preflight(Box), + Complete(Box), +} + +pub async fn run_verification_command( + command: VerificationCommand, + mut on_progress: F, +) -> Result +where + F: FnMut(VerificationProgress), +{ + if command.schema_version != VERIFICATION_COMMAND_SCHEMA { + return Err(format!( + "unsupported verification command schema `{}`", + command.schema_version + )); + } + validate_request_id(&command.request_id)?; + let mut sequence = 0_u32; + let mut emit = |stage: &str, state: &str| { + let event = VerificationProgress { + schema_version: VERIFICATION_PROGRESS_SCHEMA.into(), + request_id: command.request_id.clone(), + sequence, + stage: stage.into(), + state: state.into(), + }; + sequence = sequence.saturating_add(1); + on_progress(event); + }; + + match command.operation { + VerificationOperation::Preflight => { + emit("preflight", "running"); + let mut receipt = preflight_local_check(&command.input).await?; + receipt.request_id = Some(command.request_id.clone()); + emit("preflight", "completed"); + Ok(VerificationResult::Preflight(Box::new(receipt))) + } + VerificationOperation::Execute => { + let request_id = command.request_id.clone(); + let mut receipt = run_local_check_with_progress(command.input, |progress| { + emit(progress.stage, progress.state) + }) + .await?; + receipt.request_id = Some(request_id); + Ok(VerificationResult::Complete(Box::new(receipt))) + } + } +} + +pub fn resolve_request_id(request_id: Option<&str>) -> Result { + match request_id.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => { + validate_request_id(value)?; + Ok(value.to_string()) + } + None => Ok(uuid::Uuid::new_v4().to_string()), + } +} + +fn validate_request_id(request_id: &str) -> Result<(), String> { + if request_id.is_empty() || request_id.len() > 128 { + return Err("request id must contain between 1 and 128 characters".into()); + } + if !request_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err( + "request id may contain only ASCII letters, numbers, dash, underscore, dot, or colon" + .into(), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use crate::commands::local_check::{LocalCheckStatus, LocalCheckTarget, LocalCheckVerdict}; + + const LOCAL_CHECK_PARITY_FIXTURE: &str = + include_str!("../../tests/fixtures/surface-parity/local-check-v1.json"); + + fn git(repo: &Path, arguments: &[&str]) { + let output = std::process::Command::new("git") + .args(arguments) + .current_dir(repo) + .output() + .expect("git command"); + assert!( + output.status.success(), + "git {:?}: {}", + arguments, + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn request_identity_is_bounded_and_generated_when_absent() { + assert_eq!( + resolve_request_id(Some("native.review:fixture-1")).expect("valid request"), + "native.review:fixture-1" + ); + assert!( + uuid::Uuid::parse_str(&resolve_request_id(None).expect("generated request")).is_ok() + ); + assert!(resolve_request_id(Some("../unsafe path")).is_err()); + assert!(resolve_request_id(Some(&"x".repeat(129))).is_err()); + } + + #[test] + fn progress_contract_carries_request_identity_and_order() { + let progress = VerificationProgress { + schema_version: VERIFICATION_PROGRESS_SCHEMA.into(), + request_id: "native-review-fixture".into(), + sequence: 3, + stage: "correctness".into(), + state: "running".into(), + }; + let value = serde_json::to_value(&progress).expect("progress JSON"); + assert_eq!(value["schema_version"], VERIFICATION_PROGRESS_SCHEMA); + assert_eq!(value["request_id"], "native-review-fixture"); + assert_eq!(value["sequence"], 3); + let cancellation = + VerificationCancellation::new("native-review-fixture").expect("cancellation contract"); + assert_eq!( + cancellation.schema_version, + VERIFICATION_CANCELLATION_SCHEMA + ); + assert_eq!(cancellation.request_id, progress.request_id); + } + + #[test] + fn authoritative_service_owns_the_shared_local_check_receipt_contract() { + let fixture: serde_json::Value = + serde_json::from_str(LOCAL_CHECK_PARITY_FIXTURE).expect("local-check parity fixture"); + let receipt: LocalCheckReceipt = + serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("canonical local-check receipt"); + let request = &fixture["request"]; + + assert_eq!(fixture["authority"]["rust"], "authoritative_service"); + assert_eq!(request["schema_version"], VERIFICATION_COMMAND_SCHEMA); + assert_eq!( + receipt.schema_version, + fixture["expected"]["receipt_schema"] + ); + assert_eq!( + receipt.request_id.as_deref(), + request["request_id"].as_str() + ); + assert_eq!(receipt.run_id, fixture["expected"]["run_id"]); + assert_eq!(receipt.verdict, LocalCheckVerdict::NoConfidence); + assert_eq!( + receipt.stages.performance.status, + LocalCheckStatus::NoConfidence + ); + assert!(receipt + .limitations + .iter() + .any(|value| value == fixture["expected"]["limitation"].as_str().unwrap())); + } + + #[tokio::test] + async fn preflight_runs_through_the_service_and_correlates_every_projection() { + let repo = tempfile::tempdir().expect("repository"); + git(repo.path(), &["init", "--initial-branch", "main"]); + git( + repo.path(), + &["config", "user.email", "fixture@example.test"], + ); + git(repo.path(), &["config", "user.name", "CodeVetter Fixture"]); + std::fs::create_dir_all(repo.path().join("test")).expect("test directory"); + std::fs::write(repo.path().join("source.js"), "export const value = 1;\n").expect("source"); + std::fs::write(repo.path().join("test/source.test.js"), "// fixture\n").expect("test"); + git(repo.path(), &["add", "."]); + git(repo.path(), &["commit", "-m", "base"]); + std::fs::write(repo.path().join("source.js"), "export const value = 2;\n") + .expect("changed source"); + git(repo.path(), &["add", "source.js"]); + git(repo.path(), &["commit", "-m", "change"]); + + let command = VerificationCommand::new( + Some("native-review-service-fixture".into()), + VerificationOperation::Preflight, + LocalCheckInput { + repo_path: repo.path().to_path_buf(), + change: "HEAD^...HEAD".into(), + task: "Preserve the source contract".into(), + standards_pack: None, + standards_context: None, + spec_paths: Vec::new(), + selected_requirement_ids: Vec::new(), + review_agent: "codex".into(), + test_target: Some(LocalCheckTarget { + adapter: "node-test".into(), + target: "test/source.test.js".into(), + name: None, + source: "explicit:fixture".into(), + }), + performance_target: None, + baseline_repo_path: None, + samples: 3, + warmups: 1, + timeout_ms: 30_000, + }, + ) + .expect("command"); + let mut progress = Vec::new(); + let result = run_verification_command(command, |event| progress.push(event)) + .await + .expect("service preflight"); + let VerificationResult::Preflight(receipt) = result else { + panic!("expected preflight receipt"); + }; + + assert_eq!( + receipt.request_id.as_deref(), + Some("native-review-service-fixture") + ); + assert_eq!(receipt.status, LocalCheckStatus::Ready); + assert_eq!(progress.len(), 2); + assert!(progress + .iter() + .all(|event| event.request_id == "native-review-service-fixture")); + assert_eq!( + progress + .iter() + .map(|event| event.sequence) + .collect::>(), + vec![0, 1] + ); + assert_eq!(progress[0].state, "running"); + assert_eq!(progress[1].state, "completed"); + } +} diff --git a/apps/desktop/src-tauri/src/bin/codevetter.rs b/apps/desktop/src-tauri/src/bin/codevetter.rs index 7df6939e..23e6500d 100644 --- a/apps/desktop/src-tauri/src/bin/codevetter.rs +++ b/apps/desktop/src-tauri/src/bin/codevetter.rs @@ -1,12 +1,101 @@ +use codevetter_desktop::application::verification_service::{ + run_verification_command, VerificationCommand, VerificationOperation, VerificationProgress, + VerificationResult, +}; +use codevetter_desktop::capabilities::{ + capability_registry, capability_registry_schema, Availability, CapabilityRegistry, +}; +use codevetter_desktop::commands::agent_memories::{ + run_memory_receipt, MemoryReceipt, MemoryReceiptOperation, +}; +use codevetter_desktop::commands::evidence_scope::{ + resolve_evidence_scope, EvidenceScopeConsumer, EvidenceScopeInput, EvidenceScopeKind, + EvidenceScopePlan, +}; +use codevetter_desktop::commands::fix_attempt::{ + discard_fix_attempt, execute_fix_attempt, inspect_fix_attempt, DiscardFixAttemptInput, + FixAttemptInput, FixAttemptReceipt, +}; +use codevetter_desktop::commands::fix_packet::build_agent_fix_packet; +use codevetter_desktop::commands::history_roots::{ + run_history_roots, HistoryRootsOperation, HistoryRootsReceipt, +}; use codevetter_desktop::commands::local_check::{ - preflight_local_check, run_local_check_with_progress, LocalCheckInput, - LocalCheckPreflightReceipt, LocalCheckReceipt, LocalCheckStatus, LocalCheckTarget, - LocalCheckVerdict, + LocalCheckInput, LocalCheckPreflightReceipt, LocalCheckReceipt, LocalCheckStatus, + LocalCheckTarget, LocalCheckVerdict, +}; +use codevetter_desktop::commands::local_usage::{ + get_headless_local_usage_report, LocalUsageReport, +}; +use codevetter_desktop::commands::mcp_access::{ + run_mcp_settings_operation, McpSettingsOperation, McpSettingsReceipt, +}; +use codevetter_desktop::commands::native_settings::{ + list_native_settings, set_native_setting, NativeSettingsReceipt, +}; +use codevetter_desktop::commands::onboarding::{ + complete_onboarding, inspect_onboarding, OnboardingReceipt, +}; +use codevetter_desktop::commands::ops_status::{inspect_ops_status, OpsStatusReceipt}; +#[cfg(test)] +use codevetter_desktop::commands::ops_status::{OpsBillingStatus, OpsWebhookStatus}; +use codevetter_desktop::commands::performance_bridge::{ + run_headless_performance, PerformanceAdapter, PerformanceOperation, PerformanceRunInput, + PerformanceRunReceipt, +}; +use codevetter_desktop::commands::qa_workspace::{ + run_qa_workspace_headless, QaTargetPreset, QaWorkspaceMutation, QaWorkspaceReceipt, + StoredQaWorkflow, +}; +use codevetter_desktop::commands::repo_query::{ + query_repository_evidence_with_input, run_repository_query_worker, + RepositoryHistorySelectorKind, RepositoryQueryDomain, RepositoryQueryInput, + RepositoryQueryMode, RepositoryQueryReceipt, +}; +use codevetter_desktop::commands::rubric_settings::{ + active_rubric_prompt, read_rubric_settings, select_rubric_pack, upsert_rubric_pack, + RubricPackInput, RubricSettingsReceipt, +}; +use codevetter_desktop::commands::run_history::{list_run_history, RunHistoryReceipt}; +use codevetter_desktop::commands::scenario_compiler_bridge::{ + run_scenario_compiler_action_headless, ContextSelection, ProviderSelection, + ScenarioCompilerAction, +}; +use codevetter_desktop::commands::session_retention::{ + run_session_retention_operation, SessionRetentionOperation, SessionRetentionPolicy, + SessionRetentionReceipt, +}; +use codevetter_desktop::commands::structural_graph::query::GraphDirection; +use codevetter_desktop::commands::tool_collectors::{ + collect_tool_evidence, CollectorKind, CollectorStatus, ToolCollectionInput, + ToolCollectionReceipt, }; use codevetter_desktop::commands::trex_preview::{ execute_trex_preview, TrexChangeKind, TrexPreviewReceipt, TrexPreviewRunInput, TrexPreviewVerdict, }; +use codevetter_desktop::commands::trex_watcher::{ + disable_trex_watcher_headless, enable_trex_watcher_headless, list_trex_pr_runs_headless, + list_trex_watchers_headless, poll_trex_watcher_headless, retry_trex_watcher_headless, + StartTrexWatcherInput, TrexWatcherReceipt, +}; +use codevetter_desktop::commands::unpack::{ + compare_unpack_snapshot_commits_headless, export_repo_unpack_report_from_connection, + get_repo_unpack_report_from_connection, list_repo_unpack_reports_from_connection, + scan_and_persist_unpack_snapshot, UnpackReportRecord, UnpackReportSummary, +}; +use codevetter_desktop::commands::warm_verification_bridge::{ + cancel_differential_verification_run_headless, cancel_warm_verification_run_headless, + cleanup_differential_verification_artifacts_headless, + cleanup_warm_verification_artifacts_headless, get_current_warm_verification_identity_headless, + get_warm_verification_daemon_health_headless, prepare_differential_verification_headless, + run_differential_verification_headless, run_warm_changed_verification_headless, + start_warm_verification_daemon_headless, stop_warm_verification_daemon_headless, +}; +use codevetter_desktop::commands::xray::{ + build_agent_pr_xray_from_connection, save_agent_pr_xray_to_path, SaveXrayRequest, XrayFormat, + XrayRequest, +}; use codevetter_desktop::{db, DbState}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -17,6 +106,32 @@ CodeVetter execution-backed verification Usage: codevetter check (--pr | --range ) --task [options] codevetter trex (--pr | --range ) --preview [--repo ] [--json] + codevetter warm --operation [--repo ] [options] [--json] + codevetter differential --operation [--repo ] [options] [--json] + codevetter scenario --operation [options] [--json] + codevetter watcher --operation [options] [--json] + codevetter performance --operation [options] [--json] + codevetter scope --consumer (--flow | --change | --codebase) [--repo ] [--json] + codevetter usage [--timezone ] [--refresh] [--json] + codevetter ops [--window-days <7|30|90>] [--json] + codevetter unpack [--operation ] [--repo ] [--limit ] [--report-id ] [--json] + codevetter qa --operation --repo [options] [--json] + codevetter settings [--set =] [--json] + codevetter history-roots [--add | --remove ] [--json] + codevetter memories [--source [--diff]] [--json] + codevetter onboarding [--complete --default-adapter ] [--json] + codevetter mcp --repo [--enable | --disable | --clear-audit] [--json] + codevetter retention (--max-age-days | --max-archive-mib ) [--json] + codevetter retention (--apply | --checkpoint [--vacuum]) [--json] + codevetter rubrics [--select | --id --name --focus --check ...] [--json] + codevetter collect --range --collector [--collector ...] [--repo ] [--json] + codevetter capabilities [--json | --schema] + codevetter runs [--repo ] [--limit ] [--json] + codevetter fix-packet --run-id [--finding ...] [--json] + codevetter fix --operation execute --run-id --finding [--finding ...] --agent --confirm-run [--timeout-ms ] [--json] + codevetter fix --operation inspect --attempt-id [--json] + codevetter fix --operation discard --attempt-id --confirm-discard [--json] + codevetter xray --review-id [--public-source ] [--confirm-public] [--approve-excerpt ...] [--format ] [--save ] [--json] codevetter --version Options: @@ -26,9 +141,11 @@ Options: --repo Repository path (defaults to the current directory) --task Intended behavior for the change --preflight Validate source, specs, and targets without model or project execution + --progress-json Stream versioned progress JSON lines to stderr (requires --json) + --request-id Correlate the versioned command, progress, cancellation, and receipt --spec Repo-relative Markdown spec (repeatable) --requirement Requirement id explicitly bound to correctness (repeatable) - --agent Review executor: claude, gemini, or codex (default: claude) + --agent Review executor: claude, gemini, codex, or cross; fix executor: claude or codex --test-adapter Explicit correctness adapter --test-target Explicit correctness target --test-name Optional exact correctness test name @@ -39,6 +156,66 @@ Options: --samples Performance samples, 2-10 (default: 3) --warmups Performance warmups, 0-5 (default: 1) --timeout-ms Per-workload timeout, 100-120000 (default: 30000) + --collector gitleaks, cargo-audit, or cargo-llvm-cov (repeatable) + --operation Performance operation: plan, diagnose, verify-paired, or inspect + --consumer Scope consumer: testing or performance + --flow Discover targets for one human-described flow + --change Discover targets for one exact Git change + --codebase Discover a bounded whole-codebase target portfolio + --adapter vitest, node-test, node-script, playwright, or go-bench + --target Contained repository-relative performance target + --name Optional exact performance workload name + --request-id Optional stable performance request id + --subject-run-id Recorded performance run id for inspect + --timezone Usage reporting timezone (default: UTC) + --refresh Bypass the in-process usage cache + --window-days Ops aggregate window: 7, 30, or 90 (default: 30) + --report-id Inspect one stored Repo Unpack snapshot + --operation Repo Unpack operation: list, inspect, scan, compare, export, query, or internal query-worker + --query-domain Repository query domain: graph or history + --query-mode search, explain, impact, path, or trace + --query-target Destination node for a path query + --query-direction Impact direction: incoming, outgoing, or both + --query-depth Impact depth from 1 through 12 + --history-selector Trace selector: event, entity, revision, release, or episode + --query Bounded structural or temporal search text + --set = Save one declared non-secret native preference + --source Read one bounded memory source selected from `memories` + --diff Show the redacted Git diff for the selected memory source + --complete Complete native onboarding using the selected default adapter + --default-adapter Native onboarding default: codex or claude-code + --enable Enable MCP for one indexed repository + --disable Disable MCP for one repository + --clear-audit Clear bounded MCP access metadata for one repository + --max-age-days Preview removal of archive rows older than 1-3650 days + --max-archive-mib Preview an archive size limit of 1 MiB or greater + --apply Apply one persisted plan after rechecking its identity + --checkpoint Checkpoint the local archive without deleting evidence + --vacuum Run VACUUM after --checkpoint + --select Select one existing review rubric pack + --id Lowercase id for a custom rubric pack + --focus Review focus for a custom rubric pack + --check Review check for a custom pack (repeatable) + --review-id Persisted review identity for a public-safe X-Ray + --run-id Persisted local-check identity for an agent fix packet + --finding Source-qualified finding to include (repeatable) + --attempt-id Persisted isolated fix-attempt identity + --confirm-discard Confirm removal of one retained unmerged fix worktree + --detailed Capture bounded warm-verification artifact detail + --reference Differential reference revision + --candidate Differential candidate: worktree, staged, commit, or range + --revision Exact commit/range candidate revision + --dry-run Preview warm artifact cleanup without deleting files + --apply-cleanup Explicitly authorize warm artifact cleanup + --interval-secs PR watcher interval in seconds (minimum 60) + --base-branch Optional PR watcher base branch override + --pr-number Exact open PR to retry after a limited watcher attempt + --confirm-run Confirm a watcher poll or isolated fix may invoke external tools + --public-source Bounded public repository or pull-request reference + --confirm-public Confirm the source and finding summaries are safe to publish + --approve-excerpt Allow one recorded suggestion excerpt (repeatable) + --format X-Ray export format: json, markdown, or html + --save Save an eligible X-Ray to an explicit destination --json Print only the canonical receipt JSON "; @@ -54,6 +231,8 @@ struct TrexArguments { change_kind: TrexChangeKind, change: String, preview_url: String, + target_route: Option, + target_goal: Option, output: OutputMode, } @@ -71,12 +250,293 @@ struct CheckArguments { samples: u8, warmups: u8, timeout_ms: u64, + request_id: Option, preflight: bool, + progress_json: bool, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CollectArguments { + repo_path: PathBuf, + change: String, + collectors: Vec, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RunsArguments { + repo_path: Option, + limit: usize, + output: OutputMode, +} + +#[derive(Debug, Clone)] +struct PerformanceArguments { + input: PerformanceRunInput, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ScopeArguments { + input: EvidenceScopeInput, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UsageArguments { + timezone: Option, + refresh: bool, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OpsArguments { + window_days: u32, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum UnpackOperation { + List, + Inspect, + Scan, + Compare, + Export, + Query, + QueryWorker, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UnpackArguments { + operation: UnpackOperation, + repo_path: Option, + report_id: Option, + base_commit: Option, + head_commit: Option, + format: Option, + query_domain: Option, + query_mode: RepositoryQueryMode, + query: Option, + query_target: Option, + query_direction: Option, + query_depth: Option, + history_selector: Option, + limit: i64, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SettingsArguments { + set: Option<(String, String)>, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HistoryRootsArguments { + operation: HistoryRootsOperation, + path: Option, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MemoriesArguments { + source_id: Option, + diff: bool, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OnboardingArguments { + complete: bool, + default_adapter: Option, + output: OutputMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QaOperation { + Inspect, + SaveWorkflow, + DeleteWorkflow, + SaveTarget, + DeleteTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct QaArguments { + operation: QaOperation, + repo_path: PathBuf, + workflow_id: Option, + workflow_name: Option, + base_url: Option, + loop_id: Option, + runner_type: Option, + goal: Option, + repo_spec_path: Option, + repo_trace_mode: Option, + target_route: Option, + allow_remote_target: bool, + target_id: Option, + target_name: Option, + fix_completed_at: Option, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct McpArguments { + repo_path: PathBuf, + operation: McpSettingsOperation, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RetentionArguments { + operation: SessionRetentionOperation, + max_age_days: Option, + max_archive_mib: Option, + plan_id: Option, + vacuum: bool, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RubricsArguments { + select: Option, + upsert: Option, + output: OutputMode, +} + +#[derive(Debug, Clone)] +struct XrayArguments { + request: XrayRequest, + format: XrayFormat, + save_path: Option, + output: OutputMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FixPacketArguments { + run_id: String, + finding_ids: Vec, + output: OutputMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixOperation { + Execute, + Inspect, + Discard, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FixArguments { + operation: FixOperation, + run_id: Option, + finding_ids: Vec, + attempt_id: Option, + agent: String, + confirm_run: bool, + confirm_discard: bool, + timeout_ms: u64, + output: OutputMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WarmOperation { + Status, + Start, + Stop, + Run, + Cancel, + Cleanup, + Current, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WarmArguments { + repo_path: PathBuf, + operation: WarmOperation, + run_id: Option, + detailed: bool, + dry_run: bool, + output: OutputMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DifferentialOperation { + Prepare, + Run, + Cancel, + Cleanup, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DifferentialArguments { + repo_path: PathBuf, + operation: DifferentialOperation, + run_id: Option, + reference: Option, + candidate_kind: Option, + candidate_revision: Option, + dry_run: bool, + output: OutputMode, +} + +#[derive(Debug)] +struct ScenarioArguments { + repo_path: PathBuf, + action: ScenarioCompilerAction, + output: OutputMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WatcherOperation { + List, + Enable, + Disable, + Poll, + Retry, + Runs, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WatcherArguments { + repo_path: Option, + operation: WatcherOperation, + interval_secs: Option, + base_branch: Option, + pr_number: Option, + limit: u32, + confirm_run: bool, output: OutputMode, } enum CliCommand { Check(Box), + Capabilities(OutputMode), + CapabilitySchema, + Collect(CollectArguments), + Runs(RunsArguments), + Performance(PerformanceArguments), + Scope(ScopeArguments), + Usage(UsageArguments), + Ops(OpsArguments), + Unpack(UnpackArguments), + Settings(SettingsArguments), + HistoryRoots(HistoryRootsArguments), + Memories(MemoriesArguments), + Onboarding(OnboardingArguments), + Qa(QaArguments), + Mcp(McpArguments), + Retention(RetentionArguments), + Rubrics(RubricsArguments), + Xray(XrayArguments), + FixPacket(FixPacketArguments), + Fix(FixArguments), + Warm(WarmArguments), + Differential(DifferentialArguments), + Scenario(ScenarioArguments), + Watcher(WatcherArguments), Trex(TrexArguments), Help, Version, @@ -105,688 +565,5169 @@ async fn run() -> Result { println!("codevetter {}", app_version()); Ok(0) } + CliCommand::Capabilities(output) => run_capabilities(output), + CliCommand::CapabilitySchema => { + println!( + "{}", + serde_json::to_string_pretty(&capability_registry_schema()) + .map_err(|error| format!("serialize capability schema: {error}"))? + ); + Ok(0) + } CliCommand::Check(arguments) => run_check(*arguments).await, + CliCommand::Collect(arguments) => run_collect(arguments).await, + CliCommand::Runs(arguments) => run_runs(arguments), + CliCommand::Performance(arguments) => run_performance(arguments).await, + CliCommand::Scope(arguments) => run_scope(arguments).await, + CliCommand::Usage(arguments) => run_usage(arguments).await, + CliCommand::Ops(arguments) => run_ops(arguments), + CliCommand::Unpack(arguments) => run_unpack(arguments), + CliCommand::Settings(arguments) => run_settings(arguments), + CliCommand::HistoryRoots(arguments) => execute_history_roots(arguments), + CliCommand::Memories(arguments) => run_memories(arguments), + CliCommand::Onboarding(arguments) => run_onboarding(arguments), + CliCommand::Qa(arguments) => run_qa(arguments), + CliCommand::Mcp(arguments) => run_mcp(arguments), + CliCommand::Retention(arguments) => run_retention(arguments), + CliCommand::Rubrics(arguments) => run_rubrics(arguments), + CliCommand::Xray(arguments) => run_xray(arguments), + CliCommand::FixPacket(arguments) => run_fix_packet(arguments), + CliCommand::Fix(arguments) => run_fix(arguments).await, + CliCommand::Warm(arguments) => run_warm(arguments).await, + CliCommand::Differential(arguments) => run_differential(arguments).await, + CliCommand::Scenario(arguments) => run_scenario(arguments).await, + CliCommand::Watcher(arguments) => run_watcher(arguments).await, CliCommand::Trex(arguments) => run_trex(arguments).await, } } -async fn run_check(arguments: CheckArguments) -> Result { - let output = arguments.output; - let preflight = arguments.preflight; - let input = LocalCheckInput { - repo_path: arguments.repo_path, - change: arguments.change, - task: arguments.task, - spec_paths: arguments.spec_paths, - selected_requirement_ids: arguments.selected_requirement_ids, - review_agent: arguments.review_agent, - test_target: arguments.test_target, - performance_target: arguments.performance_target, - baseline_repo_path: arguments.baseline_repo_path, - samples: arguments.samples, - warmups: arguments.warmups, - timeout_ms: arguments.timeout_ms, - }; - if preflight { - let receipt = preflight_local_check(&input).await?; - match output { - OutputMode::Json => println!( - "{}", - serde_json::to_string(&receipt) - .map_err(|error| format!("serialize local check preflight: {error}"))? - ), - OutputMode::Human => print!("{}", render_human_preflight(&receipt)), +async fn run_warm(arguments: WarmArguments) -> Result { + let repo_path = std::fs::canonicalize(&arguments.repo_path) + .map_err(|error| { + format!( + "repository {} is unavailable: {error}", + arguments.repo_path.display() + ) + })? + .to_string_lossy() + .into_owned(); + let value = match arguments.operation { + WarmOperation::Status => { + serde_json::to_value(get_warm_verification_daemon_health_headless(repo_path).await?) + } + WarmOperation::Start => { + serde_json::to_value(start_warm_verification_daemon_headless(repo_path).await?) + } + WarmOperation::Stop => { + serde_json::to_value(stop_warm_verification_daemon_headless(repo_path).await?) + } + WarmOperation::Run => { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let db = DbState(Arc::new(Mutex::new(connection))); + serde_json::to_value( + run_warm_changed_verification_headless( + &db, + repo_path, + arguments.detailed, + arguments + .run_id + .ok_or("--run-id is required for warm run")?, + ) + .await?, + ) } - return Ok(preflight_exit_code(receipt.status)); + WarmOperation::Cancel => serde_json::to_value( + cancel_warm_verification_run_headless( + repo_path, + arguments + .run_id + .ok_or("--run-id is required for warm cancel")?, + ) + .await?, + ), + WarmOperation::Cleanup => serde_json::to_value( + cleanup_warm_verification_artifacts_headless(repo_path, arguments.dry_run).await?, + ), + WarmOperation::Current => { + serde_json::to_value(get_current_warm_verification_identity_headless(repo_path).await?) + } + } + .map_err(|error| format!("serialize warm verification receipt: {error}"))?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&value) + .map_err(|error| format!("serialize warm verification receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_warm(arguments.operation, &value)), } + let status = match arguments.operation { + WarmOperation::Status if value.is_null() => 2, + WarmOperation::Run => match value + .pointer("/result/outcome") + .and_then(serde_json::Value::as_str) + { + Some("passed") => 0, + Some("regression") => 1, + _ => 2, + }, + WarmOperation::Cancel + if value.get("accepted").and_then(serde_json::Value::as_bool) == Some(false) => + { + 2 + } + _ => 0, + }; + Ok(status) +} - let human_progress = output == OutputMode::Human; - let receipt = run_local_check_with_progress(input, |progress| { - if human_progress { - eprintln!("[codevetter] {}: {}", progress.stage, progress.state); +async fn run_differential(arguments: DifferentialArguments) -> Result { + let repo_path = std::fs::canonicalize(&arguments.repo_path) + .map_err(|error| { + format!( + "repository {} is unavailable: {error}", + arguments.repo_path.display() + ) + })? + .to_string_lossy() + .into_owned(); + let value = match arguments.operation { + DifferentialOperation::Prepare => serde_json::to_value( + prepare_differential_verification_headless( + repo_path, + arguments.run_id.ok_or("--run-id is required")?, + arguments.reference.ok_or("--reference is required")?, + arguments.candidate_kind.ok_or("--candidate is required")?, + arguments.candidate_revision, + ) + .await?, + ), + DifferentialOperation::Run => { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let db = DbState(Arc::new(Mutex::new(connection))); + serde_json::to_value( + run_differential_verification_headless( + &db, + repo_path, + arguments.run_id.ok_or("--run-id is required")?, + arguments.reference.ok_or("--reference is required")?, + arguments.candidate_kind.ok_or("--candidate is required")?, + arguments.candidate_revision, + ) + .await?, + ) } - }) - .await?; - match output { + DifferentialOperation::Cancel => serde_json::to_value( + cancel_differential_verification_run_headless( + repo_path, + arguments.run_id.ok_or("--run-id is required")?, + ) + .await?, + ), + DifferentialOperation::Cleanup => serde_json::to_value( + cleanup_differential_verification_artifacts_headless(repo_path, arguments.dry_run) + .await?, + ), + } + .map_err(|error| format!("serialize differential receipt: {error}"))?; + match arguments.output { OutputMode::Json => println!( "{}", - serde_json::to_string(&receipt) - .map_err(|error| format!("serialize local check receipt: {error}"))? + serde_json::to_string(&value) + .map_err(|error| format!("serialize differential receipt: {error}"))? + ), + OutputMode::Human => println!( + "Differential verification · {:?}\n{}", + arguments.operation, + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) ), - OutputMode::Human => print!("{}", render_human_check(&receipt)), } - Ok(local_check_exit_code(receipt.verdict)) + Ok(match arguments.operation { + DifferentialOperation::Prepare + if value.get("status").and_then(serde_json::Value::as_str) != Some("ready") => + { + 2 + } + DifferentialOperation::Run => match value + .pointer("/summary/classification") + .and_then(serde_json::Value::as_str) + { + Some("regressed") => 1, + Some("incomparable") | None => 2, + _ => 0, + }, + DifferentialOperation::Cancel + if value.get("accepted").and_then(serde_json::Value::as_bool) == Some(false) => + { + 2 + } + _ => 0, + }) } -fn app_version() -> String { - serde_json::from_str::(include_str!("../../tauri.conf.json")) - .ok() - .and_then(|config| config.get("version")?.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()) +async fn run_scenario(arguments: ScenarioArguments) -> Result { + let repo_path = std::fs::canonicalize(&arguments.repo_path) + .map_err(|error| { + format!( + "repository {} is unavailable: {error}", + arguments.repo_path.display() + ) + })? + .to_string_lossy() + .into_owned(); + let receipt = run_scenario_compiler_action_headless(repo_path, arguments.action).await?; + let value = serde_json::to_value(&receipt) + .map_err(|error| format!("serialize scenario compiler receipt: {error}"))?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&value) + .map_err(|error| format!("serialize scenario compiler receipt: {error}"))? + ), + OutputMode::Human => println!( + "Scenario compiler · {} · {}\n{}", + value + .get("action") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"), + value + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"), + value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + ), + } + Ok( + match value.get("status").and_then(serde_json::Value::as_str) { + Some("ok") => 0, + Some("rejected") => 1, + _ => 2, + }, + ) } -async fn run_trex(arguments: TrexArguments) -> Result { - let repo_path = std::fs::canonicalize(&arguments.repo_path).map_err(|error| { - format!( - "repository {} is unavailable: {error}", - arguments.repo_path.display() - ) - })?; - let app_data_dir = default_app_data_dir()?; - let connection = db::init_db(app_data_dir.clone()) +async fn run_watcher(arguments: WatcherArguments) -> Result { + let connection = db::init_db(default_app_data_dir()?) .map_err(|error| format!("open CodeVetter database: {error}"))?; let db = DbState(Arc::new(Mutex::new(connection))); - let receipt = execute_trex_preview( - TrexPreviewRunInput { - repo_path: repo_path.to_string_lossy().into_owned(), - change_kind: arguments.change_kind, - change: arguments.change, - preview_url: arguments.preview_url, - }, - &db, - app_data_dir, - None, - ) - .await?; - + let repo_path = arguments + .repo_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + let receipt = match arguments.operation { + WatcherOperation::List => list_trex_watchers_headless(&db)?, + WatcherOperation::Enable => enable_trex_watcher_headless( + &db, + StartTrexWatcherInput { + repo_path: repo_path.ok_or("--repo is required for watcher enable")?, + interval_secs: arguments.interval_secs, + base_branch: arguments.base_branch, + }, + )?, + WatcherOperation::Disable => disable_trex_watcher_headless( + &db, + &repo_path.ok_or("--repo is required for watcher disable")?, + )?, + WatcherOperation::Poll => { + if !arguments.confirm_run { + return Err( + "watcher poll requires --confirm-run because it may use network access, invoke an agent, execute project code, and post GitHub statuses" + .to_string(), + ); + } + poll_trex_watcher_headless( + &db, + &repo_path.ok_or("--repo is required for watcher poll")?, + ) + .await? + } + WatcherOperation::Retry => { + if !arguments.confirm_run { + return Err( + "watcher retry requires --confirm-run because it may use network access, invoke an agent, execute project code, and post GitHub statuses" + .to_string(), + ); + } + retry_trex_watcher_headless( + &db, + &repo_path.ok_or("--repo is required for watcher retry")?, + arguments + .pr_number + .ok_or("--pr-number is required for watcher retry")?, + ) + .await? + } + WatcherOperation::Runs => { + list_trex_pr_runs_headless(&db, repo_path.as_deref(), arguments.limit)? + } + }; match arguments.output { OutputMode::Json => println!( "{}", serde_json::to_string(&receipt) - .map_err(|error| format!("serialize T-Rex receipt: {error}"))? + .map_err(|error| format!("serialize watcher receipt: {error}"))? ), - OutputMode::Human => print!("{}", render_human_receipt(&receipt)), + OutputMode::Human => print!("{}", render_human_watcher(&receipt)), } - Ok(verdict_exit_code(receipt.verdict)) + Ok(0) } -fn parse_arguments( - arguments: impl IntoIterator, - cwd: &Path, -) -> Result { - let mut arguments = arguments.into_iter(); - let Some(command) = arguments.next() else { - return Ok(CliCommand::Help); - }; - match command.as_str() { - "--help" | "-h" | "help" => return Ok(CliCommand::Help), - "--version" | "-V" => return Ok(CliCommand::Version), - "check" => return parse_check(arguments, cwd), - "trex" => {} - _ => return Err(format!("unknown command `{command}`\n\n{HELP}")), +fn render_human_watcher(receipt: &TrexWatcherReceipt) -> String { + let mut output = format!("PR watcher · {}\n{}\n", receipt.operation, receipt.message); + if let Some(watcher) = &receipt.watcher { + output.push_str(&format!( + "{} · {} · every {}s\n", + watcher.repo_path, + if watcher.enabled { + "enabled" + } else { + "disabled" + }, + watcher.interval_secs, + )); } - - let mut repo_path = None; - let mut pull_request = None; - let mut range = None; - let mut preview_url = None; - let mut output = OutputMode::Human; - while let Some(argument) = arguments.next() { - match argument.as_str() { - "--repo" => { - repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)); - } - "--pr" => { - pull_request = Some(required_value(&mut arguments, "--pr")?); - } - "--range" => { - range = Some(required_value(&mut arguments, "--range")?); - } - "--preview" => { - preview_url = Some(required_value(&mut arguments, "--preview")?); - } - "--json" => output = OutputMode::Json, - "--help" | "-h" => return Ok(CliCommand::Help), - _ => return Err(format!("unknown trex argument `{argument}`")), + if !receipt.watchers.is_empty() { + for watcher in &receipt.watchers { + output.push_str(&format!( + "{} · {} · every {}s\n", + watcher.repo_path, + if watcher.enabled { + "enabled" + } else { + "disabled" + }, + watcher.interval_secs, + )); } } + for run in &receipt.runs { + output.push_str(&format!( + "PR #{} · {} · {} · {}ms\n", + run.pr_number, + run.verdict, + run.head_sha.chars().take(7).collect::(), + run.duration_ms, + )); + } + output +} - let (change_kind, change) = match (pull_request, range) { - (Some(value), None) => (TrexChangeKind::PullRequest, value), - (None, Some(value)) => (TrexChangeKind::Range, value), - (Some(_), Some(_)) => return Err("choose exactly one of --pr or --range".into()), - (None, None) => return Err("one of --pr or --range is required".into()), +fn render_human_warm(operation: WarmOperation, value: &serde_json::Value) -> String { + let label = match operation { + WarmOperation::Status => "status", + WarmOperation::Start => "start", + WarmOperation::Stop => "stop", + WarmOperation::Run => "run", + WarmOperation::Cancel => "cancel", + WarmOperation::Cleanup => "cleanup", + WarmOperation::Current => "current identity", }; - let preview_url = preview_url.ok_or_else(|| "--preview is required".to_string())?; - Ok(CliCommand::Trex(TrexArguments { - repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), - change_kind, - change, - preview_url, - output, - })) + format!( + "Warm verification · {label}\n{}\n", + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) + ) } -fn parse_check( - mut arguments: impl Iterator, - cwd: &Path, -) -> Result { - let mut repo_path = None; - let mut pull_request = None; - let mut range = None; - let mut task = None; - let mut spec_paths = Vec::new(); - let mut selected_requirement_ids = Vec::new(); - let mut review_agent = "claude".to_string(); - let mut test_adapter = None; - let mut test_target = None; - let mut test_name = None; - let mut performance_adapter = None; - let mut performance_target = None; - let mut performance_name = None; - let mut baseline_repo_path = None; - let mut samples = 3; - let mut warmups = 1; - let mut timeout_ms = 30_000; - let mut preflight = false; - let mut output = OutputMode::Human; - while let Some(argument) = arguments.next() { - match argument.as_str() { - "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), - "--pr" => pull_request = Some(required_value(&mut arguments, "--pr")?), - "--range" => range = Some(required_value(&mut arguments, "--range")?), - "--task" => task = Some(required_value(&mut arguments, "--task")?), - "--preflight" => preflight = true, - "--spec" => spec_paths.push(PathBuf::from(required_value(&mut arguments, "--spec")?)), - "--requirement" => { - selected_requirement_ids.push(required_value(&mut arguments, "--requirement")?) - } - "--agent" => review_agent = required_value(&mut arguments, "--agent")?, - "--test-adapter" => { - test_adapter = Some(required_value(&mut arguments, "--test-adapter")?) - } - "--test-target" => test_target = Some(required_value(&mut arguments, "--test-target")?), - "--test-name" => test_name = Some(required_value(&mut arguments, "--test-name")?), - "--perf-adapter" => { - performance_adapter = Some(required_value(&mut arguments, "--perf-adapter")?) - } - "--perf-target" => { - performance_target = Some(required_value(&mut arguments, "--perf-target")?) - } - "--perf-name" => { - performance_name = Some(required_value(&mut arguments, "--perf-name")?) - } - "--baseline-repo" => { - baseline_repo_path = Some(PathBuf::from(required_value( - &mut arguments, - "--baseline-repo", - )?)) - } - "--samples" => samples = parse_number(&mut arguments, "--samples")?, - "--warmups" => warmups = parse_number(&mut arguments, "--warmups")?, - "--timeout-ms" => timeout_ms = parse_number(&mut arguments, "--timeout-ms")?, - "--json" => output = OutputMode::Json, - "--help" | "-h" => return Ok(CliCommand::Help), - _ => return Err(format!("unknown check argument `{argument}`")), - } - } - let change = match (pull_request, range) { - (Some(value), None) | (None, Some(value)) => value, - (Some(_), Some(_)) => return Err("choose exactly one of --pr or --range".into()), - (None, None) => return Err("one of --pr or --range is required".into()), - }; - let test_target = paired_target("test", test_adapter, test_target, test_name)?; - let performance_target = paired_target( - "performance", - performance_adapter, - performance_target, - performance_name, - )?; - if spec_paths.is_empty() && !selected_requirement_ids.is_empty() { - return Err("--requirement requires at least one --spec".into()); +fn run_fix_packet(arguments: FixPacketArguments) -> Result { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let receipt = build_agent_fix_packet(&connection, &arguments.run_id, &arguments.finding_ids)?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize agent fix packet: {error}"))? + ), + OutputMode::Human => println!("{}", receipt.markdown), } - Ok(CliCommand::Check(Box::new(CheckArguments { - repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), - change, - task: task.ok_or_else(|| "--task is required".to_string())?, - spec_paths, - selected_requirement_ids, - review_agent, - test_target, - performance_target, - baseline_repo_path, - samples, - warmups, - timeout_ms, - preflight, - output, - }))) + Ok(0) } -fn paired_target( - label: &str, - adapter: Option, - target: Option, - name: Option, -) -> Result, String> { - match (adapter, target) { - (Some(adapter), Some(target)) => Ok(Some(LocalCheckTarget { - adapter, - target, - name, - source: "explicit".into(), - })), - (None, None) if name.is_none() => Ok(None), - _ => Err(format!( - "--{label}-adapter and --{label}-target must be provided together" - )), +async fn run_fix(arguments: FixArguments) -> Result { + let app_data_dir = default_app_data_dir()?; + let receipt = match arguments.operation { + FixOperation::Execute => { + execute_fix_attempt( + app_data_dir, + FixAttemptInput { + run_id: arguments + .run_id + .ok_or("--run-id is required for fix execute")?, + finding_ids: arguments.finding_ids, + agent: arguments.agent, + confirmed: arguments.confirm_run, + timeout_ms: arguments.timeout_ms, + }, + ) + .await? + } + FixOperation::Inspect => inspect_fix_attempt( + &app_data_dir, + arguments + .attempt_id + .as_deref() + .ok_or("--attempt-id is required for fix inspect")?, + )?, + FixOperation::Discard => discard_fix_attempt( + &app_data_dir, + DiscardFixAttemptInput { + attempt_id: arguments + .attempt_id + .ok_or("--attempt-id is required for fix discard")?, + confirmed: arguments.confirm_discard, + }, + )?, + }; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize fix-attempt receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_fix_attempt(&receipt)), } + Ok(match receipt.state.as_str() { + "verified_fixed" | "discarded" => 0, + "reproduced" | "failed" => 1, + _ => 2, + }) } -fn parse_number( - arguments: &mut impl Iterator, - flag: &str, -) -> Result { - required_value(arguments, flag)? - .parse() - .map_err(|_| format!("{flag} requires a number")) +fn render_human_fix_attempt(receipt: &FixAttemptReceipt) -> String { + let files = if receipt.change.changed_files.is_empty() { + "none".into() + } else { + receipt.change.changed_files.join(", ") + }; + let findings = receipt + .recheck + .findings + .iter() + .map(|finding| { + format!( + "- {} · {} · {}", + finding.finding_id, finding.status, finding.reason + ) + }) + .collect::>() + .join("\n"); + format!( + "Isolated fix attempt · {}\nstate: {}\nagent: {} · {}\nworktree: {}\nretained: {}\nchanged files: {}\ndiff check: {}\ncorrectness: {}\nreview: {}\n{}{}\n", + receipt.attempt_id, + receipt.state, + receipt.agent.id, + receipt.agent.status, + receipt.worktree.path, + receipt.worktree.retained, + files, + receipt.recheck.diff_check.status, + receipt.recheck.correctness.status, + receipt.recheck.review.status, + if findings.is_empty() { "" } else { "findings:\n" }, + findings, + ) } -fn required_value( - arguments: &mut impl Iterator, - flag: &str, -) -> Result { - let value = arguments - .next() - .ok_or_else(|| format!("{flag} requires a value"))?; - if value.trim().is_empty() || value.starts_with("--") { - return Err(format!("{flag} requires a value")); +fn run_xray(arguments: XrayArguments) -> Result { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let result = build_agent_pr_xray_from_connection(&connection, arguments.request.clone())?; + let saved_path = arguments + .save_path + .map(|path| { + save_agent_pr_xray_to_path( + &connection, + SaveXrayRequest { + xray: arguments.request, + format: arguments.format, + path, + }, + ) + }) + .transpose()?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&result) + .map_err(|error| format!("serialize X-Ray preview: {error}"))? + ), + OutputMode::Human => { + println!( + "Agent PR X-Ray · {}\noutcome: {}\neligible: {}\nfindings: {} · stages: {}\nmissing requirements: {} · sanitizer issues: {}{}", + result.payload.xray_id, + format!("{:?}", result.payload.outcome).to_ascii_lowercase(), + if result.eligible { "yes" } else { "no" }, + result.payload.findings.len(), + result.payload.stages.len(), + result.missing_requirements.len(), + result.sanitizer_issues.len(), + saved_path + .as_deref() + .map(|path| format!("\nsaved: {path}")) + .unwrap_or_default(), + ); + } } - Ok(value) + Ok(if result.eligible { 0 } else { 2 }) } -fn default_app_data_dir() -> Result { - if let Some(override_dir) = std::env::var_os("CODEVETTER_APP_DATA_DIR") { - return Ok(PathBuf::from(override_dir)); - } - - #[cfg(target_os = "macos")] - { - let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; - Ok(PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("com.codevetter.desktop")) - } - #[cfg(target_os = "windows")] - { - let app_data = - std::env::var_os("APPDATA").ok_or_else(|| "APPDATA is unavailable".to_string())?; - Ok(PathBuf::from(app_data).join("com.codevetter.desktop")) - } - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] - { - if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { - return Ok(PathBuf::from(data_home).join("com.codevetter.desktop")); - } - let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; - Ok(PathBuf::from(home) - .join(".local") - .join("share") - .join("com.codevetter.desktop")) +fn run_rubrics(arguments: RubricsArguments) -> Result { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let receipt = if let Some(pack_id) = arguments.select { + select_rubric_pack(&connection, &pack_id)? + } else if let Some(pack) = arguments.upsert { + upsert_rubric_pack(&connection, pack)? + } else { + read_rubric_settings(&connection, None)? + }; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize rubric settings: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_rubrics(&receipt)), } + Ok(0) } -fn verdict_exit_code(verdict: TrexPreviewVerdict) -> i32 { - match verdict { - TrexPreviewVerdict::PassedWithLimits => 0, - TrexPreviewVerdict::Failed => 1, - TrexPreviewVerdict::NoConfidence => 2, +fn render_human_rubrics(receipt: &RubricSettingsReceipt) -> String { + let mut output = format!( + "Review rubrics · {} packs\nactive: {}\n", + receipt.packs.len(), + receipt + .active_pack_id + .as_deref() + .unwrap_or("default (not explicitly selected)") + ); + for pack in &receipt.packs { + output.push_str(&format!( + "{} {} · {} checks · {} reviews · {} findings\n", + if pack.active { "*" } else { " " }, + pack.name, + pack.checks.len(), + pack.review_count, + pack.total_findings, + )); } + output } -fn local_check_exit_code(verdict: LocalCheckVerdict) -> i32 { - match verdict { - LocalCheckVerdict::PassedWithLimits => 0, - LocalCheckVerdict::NeedsAttention | LocalCheckVerdict::Failed => 1, - LocalCheckVerdict::NoConfidence => 2, +fn run_retention(arguments: RetentionArguments) -> Result { + let mut connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let policy = if arguments.operation == SessionRetentionOperation::Plan { + Some(SessionRetentionPolicy { + max_age_days: arguments.max_age_days, + max_archive_bytes: arguments + .max_archive_mib + .map(|value| value.saturating_mul(1024 * 1024)), + }) + } else { + None + }; + let receipt = run_session_retention_operation( + &mut connection, + arguments.operation, + policy, + arguments.plan_id.as_deref(), + arguments.vacuum, + )?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize session retention: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_retention(&receipt)), } + Ok(0) } -fn preflight_exit_code(status: LocalCheckStatus) -> i32 { - if status == LocalCheckStatus::Ready { - 0 +fn render_human_retention(receipt: &SessionRetentionReceipt) -> String { + match (&receipt.operation, &receipt.plan, &receipt.result) { + (SessionRetentionOperation::Plan, Some(plan), _) => format!( + "Session retention preview\nplan: {}\nremovable: {} sessions · {} rows · {} bytes\nprotected: {} sessions\nprojected archive: {} rows · {} bytes\nsource transcripts deleted: no\n", + plan.id, + plan.candidates.len(), + plan.candidate_rows, + plan.candidate_bytes, + plan.protected.len(), + plan.projected_rows, + plan.projected_bytes, + ), + (SessionRetentionOperation::Apply, _, Some(result)) => format!( + "Session retention applied\n{}\n", + serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()) + ), + (SessionRetentionOperation::Checkpoint, _, Some(result)) => format!( + "Session archive checkpoint complete\n{}\n", + serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()) + ), + _ => "Session retention returned an incomplete receipt\n".to_string(), + } +} + +fn run_mcp(arguments: McpArguments) -> Result { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let db = DbState(Arc::new(Mutex::new(connection))); + let receipt = run_mcp_settings_operation( + arguments.repo_path.to_string_lossy().into_owned(), + arguments.operation, + &db, + )?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize MCP settings: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_mcp(&receipt)), + } + Ok(0) +} + +fn render_human_mcp(receipt: &McpSettingsReceipt) -> String { + let settings = &receipt.settings; + format!( + "Repository MCP · {:?}\nstate: {}\nhistory: {}{}\ntools: {} · resources: {}\naudit rows: {}{}\nserver: {}\n", + receipt.operation, + if settings.enabled { "enabled" } else { "disabled" }, + if settings.indexed { "indexed" } else { "not built" }, + if settings.stale { " (stale)" } else { "" }, + settings.tool_names.len(), + settings.resource_kinds.len(), + settings.recent_audit.len(), + if receipt.cleared_audit_rows > 0 { + format!(" · {} cleared", receipt.cleared_audit_rows) + } else { + String::new() + }, + settings.server_path, + ) +} + +fn run_settings(arguments: SettingsArguments) -> Result { + let receipt = if let Some((key, value)) = arguments.set { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + set_native_setting(&connection, &key, &value)? } else { - 2 + let connection = open_read_only_app_database()?; + list_native_settings(connection.as_ref())? + }; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize native settings: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_settings(&receipt)), } + Ok(0) } -fn render_human_preflight(receipt: &LocalCheckPreflightReceipt) -> String { - let target = |value: Option<&LocalCheckTarget>| { - value - .map(|target| format!("{} {}", target.adapter, target.target)) - .unwrap_or_else(|| "unavailable".into()) +fn run_ops(arguments: OpsArguments) -> Result { + let connection = open_read_only_app_database()?; + let receipt = inspect_ops_status(connection.as_ref(), arguments.window_days)?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize Ops status receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_ops(&receipt)), + } + Ok(0) +} + +fn render_human_ops(receipt: &OpsStatusReceipt) -> String { + format!( + "Ops · {} days\ndatabase: {}\nbilling configuration: Anthropic {} · OpenAI {}\nwebhook: {} · {}\naggregate rows: {}\ncredentials and endpoint values: excluded\n", + receipt.window_days, + if receipt.database_available { "available" } else { "unavailable" }, + if receipt.billing.anthropic_configured { "configured" } else { "not configured" }, + if receipt.billing.openai_configured { "configured" } else { "not configured" }, + if receipt.webhook.configured { "configured" } else { "not configured" }, + receipt.webhook.flavor, + receipt.observability.len(), + ) +} + +fn execute_history_roots(arguments: HistoryRootsArguments) -> Result { + let receipt = match arguments.operation { + HistoryRootsOperation::Read => { + let connection = open_read_only_app_database()?; + run_history_roots(connection.as_ref(), arguments.operation, None)? + } + HistoryRootsOperation::Add | HistoryRootsOperation::Remove => { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + run_history_roots( + Some(&connection), + arguments.operation, + arguments.path.as_deref(), + )? + } + }; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize history-roots receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_history_roots(&receipt)), + } + Ok(0) +} + +fn run_memories(arguments: MemoriesArguments) -> Result { + let operation = if arguments.diff { + MemoryReceiptOperation::Diff + } else if arguments.source_id.is_some() { + MemoryReceiptOperation::Read + } else { + MemoryReceiptOperation::List }; + let receipt = run_memory_receipt(operation, arguments.source_id.as_deref())?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize memories receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_memories(&receipt)), + } + Ok(0) +} + +fn render_human_memories(receipt: &MemoryReceipt) -> String { let mut output = format!( - "preflight: {}\nhead: {}\ncorrectness target: {}\nperformance target: {}\n", - local_status_text(receipt.status), - receipt.source.head_sha, - target(receipt.correctness_target.as_ref()), - target(receipt.performance_target.as_ref()), + "Agent memories · {} of {} bounded sources\n", + receipt.sources.len(), + receipt.sources_total ); - if let Some(spec) = receipt.spec_coverage.as_ref() { + for source in &receipt.sources { output.push_str(&format!( - "specs: {} source(s), {} requirement(s), {} selected\n", - spec.sources.len(), - spec.summary.total_requirements, - spec.summary.selected_for_execution, + " {} · {} · {} · {}\n", + source.id, + source.tool, + source.label, + if source.readable { + "readable" + } else { + "unavailable" + } )); + output.push_str(&format!(" {}\n", source.display_path)); } - if !receipt.limitations.is_empty() { - output.push_str("limitations:\n"); - for limitation in &receipt.limitations { - output.push_str(&format!("- {limitation}\n")); - } + if let Some(document) = &receipt.document { + output.push_str(&format!( + "\nsource: {}{}\n{}\n", + document.source_id, + if document.truncated { + " · truncated" + } else { + "" + }, + document.content + )); } - if receipt.status == LocalCheckStatus::Ready { - output.push_str("next: rerun this command without --preflight to execute verification\n"); + if let Some(diff) = &receipt.diff { + output.push_str(&format!("\ndiff: {} · {}\n", diff.source_id, diff.status)); + if !diff.diff.is_empty() { + output.push_str(&diff.diff); + output.push('\n'); + } } + output.push_str( + "\nRead-only local projection; absolute paths and agent authority are excluded.\n", + ); output } -fn render_human_check(receipt: &LocalCheckReceipt) -> String { - let verdict = serde_json::to_value(receipt.verdict) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| "unknown".into()); +fn render_human_history_roots(receipt: &HistoryRootsReceipt) -> String { let mut output = format!( - "verdict: {verdict}\nhead: {}\nreview: {}\ncorrectness: {}\nperformance: {}\noptimization: {}\n", - receipt.source.head_sha, - local_status_text(receipt.stages.review.status), - local_status_text(receipt.stages.correctness.status), - local_status_text(receipt.stages.performance.status), - local_status_text(receipt.stages.optimization.status), + "Additional Codex history roots · {} configured\n", + receipt.roots.len() ); - render_review_findings(&mut output, &receipt.stages.review.evidence); - if let Some(spec) = receipt.spec_coverage.as_ref() { - let percent = |value: Option| { - value - .map(|number| format!("{number}%")) - .unwrap_or_else(|| "n/a".into()) + for root in &receipt.roots { + let state = if !root.exists { + "missing" + } else if root.sessions_available || root.archived_sessions_available { + "ready" + } else { + "no session folders" }; + output.push_str(&format!(" {} · {state}\n", root.display_path)); + } + output.push_str("Saving a root does not start reconciliation or delete transcripts.\n"); + output +} + +fn run_onboarding(arguments: OnboardingArguments) -> Result { + let receipt = if arguments.complete { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + complete_onboarding( + &connection, + arguments + .default_adapter + .as_deref() + .ok_or("--default-adapter is required with --complete")?, + )? + } else { + let connection = open_read_only_app_database()?; + inspect_onboarding(connection.as_ref())? + }; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize onboarding receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_onboarding(&receipt)), + } + Ok(0) +} + +fn render_human_onboarding(receipt: &OnboardingReceipt) -> String { + let mut output = format!( + "Native onboarding · {}\ndefault agent: {}\n", + if receipt.completed { + "complete" + } else { + "not complete" + }, + receipt.default_adapter + ); + for tool in &receipt.tools { output.push_str(&format!( - "specs: {} source(s), {} requirement(s)\nspec review input: {}/{} ({})\nspec executable evidence: {}/{} ({})\nspec verified: {}/{} ({})\n", - spec.sources.len(), - spec.summary.total_requirements, - spec.summary.review_input_requirements, - spec.summary.total_requirements, - percent(spec.summary.review_input_coverage_percent), - spec.summary.verified + spec.summary.contradicted, - spec.summary.total_requirements, - percent(spec.summary.executable_evidence_coverage_percent), - spec.summary.verified, - spec.summary.total_requirements, - percent(spec.summary.verified_coverage_percent), + "{}: {} · authentication {}\n", + tool.label, + if tool.available { + "available" + } else { + "not found" + }, + tool.authentication.replace('_', " ") )); - if !spec.limitations.is_empty() { - output.push_str("spec limitations:\n"); - for limitation in spec.limitations.iter().take(8) { - output.push_str(&format!("- {limitation}\n")); - } - } } - if let Some(command) = receipt - .stages - .optimization - .evidence - .get("candidate_command") - .and_then(serde_json::Value::as_str) - { - output.push_str(&format!("next: {command}\n")); + for limitation in &receipt.limitations { + output.push_str(&format!("limit: {limitation}\n")); } - if !receipt.limitations.is_empty() { - output.push_str("limitations:\n"); - for limitation in &receipt.limitations { - output.push_str(&format!("- {limitation}\n")); + output +} + +fn render_human_settings(receipt: &NativeSettingsReceipt) -> String { + let mut output = format!( + "Native settings · {} declared non-secret values\n", + receipt.settings.len() + ); + if let Some(saved_key) = &receipt.saved_key { + output.push_str(&format!("saved: {saved_key}\n")); + } + let mut current_section = ""; + for setting in &receipt.settings { + if setting.section != current_section { + current_section = &setting.section; + output.push_str(&format!("\n{}\n", current_section)); } + output.push_str(&format!(" {} = {}\n", setting.key, setting.value)); } + output.push_str("\nSensitive credentials are excluded from this projection.\n"); output } -fn local_status_text(status: LocalCheckStatus) -> String { - serde_json::to_value(status) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| "unknown".into()) +fn render_human_qa(receipt: &QaWorkspaceReceipt) -> String { + let mut output = format!( + "QA workspace · {} workflows · {} Playwright specs\n{}\n", + receipt.workflows.len(), + receipt.specs.len(), + receipt.repo_path + ); + for workflow in &receipt.workflows { + output.push_str(&format!( + "{} · {} · {} targets{}\n", + workflow.name, + workflow.runner_type, + workflow.targets.len(), + if workflow.editable { + "" + } else { + " · read-only" + } + )); + } + if let Some(post_fix) = &receipt.post_fix { + output.push_str(&format!( + "post-fix {} · {}\n", + post_fix.status, post_fix.summary + )); + } + output } -fn render_review_findings(output: &mut String, evidence: &serde_json::Value) { - let Some(findings) = evidence - .get("findings") - .and_then(serde_json::Value::as_array) - else { - return; +fn run_qa(arguments: QaArguments) -> Result { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let mutation = match arguments.operation { + QaOperation::Inspect => QaWorkspaceMutation::Inspect, + QaOperation::SaveWorkflow => QaWorkspaceMutation::SaveWorkflow(StoredQaWorkflow { + id: arguments.workflow_id.ok_or("--workflow-id is required")?, + name: arguments + .workflow_name + .ok_or("--workflow-name is required")?, + base_url: arguments.base_url.unwrap_or_default(), + loop_id: arguments.loop_id.ok_or("--loop-id is required")?, + runner_type: arguments.runner_type.ok_or("--runner is required")?, + goal: arguments.goal.ok_or("--goal is required")?, + repo_spec_path: arguments.repo_spec_path.unwrap_or_default(), + repo_trace_mode: arguments + .repo_trace_mode + .unwrap_or_else(|| "retain-on-failure".into()), + target_route: arguments.target_route.ok_or("--target-route is required")?, + allow_remote_target: arguments.allow_remote_target, + targets: Vec::new(), + updated_at: String::new(), + }), + QaOperation::DeleteWorkflow => QaWorkspaceMutation::DeleteWorkflow { + workflow_id: arguments.workflow_id.ok_or("--workflow-id is required")?, + }, + QaOperation::SaveTarget => QaWorkspaceMutation::SaveTarget { + workflow_id: arguments.workflow_id.ok_or("--workflow-id is required")?, + target: QaTargetPreset { + id: arguments.target_id.ok_or("--target-id is required")?, + name: arguments.target_name.ok_or("--target-name is required")?, + route: arguments.target_route.ok_or("--target-route is required")?, + goal: arguments.goal.ok_or("--goal is required")?, + }, + }, + QaOperation::DeleteTarget => QaWorkspaceMutation::DeleteTarget { + workflow_id: arguments.workflow_id.ok_or("--workflow-id is required")?, + target_id: arguments.target_id.ok_or("--target-id is required")?, + }, }; - let mut findings = findings.iter().collect::>(); - findings.sort_by_key(|finding| { - match finding.get("severity").and_then(serde_json::Value::as_str) { - Some("critical") => 0, - Some("high") => 1, - Some("medium") => 2, - Some("low") => 3, - _ => 4, - } - }); - if findings.is_empty() { - return; + let receipt = run_qa_workspace_headless( + &connection, + arguments.repo_path, + mutation, + arguments.fix_completed_at.as_deref(), + )?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize QA workspace receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_qa(&receipt)), } - output.push_str("review findings:\n"); - for finding in findings.into_iter().take(3) { - let severity = finding - .get("severity") - .and_then(serde_json::Value::as_str) - .map(|value| terminal_text(value, 16)) - .unwrap_or_else(|| "unknown".into()); - let title = finding - .get("title") - .and_then(serde_json::Value::as_str) - .map(|value| terminal_text(value, 180)) - .unwrap_or_else(|| "Untitled finding".into()); - let location = finding - .get("filePath") - .and_then(serde_json::Value::as_str) - .map(|path| { - let path = terminal_text(path, 180); - finding - .get("line") - .and_then(serde_json::Value::as_u64) - .map(|line| format!(" ({path}:{line})")) - .unwrap_or_else(|| format!(" ({path})")) - }) - .unwrap_or_default(); - output.push_str(&format!("- {severity}: {title}{location}\n")); + Ok(0) +} + +async fn run_usage(arguments: UsageArguments) -> Result { + let connection = open_read_only_app_database()?; + let report = get_headless_local_usage_report( + connection.as_ref(), + arguments.refresh, + arguments.timezone.as_deref(), + ) + .await?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&report) + .map_err(|error| format!("serialize local usage report: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_usage(&report)), } + Ok(match report.status.as_str() { + "ready" => 0, + "stale" => 1, + _ => 2, + }) } -fn terminal_text(value: &str, max_chars: usize) -> String { - value - .chars() - .filter_map(|character| match character { - '\n' | '\r' | '\t' => Some(' '), - value if value.is_control() => None, - value => Some(value), - }) - .take(max_chars) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") +#[derive(serde::Serialize)] +struct UnpackHistoryReceipt { + schema_version: &'static str, + generated_at: String, + database_available: bool, + repo_path: Option, + limit: i64, + returned: usize, + reports: Vec, } -fn render_human_receipt(receipt: &TrexPreviewReceipt) -> String { - let verdict = match receipt.verdict { - TrexPreviewVerdict::PassedWithLimits => "passed_with_limits", - TrexPreviewVerdict::Failed => "failed", - TrexPreviewVerdict::NoConfidence => "no_confidence", +fn run_unpack(arguments: UnpackArguments) -> Result { + if arguments.operation == UnpackOperation::QueryWorker { + let connection = open_read_only_app_database()?.ok_or_else(|| { + "the CodeVetter database is unavailable; repository query worker cannot start" + .to_string() + })?; + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + run_repository_query_worker(&connection, stdin.lock(), stdout.lock())?; + return Ok(0); + } + + if arguments.operation == UnpackOperation::Query { + let repo_path = arguments + .repo_path + .as_deref() + .ok_or_else(|| "--repo is required for unpack query".to_string())?; + let connection = open_read_only_app_database()?.ok_or_else(|| { + "the CodeVetter database is unavailable; repository evidence cannot be queried" + .to_string() + })?; + let receipt = query_repository_evidence_with_input( + &connection, + Path::new(repo_path), + RepositoryQueryInput { + domain: arguments + .query_domain + .ok_or("--query-domain is required for unpack query")?, + mode: arguments.query_mode, + query: arguments + .query + .ok_or("--query is required for unpack query")?, + target: arguments.query_target, + direction: arguments.query_direction, + depth: arguments.query_depth, + history_selector: arguments.history_selector, + limit: arguments.limit as usize, + }, + )?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize repository query: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_repo_query(&receipt)), + } + return Ok(0); + } + + if arguments.operation == UnpackOperation::Scan { + let repo_path = arguments + .repo_path + .as_deref() + .ok_or_else(|| "--repo is required for unpack scan".to_string())?; + let repo_path = std::fs::canonicalize(repo_path) + .map_err(|error| format!("repository {repo_path} is unavailable: {error}"))?; + let repo_path = repo_path.to_string_lossy().into_owned(); + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let receipt = scan_and_persist_unpack_snapshot(&connection, &repo_path, None, None)?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize unpack scan receipt: {error}"))? + ), + OutputMode::Human => println!( + "Saved Repo Unpack snapshot {} · {} files · {}", + receipt.report_id, receipt.inventory.files_scanned, receipt.inventory.repo_path + ), + } + return Ok(0); + } + + if arguments.operation == UnpackOperation::Compare { + let repo_path = arguments + .repo_path + .as_deref() + .ok_or_else(|| "--repo is required for unpack compare".to_string())?; + let repo_path = std::fs::canonicalize(repo_path) + .map_err(|error| format!("repository {repo_path} is unavailable: {error}"))?; + let range = compare_unpack_snapshot_commits_headless( + &repo_path.to_string_lossy(), + arguments + .base_commit + .as_deref() + .ok_or("--base-commit is required for unpack compare")?, + arguments + .head_commit + .as_deref() + .ok_or("--head-commit is required for unpack compare")?, + )?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&range) + .map_err(|error| format!("serialize unpack comparison: {error}"))? + ), + OutputMode::Human => println!( + "Repo Unpack delta · {} commits · {} → {}{}", + range.commit_count, + &range.base_commit[..7], + &range.head_commit[..7], + if range.truncated { " · bounded" } else { "" } + ), + } + return Ok(0); + } + + if arguments.operation == UnpackOperation::Export { + let connection = open_read_only_app_database()?.ok_or_else(|| { + "the CodeVetter database is unavailable; no stored snapshot can be exported".to_string() + })?; + let receipt = export_repo_unpack_report_from_connection( + &connection, + arguments + .report_id + .as_deref() + .ok_or("--report-id is required for unpack export")?, + arguments + .format + .as_deref() + .ok_or("--format is required for unpack export")?, + )?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize unpack export: {error}"))? + ), + OutputMode::Human => print!("{}", receipt.content), + } + return Ok(0); + } + + let connection = open_read_only_app_database()?; + if arguments.operation == UnpackOperation::Inspect { + let report_id = arguments + .report_id + .ok_or_else(|| "--report-id is required for unpack inspect".to_string())?; + let connection = connection.as_ref().ok_or_else(|| { + "the CodeVetter database is unavailable; no stored snapshot can be inspected" + .to_string() + })?; + let record = get_repo_unpack_report_from_connection(connection, &report_id)?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&record) + .map_err(|error| format!("serialize unpack snapshot: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_unpack_record(&record)), + } + return Ok(0); + } + + let reports = connection + .as_ref() + .map(|connection| { + list_repo_unpack_reports_from_connection( + connection, + arguments.repo_path.as_deref(), + Some(arguments.limit), + ) + }) + .transpose()? + .unwrap_or_default(); + let receipt = UnpackHistoryReceipt { + schema_version: "codevetter.unpack-history/v1", + generated_at: chrono::Utc::now().to_rfc3339(), + database_available: connection.is_some(), + repo_path: arguments.repo_path, + limit: arguments.limit, + returned: reports.len(), + reports, }; - let preview = serde_json::to_value(receipt.preview.status) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| "unknown".into()); - let passed = receipt - .journeys - .iter() - .filter(|journey| journey.pass) - .count(); + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize unpack history: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_unpack_history(&receipt)), + } + Ok(0) +} + +fn render_human_repo_query(receipt: &RepositoryQueryReceipt) -> String { let mut output = format!( - "verdict: {verdict}\nhead: {}\npreview: {preview}\njourneys: {passed}/{} passed\nsummary: {}\n", - receipt.source.head_sha, - receipt.routes.len(), - receipt.summary + "Repository {} {:?} query · {} · {}\n", + match receipt.domain { + RepositoryQueryDomain::Graph => "graph", + RepositoryQueryDomain::History => "history", + }, + receipt.mode, + receipt.status, + receipt.repo_path ); - if !receipt.limitations.is_empty() { - output.push_str("limitations:\n"); - for limitation in &receipt.limitations { - output.push_str(&format!("- {limitation}\n")); + if let Some(issue) = &receipt.issue { + output.push_str(&format!("Coverage: {issue}\n")); + } + if let Some(result) = &receipt.graph_result { + for hit in &result.hits { + output.push_str(&format!( + "- {} · {} · score {} · {}\n", + hit.node.label, hit.node.kind, hit.score, hit.matched_by + )); } } - for journey in receipt.journeys.iter().filter(|journey| !journey.pass) { - output.push_str(&format!("failure {}: {}\n", journey.route, journey.notes)); - if let Some(path) = &journey.screenshot_path { - output.push_str(&format!("artifact: {path}\n")); + if let Some(result) = &receipt.graph_explanation { + output.push_str(&format!( + "- {} · {} incoming · {} outgoing\n", + result.node.label, result.incoming_count, result.outgoing_count + )); + } + if let Some(result) = &receipt.graph_impact { + output.push_str(&format!( + "- {} · {} affected · depth {}{}\n", + result.root.label, + result.affected.len(), + result.depth_reached, + if result.truncated { " · bounded" } else { "" } + )); + } + if let Some(result) = &receipt.graph_path { + output.push_str(&format!( + "- {} nodes · {} edges · cost {:.3}{}\n", + result.nodes.len(), + result.edges.len(), + result.total_cost, + if result.truncated { " · bounded" } else { "" } + )); + } + if let Some(result) = &receipt.history_result { + for item in &result.items { + output.push_str(&format!( + "- {:?} · {} · {}\n", + item.kind, item.label, item.summary + )); } } + if let Some(result) = &receipt.history_trace { + output.push_str(&format!( + "- {} causal episode(s) · {} scanned events{}\n", + result.episodes.len(), + result.scanned_events, + if result.truncated { " · bounded" } else { "" } + )); + } output } -#[cfg(test)] -mod tests { - use super::*; - use codevetter_desktop::commands::local_check::{LocalCheckStage, LocalCheckStages}; - use codevetter_desktop::commands::synthetic_qa::{SyntheticQaRunResult, SyntheticQaTrace}; - use codevetter_desktop::commands::trex_preview::{ - TrexPreviewIdentity, TrexPreviewIdentityStatus, TrexPreviewRoute, TrexSourceReceipt, - }; +async fn run_performance(arguments: PerformanceArguments) -> Result { + let receipt = run_headless_performance(arguments.input).await?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize performance receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_performance(&receipt)), + } + Ok(match receipt.state.as_str() { + "succeeded" => 0, + "completed_with_rejection" => 1, + _ => 2, + }) +} - fn fixture_receipt(verdict: TrexPreviewVerdict) -> TrexPreviewReceipt { - TrexPreviewReceipt { - schema_version: 1, - run_id: "trex-preview-cli-fixture".into(), - repo_path: "/tmp/widget".into(), - source: TrexSourceReceipt { - kind: TrexChangeKind::Range, - input: "main..HEAD".into(), - base_sha: "a".repeat(40), - head_sha: "b".repeat(40), - commits: vec!["b".repeat(40)], - changed_paths: vec!["src/pages/index.tsx".into()], - }, - preview: TrexPreviewIdentity { - status: TrexPreviewIdentityStatus::Claimed, - requested_url: "https://preview.example.com".into(), - final_url: "https://preview.example.com".into(), - revision: None, - evidence: "No supported revision header was returned.".into(), - }, - routes: vec![TrexPreviewRoute { - route: "/".into(), - reason: "Required root smoke".into(), - }], - journeys: vec![SyntheticQaRunResult { - loop_id: "generic-page-smoke".into(), - route: "/".into(), - goal: "smoke".into(), - pass: verdict != TrexPreviewVerdict::Failed, - notes: "fixture journey".into(), - screenshot_path: None, - artifacts: Vec::new(), - duration_ms: 12, - trace: SyntheticQaTrace { - final_url: "https://preview.example.com/".into(), - page_title: "Preview".into(), - console_errors: Vec::new(), - stage_timings_ms: Default::default(), - runner_rss_bytes: None, - }, - error: None, - runner_type: Some("chromiumoxide_builtin".into()), - }], - verdict, - summary: "Fixture summary.".into(), - limitations: vec!["Preview identity is claimed.".into()], - duration_ms: 42, - ran_at: "2026-07-29T00:00:00Z".into(), +async fn run_scope(arguments: ScopeArguments) -> Result { + let receipt = resolve_evidence_scope(arguments.input).await?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize evidence scope receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_scope(&receipt)), + } + Ok(if receipt.status == "ready" { 0 } else { 2 }) +} + +fn run_capabilities(output: OutputMode) -> Result { + let registry = capability_registry(); + match output { + OutputMode::Json => println!( + "{}", + serde_json::to_string_pretty(®istry) + .map_err(|error| format!("serialize capability registry: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_capabilities(®istry)), + } + Ok(0) +} + +fn run_runs(arguments: RunsArguments) -> Result { + let repo_path = arguments + .repo_path + .map(|path| { + std::fs::canonicalize(&path) + .map(|value| value.to_string_lossy().into_owned()) + .map_err(|error| format!("repository {} is unavailable: {error}", path.display())) + }) + .transpose()?; + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let history = list_run_history(&connection, repo_path.as_deref(), arguments.limit)?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string_pretty(&history) + .map_err(|error| format!("serialize run history: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_runs(&history)), + } + Ok(0) +} + +fn render_human_runs(history: &RunHistoryReceipt) -> String { + if history.runs.is_empty() { + return "No verification runs recorded.\n".into(); + } + let mut output = String::new(); + for record in &history.runs { + let source = record.source_label.as_deref().unwrap_or("—"); + output.push_str(&format!( + "{} {:?} {} {}\n {}\n {}\n", + record.recorded_at, + record.kind, + record.outcome, + source, + record.title, + record + .repo_path + .as_deref() + .unwrap_or("No repository recorded"), + )); + } + output +} + +fn render_human_usage(report: &LocalUsageReport) -> String { + let mut output = format!( + "Local usage · {} {} · {}\nstatus: {}{}\nagents: {}\ntokens: {} total · {} generated · {} cache read\ncost: ${:.2}\nperiods: {} daily · {} weekly · {} monthly · {} sessions\nsource: {}\n", + report.provenance.engine, + report.provenance.version, + report.provenance.timezone, + report.status, + if report.stale { " (stale)" } else { "" }, + if report.provenance.detected_agents.is_empty() { + "none".into() + } else { + report.provenance.detected_agents.join(", ") + }, + report.totals.total_tokens, + report.totals.generated_tokens(), + report.totals.cache_read_tokens, + report.totals.cost_usd, + report.daily.len(), + report.weekly.len(), + report.monthly.len(), + report.sessions.len(), + if report.provenance.source_fingerprint.is_empty() { + "unavailable" + } else { + &report.provenance.source_fingerprint + }, + ); + output.push_str("boundary: Claude, Codex, and Grok are accounted by ccusage; Devin and live provider quotas are separate.\n"); + if let Some(devin) = &report.devin { + output.push_str(&format!( + "devin: {} · {} sessions · {} generated · {} cache read · ${:.2} · all-time separate source\n", + devin.status, + devin.sessions, + devin.generated_tokens, + devin.cache_read_tokens, + devin.cost_usd, + )); + if !devin.windows.is_empty() { + output.push_str("devin windows:"); + for window in &devin.windows { + output.push_str(&format!( + " {} {} sessions / {} generated / ${:.2};", + window.window, window.sessions, window.generated_tokens, window.cost_usd + )); + } + output.push('\n'); } } + if let Some(error) = &report.error { + output.push_str(&format!("error [{}]: {}\n", error.category, error.message)); + } + if !report.provenance.excluded_agents.is_empty() { + output.push_str(&format!( + "excluded: {}\n", + report.provenance.excluded_agents.join(", ") + )); + } + output +} + +fn render_human_unpack_history(receipt: &UnpackHistoryReceipt) -> String { + if !receipt.database_available { + return "No CodeVetter database is available. No stored Repo Unpack snapshots were changed.\n" + .into(); + } + if receipt.reports.is_empty() { + return "No stored Repo Unpack snapshots match this scope.\n".into(); + } + let mut output = format!("Stored Repo Unpack snapshots ({})\n", receipt.returned); + for report in &receipt.reports { + output.push_str(&format!( + "{} {} {} files {}\n {}\n", + report.created_at, + report.status, + report.files_scanned, + report.commit_sha.as_deref().unwrap_or("no commit"), + report.id, + )); + } + output +} - fn fixture_local_check(verdict: LocalCheckVerdict) -> LocalCheckReceipt { - let stage = |status| LocalCheckStage { - status, - duration_ms: 12, - target: None, - evidence: serde_json::json!({}), - limitations: Vec::new(), +fn render_human_unpack_record(record: &UnpackReportRecord) -> String { + format!( + "{} · {}\nstatus: {}\ncommit: {}\nfiles: {} scanned · {} skipped\nbytes: {}\nanalysis: {}\nsnapshot: {}\n", + record.summary.repo_name, + record.summary.repo_path, + record.summary.status, + record.summary.commit_sha.as_deref().unwrap_or("unrecorded"), + record.summary.files_scanned, + record.summary.files_skipped, + record.bytes_scanned, + if record.summary.analysis_ready { + "available" + } else { + "not generated" + }, + record.summary.id, + ) +} + +fn render_human_capabilities(registry: &CapabilityRegistry) -> String { + let mut output = format!("CodeVetter capabilities ({})\n\n", registry.schema_version); + for capability in ®istry.capabilities { + output.push_str(&format!( + "{} [{} / {:?}]\n {}\n UI: {} | CLI: {} | agent: {}\n", + capability.name, + capability.id, + capability.stage, + capability.purpose, + availability_label(capability.surfaces.ui.availability), + availability_label(capability.surfaces.cli.availability), + availability_label(capability.surfaces.agent.availability), + )); + if !capability.underlying_tools.is_empty() { + output.push_str(" Uses: "); + output.push_str( + &capability + .underlying_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>() + .join(", "), + ); + output.push('\n'); + } + output.push_str(&format!(" Next: {}\n\n", capability.next_step)); + } + output +} + +fn availability_label(availability: Availability) -> &'static str { + match availability { + Availability::Available => "available", + Availability::Building => "building", + Availability::Planned => "planned", + Availability::Unavailable => "unavailable", + } +} + +async fn run_collect(arguments: CollectArguments) -> Result { + let receipt = collect_tool_evidence(ToolCollectionInput { + repo_path: arguments.repo_path, + change: arguments.change, + collectors: arguments.collectors, + }) + .await?; + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize tool collection receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_collection(&receipt)), + } + Ok(collection_exit_code(&receipt)) +} + +async fn run_check(arguments: CheckArguments) -> Result { + let output = arguments.output; + let preflight = arguments.preflight; + let progress_json = arguments.progress_json; + let request_id = arguments.request_id; + let (standards_pack, standards_context) = if preflight { + (None, None) + } else { + active_rubric_context()? + }; + let input = LocalCheckInput { + repo_path: arguments.repo_path, + change: arguments.change, + task: arguments.task, + standards_pack, + standards_context, + spec_paths: arguments.spec_paths, + selected_requirement_ids: arguments.selected_requirement_ids, + review_agent: arguments.review_agent, + test_target: arguments.test_target, + performance_target: arguments.performance_target, + baseline_repo_path: arguments.baseline_repo_path, + samples: arguments.samples, + warmups: arguments.warmups, + timeout_ms: arguments.timeout_ms, + }; + let operation = if preflight { + VerificationOperation::Preflight + } else { + VerificationOperation::Execute + }; + let command = VerificationCommand::new(request_id, operation, input)?; + let human_progress = output == OutputMode::Human; + let result = run_verification_command(command, |progress| { + if human_progress { + eprintln!("[codevetter] {}: {}", progress.stage, progress.state); + } else if progress_json { + eprintln!("{}", render_progress_json(&progress)); + } + }) + .await?; + + match result { + VerificationResult::Preflight(receipt) => { + match output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize local check preflight: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_preflight(&receipt)), + } + Ok(preflight_exit_code(receipt.status)) + } + VerificationResult::Complete(receipt) => { + match output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize local check receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_check(&receipt)), + } + Ok(local_check_exit_code(receipt.verdict)) + } + } +} + +fn active_rubric_context() -> Result<(Option, Option), String> { + let connection = db::init_db(default_app_data_dir()?) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let (active_pack_id, prompt) = active_rubric_prompt(&connection)?; + Ok((active_pack_id, Some(prompt))) +} + +fn render_progress_json(progress: &VerificationProgress) -> String { + serde_json::to_string(progress).expect("verification progress is serializable") +} + +fn app_version() -> String { + serde_json::from_str::(include_str!("../../tauri.conf.json")) + .ok() + .and_then(|config| config.get("version")?.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()) +} + +async fn run_trex(arguments: TrexArguments) -> Result { + let repo_path = std::fs::canonicalize(&arguments.repo_path).map_err(|error| { + format!( + "repository {} is unavailable: {error}", + arguments.repo_path.display() + ) + })?; + let app_data_dir = default_app_data_dir()?; + let connection = db::init_db(app_data_dir.clone()) + .map_err(|error| format!("open CodeVetter database: {error}"))?; + let db = DbState(Arc::new(Mutex::new(connection))); + let receipt = execute_trex_preview( + TrexPreviewRunInput { + repo_path: repo_path.to_string_lossy().into_owned(), + change_kind: arguments.change_kind, + change: arguments.change, + preview_url: arguments.preview_url, + target_route: arguments.target_route, + target_goal: arguments.target_goal, + }, + &db, + app_data_dir, + None, + ) + .await?; + + match arguments.output { + OutputMode::Json => println!( + "{}", + serde_json::to_string(&receipt) + .map_err(|error| format!("serialize T-Rex receipt: {error}"))? + ), + OutputMode::Human => print!("{}", render_human_receipt(&receipt)), + } + Ok(verdict_exit_code(receipt.verdict)) +} + +fn parse_arguments( + arguments: impl IntoIterator, + cwd: &Path, +) -> Result { + let mut arguments = arguments.into_iter(); + let Some(command) = arguments.next() else { + return Ok(CliCommand::Help); + }; + match command.as_str() { + "--help" | "-h" | "help" => return Ok(CliCommand::Help), + "--version" | "-V" => return Ok(CliCommand::Version), + "capabilities" => { + let arguments = arguments.collect::>(); + return match arguments.as_slice() { + [] => Ok(CliCommand::Capabilities(OutputMode::Human)), + [argument] if argument == "--json" => { + Ok(CliCommand::Capabilities(OutputMode::Json)) + } + [argument] if argument == "--schema" => Ok(CliCommand::CapabilitySchema), + _ => Err("capabilities accepts only --json or --schema".to_string()), + }; + } + "check" => return parse_check(arguments, cwd), + "collect" => return parse_collect(arguments, cwd), + "runs" => return parse_runs(arguments), + "performance" => return parse_performance(arguments, cwd), + "scope" => return parse_scope(arguments, cwd), + "usage" => return parse_usage(arguments), + "ops" => return parse_ops(arguments), + "unpack" => return parse_unpack(arguments), + "settings" => return parse_settings(arguments), + "history-roots" => return parse_history_roots(arguments), + "memories" => return parse_memories(arguments), + "onboarding" => return parse_onboarding(arguments), + "qa" => return parse_qa(arguments, cwd), + "mcp" => return parse_mcp(arguments), + "retention" => return parse_retention(arguments), + "rubrics" => return parse_rubrics(arguments), + "fix" => return parse_fix(arguments), + "fix-packet" => return parse_fix_packet(arguments), + "xray" => return parse_xray(arguments), + "warm" => return parse_warm(arguments, cwd), + "differential" => return parse_differential(arguments, cwd), + "scenario" => return parse_scenario(arguments, cwd), + "watcher" => return parse_watcher(arguments, cwd), + "trex" => {} + _ => return Err(format!("unknown command `{command}`\n\n{HELP}")), + } + + let mut repo_path = None; + let mut pull_request = None; + let mut range = None; + let mut preview_url = None; + let mut target_route = None; + let mut target_goal = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => { + repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)); + } + "--pr" => { + pull_request = Some(required_value(&mut arguments, "--pr")?); + } + "--range" => { + range = Some(required_value(&mut arguments, "--range")?); + } + "--preview" => { + preview_url = Some(required_value(&mut arguments, "--preview")?); + } + "--route" => { + target_route = Some(required_value(&mut arguments, "--route")?); + } + "--journey-goal" => { + target_goal = Some(required_value(&mut arguments, "--journey-goal")?); + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown trex argument `{argument}`")), + } + } + + let (change_kind, change) = match (pull_request, range) { + (Some(value), None) => (TrexChangeKind::PullRequest, value), + (None, Some(value)) => (TrexChangeKind::Range, value), + (Some(_), Some(_)) => return Err("choose exactly one of --pr or --range".into()), + (None, None) => return Err("one of --pr or --range is required".into()), + }; + let preview_url = preview_url.ok_or_else(|| "--preview is required".to_string())?; + Ok(CliCommand::Trex(TrexArguments { + repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), + change_kind, + change, + preview_url, + target_route, + target_goal, + output, + })) +} + +fn parse_watcher( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut operation = None; + let mut interval_secs = None; + let mut base_branch = None; + let mut pr_number = None; + let mut limit = 50_u32; + let mut limit_supplied = false; + let mut confirm_run = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--operation" => operation = Some(required_value(&mut arguments, "--operation")?), + "--interval-secs" => { + interval_secs = Some( + required_value(&mut arguments, "--interval-secs")? + .parse::() + .map_err(|_| "--interval-secs must be a positive integer".to_string())?, + ) + } + "--base-branch" => base_branch = Some(required_value(&mut arguments, "--base-branch")?), + "--pr-number" => { + let value = required_value(&mut arguments, "--pr-number")?; + let parsed = value + .parse::() + .map_err(|_| "--pr-number must be a positive integer".to_string())?; + if parsed <= 0 { + return Err("--pr-number must be a positive integer".to_string()); + } + pr_number = Some(parsed); + } + "--limit" => { + limit_supplied = true; + limit = required_value(&mut arguments, "--limit")? + .parse::() + .map_err(|_| "--limit must be an integer from 1 to 100".to_string())?; + if !(1..=100).contains(&limit) { + return Err("--limit must be an integer from 1 to 100".to_string()); + } + } + "--confirm-run" => confirm_run = true, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown watcher argument `{argument}`")), + } + } + let operation = match operation + .ok_or_else(|| "--operation is required for watcher".to_string())? + .as_str() + { + "list" => WatcherOperation::List, + "enable" => WatcherOperation::Enable, + "disable" => WatcherOperation::Disable, + "poll" => WatcherOperation::Poll, + "retry" => WatcherOperation::Retry, + "runs" => WatcherOperation::Runs, + value => return Err(format!("unsupported watcher operation `{value}`")), + }; + if operation != WatcherOperation::Enable && (interval_secs.is_some() || base_branch.is_some()) { + return Err( + "--interval-secs and --base-branch are only valid for watcher enable".to_string(), + ); + } + if operation != WatcherOperation::Runs && limit_supplied { + return Err("--limit is only valid for watcher runs".to_string()); + } + if operation != WatcherOperation::Retry && pr_number.is_some() { + return Err("--pr-number is only valid for watcher retry".to_string()); + } + if operation == WatcherOperation::Retry && pr_number.is_none() { + return Err("--pr-number is required for watcher retry".to_string()); + } + if !matches!(operation, WatcherOperation::Poll | WatcherOperation::Retry) && confirm_run { + return Err("--confirm-run is only valid for watcher poll or retry".to_string()); + } + if operation == WatcherOperation::List && repo_path.is_some() { + return Err("watcher list does not accept --repo".to_string()); + } + if operation == WatcherOperation::Poll && !confirm_run { + return Err( + "watcher poll requires --confirm-run because it may use network access, invoke an agent, execute project code, and post GitHub statuses" + .to_string(), + ); + } + if operation == WatcherOperation::Retry && !confirm_run { + return Err( + "watcher retry requires --confirm-run because it may use network access, invoke an agent, execute project code, and post GitHub statuses" + .to_string(), + ); + } + if matches!( + operation, + WatcherOperation::Enable + | WatcherOperation::Disable + | WatcherOperation::Poll + | WatcherOperation::Retry + | WatcherOperation::Runs + ) && repo_path.is_none() + { + repo_path = Some(cwd.to_path_buf()); + } + Ok(CliCommand::Watcher(WatcherArguments { + repo_path, + operation, + interval_secs, + base_branch, + pr_number, + limit, + confirm_run, + output, + })) +} + +fn parse_scenario( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut operation = None; + let mut candidate_id = None; + let mut candidate_hash = None; + let mut spec_path = None; + let mut spec_section = None; + let mut model = None; + let mut capabilities = Vec::new(); + let mut auth_profiles = Vec::new(); + let mut states = Vec::new(); + let mut routes = Vec::new(); + let mut examples = Vec::new(); + let mut include_request_policy = false; + let mut destinations = Vec::new(); + let mut approve_replacements = false; + let mut apply_cleanup = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--operation" => operation = Some(required_value(&mut arguments, "--operation")?), + "--candidate-id" => { + candidate_id = Some(required_value(&mut arguments, "--candidate-id")?) + } + "--candidate-hash" => { + candidate_hash = Some(required_value(&mut arguments, "--candidate-hash")?) + } + "--spec" => spec_path = Some(required_value(&mut arguments, "--spec")?), + "--section" => spec_section = Some(required_value(&mut arguments, "--section")?), + "--model" => model = Some(required_value(&mut arguments, "--model")?), + "--capability" => capabilities.push(required_value(&mut arguments, "--capability")?), + "--auth-profile" => { + auth_profiles.push(required_value(&mut arguments, "--auth-profile")?) + } + "--state" => states.push(required_value(&mut arguments, "--state")?), + "--route" => routes.push(required_value(&mut arguments, "--route")?), + "--example" => examples.push(required_value(&mut arguments, "--example")?), + "--request-policy" => include_request_policy = true, + "--destination" => destinations.push(required_value(&mut arguments, "--destination")?), + "--approve-replacements" => approve_replacements = true, + "--apply-cleanup" => apply_cleanup = true, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown scenario argument `{argument}`")), + } + } + let operation = operation.ok_or_else(|| "--operation is required for scenario".to_string())?; + let generation_fields = spec_path.is_some() + || spec_section.is_some() + || model.is_some() + || include_request_policy + || !capabilities.is_empty() + || !auth_profiles.is_empty() + || !states.is_empty() + || !routes.is_empty() + || !examples.is_empty(); + let acceptance_fields = + candidate_hash.is_some() || !destinations.is_empty() || approve_replacements; + let action = match operation.as_str() { + "generate" => { + if candidate_id.is_some() || acceptance_fields || apply_cleanup { + return Err("scenario generate does not accept candidate mutation fields".into()); + } + ScenarioCompilerAction::Generate { + spec_source_path: spec_path.ok_or("--spec is required for scenario generate")?, + spec_section, + provider: Box::new(ProviderSelection { + kind: "local_command".into(), + provider: "local".into(), + model: model.ok_or("--model is required for scenario generate")?, + cost_class: "free".into(), + paid_approved: false, + }), + context: Box::new(ContextSelection { + capabilities, + auth_profiles, + states, + routes, + include_request_policy, + examples, + }), + } + } + "inspect" => { + if generation_fields || acceptance_fields || apply_cleanup { + return Err("scenario inspect accepts only an optional --candidate-id".into()); + } + ScenarioCompilerAction::Inspect { candidate_id } + } + "validate" | "dry-run" => { + if generation_fields || acceptance_fields || apply_cleanup { + return Err("scenario validate and dry-run accept only --candidate-id".into()); + } + let candidate_id = candidate_id.ok_or("--candidate-id is required")?; + if operation == "validate" { + ScenarioCompilerAction::Validate { candidate_id } + } else { + ScenarioCompilerAction::DryRun { candidate_id } + } + } + "accept" => { + if generation_fields || apply_cleanup { + return Err("scenario accept does not accept generation or cleanup fields".into()); + } + ScenarioCompilerAction::Accept { + candidate_id: candidate_id.ok_or("--candidate-id is required")?, + expected_candidate_hash: candidate_hash.ok_or("--candidate-hash is required")?, + selected_destinations: destinations, + approve_replacements, + } + } + "reject" => { + if generation_fields + || !destinations.is_empty() + || approve_replacements + || apply_cleanup + { + return Err("scenario reject accepts only candidate identity and hash".into()); + } + ScenarioCompilerAction::Reject { + candidate_id: candidate_id.ok_or("--candidate-id is required")?, + expected_candidate_hash: candidate_hash.ok_or("--candidate-hash is required")?, + } + } + "cleanup" => { + if generation_fields || candidate_id.is_some() || acceptance_fields || !apply_cleanup { + return Err("scenario cleanup requires only explicit --apply-cleanup".into()); + } + ScenarioCompilerAction::Cleanup {} + } + value => return Err(format!("unsupported scenario operation `{value}`")), + }; + Ok(CliCommand::Scenario(ScenarioArguments { + repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), + action, + output, + })) +} + +fn parse_differential( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut operation = None; + let mut run_id = None; + let mut reference = None; + let mut candidate_kind = None; + let mut candidate_revision = None; + let mut dry_run = false; + let mut apply_cleanup = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--operation" => { + operation = Some( + match required_value(&mut arguments, "--operation")?.as_str() { + "prepare" => DifferentialOperation::Prepare, + "run" => DifferentialOperation::Run, + "cancel" => DifferentialOperation::Cancel, + "cleanup" => DifferentialOperation::Cleanup, + value => { + return Err(format!("unsupported differential operation `{value}`")) + } + }, + ) + } + "--run-id" => run_id = Some(required_value(&mut arguments, "--run-id")?), + "--reference" => reference = Some(required_value(&mut arguments, "--reference")?), + "--candidate" => { + let value = required_value(&mut arguments, "--candidate")?; + if !matches!(value.as_str(), "worktree" | "staged" | "commit" | "range") { + return Err("--candidate must be worktree, staged, commit, or range".into()); + } + candidate_kind = Some(value); + } + "--revision" => { + candidate_revision = Some(required_value(&mut arguments, "--revision")?) + } + "--dry-run" => dry_run = true, + "--apply-cleanup" => apply_cleanup = true, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown differential argument `{argument}`")), + } + } + let operation = + operation.ok_or_else(|| "--operation is required for differential".to_string())?; + if matches!( + operation, + DifferentialOperation::Prepare | DifferentialOperation::Run + ) { + if run_id.is_none() || reference.is_none() || candidate_kind.is_none() { + return Err( + "differential prepare and run require --run-id, --reference, and --candidate" + .into(), + ); + } + let kind = candidate_kind.as_deref().unwrap_or_default(); + if matches!(kind, "commit" | "range") != candidate_revision.is_some() { + return Err("--revision is required only for commit and range candidates".into()); + } + } else if operation == DifferentialOperation::Cancel { + if run_id.is_none() + || reference.is_some() + || candidate_kind.is_some() + || candidate_revision.is_some() + { + return Err("differential cancel accepts only --run-id".into()); + } + } else { + if run_id.is_some() + || reference.is_some() + || candidate_kind.is_some() + || candidate_revision.is_some() + { + return Err("differential cleanup does not accept run or source selection".into()); + } + if dry_run == apply_cleanup { + return Err( + "differential cleanup requires exactly one of --dry-run or --apply-cleanup".into(), + ); + } + } + if operation != DifferentialOperation::Cleanup && (dry_run || apply_cleanup) { + return Err("cleanup authority is only valid for differential cleanup".into()); + } + Ok(CliCommand::Differential(DifferentialArguments { + repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), + operation, + run_id, + reference, + candidate_kind, + candidate_revision, + dry_run, + output, + })) +} + +fn parse_warm( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut operation = None; + let mut run_id = None; + let mut detailed = false; + let mut dry_run = false; + let mut apply_cleanup = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--operation" => { + operation = Some( + match required_value(&mut arguments, "--operation")?.as_str() { + "status" => WarmOperation::Status, + "start" => WarmOperation::Start, + "stop" => WarmOperation::Stop, + "run" => WarmOperation::Run, + "cancel" => WarmOperation::Cancel, + "cleanup" => WarmOperation::Cleanup, + "current" => WarmOperation::Current, + value => return Err(format!("unsupported warm operation `{value}`")), + }, + ); + } + "--run-id" => run_id = Some(required_value(&mut arguments, "--run-id")?), + "--detailed" => detailed = true, + "--dry-run" => dry_run = true, + "--apply-cleanup" => apply_cleanup = true, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown warm argument `{argument}`")), + } + } + let operation = operation.ok_or_else(|| "--operation is required for warm".to_string())?; + if matches!(operation, WarmOperation::Run | WarmOperation::Cancel) && run_id.is_none() { + return Err("--run-id is required for warm run and cancel".into()); + } + if !matches!(operation, WarmOperation::Run | WarmOperation::Cancel) && run_id.is_some() { + return Err("--run-id is only valid for warm run and cancel".into()); + } + if detailed && operation != WarmOperation::Run { + return Err("--detailed is only valid for warm run".into()); + } + if (dry_run || apply_cleanup) && operation != WarmOperation::Cleanup { + return Err("--dry-run and --apply-cleanup are only valid for warm cleanup".into()); + } + if operation == WarmOperation::Cleanup && dry_run == apply_cleanup { + return Err("warm cleanup requires exactly one of --dry-run or --apply-cleanup".into()); + } + Ok(CliCommand::Warm(WarmArguments { + repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), + operation, + run_id, + detailed, + dry_run, + output, + })) +} + +fn parse_fix_packet(mut arguments: impl Iterator) -> Result { + let mut run_id = None; + let mut finding_ids = Vec::new(); + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--run-id" => run_id = Some(required_value(&mut arguments, "--run-id")?), + "--finding" => finding_ids.push(required_value(&mut arguments, "--finding")?), + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown fix-packet argument `{argument}`")), + } + } + if finding_ids.len() > 100 { + return Err("at most 100 --finding values are allowed".into()); + } + Ok(CliCommand::FixPacket(FixPacketArguments { + run_id: run_id.ok_or_else(|| "--run-id is required".to_string())?, + finding_ids, + output, + })) +} + +fn parse_fix(mut arguments: impl Iterator) -> Result { + let mut operation = None; + let mut run_id = None; + let mut finding_ids = Vec::new(); + let mut attempt_id = None; + let mut agent = "codex".to_string(); + let mut confirm_run = false; + let mut confirm_discard = false; + let mut timeout_ms = 30_000; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--operation" => { + operation = Some( + match required_value(&mut arguments, "--operation")?.as_str() { + "execute" => FixOperation::Execute, + "inspect" => FixOperation::Inspect, + "discard" => FixOperation::Discard, + value => return Err(format!("unsupported fix operation `{value}`")), + }, + ); + } + "--run-id" => run_id = Some(required_value(&mut arguments, "--run-id")?), + "--finding" => finding_ids.push(required_value(&mut arguments, "--finding")?), + "--attempt-id" => attempt_id = Some(required_value(&mut arguments, "--attempt-id")?), + "--agent" => agent = required_value(&mut arguments, "--agent")?, + "--confirm-run" => confirm_run = true, + "--confirm-discard" => confirm_discard = true, + "--timeout-ms" => timeout_ms = parse_number(&mut arguments, "--timeout-ms")?, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown fix argument `{argument}`")), + } + } + let operation = operation.ok_or_else(|| "--operation is required for fix".to_string())?; + match operation { + FixOperation::Execute => { + if run_id.is_none() || finding_ids.is_empty() { + return Err("fix execute requires --run-id and at least one --finding".into()); + } + if attempt_id.is_some() || confirm_discard { + return Err( + "--attempt-id and --confirm-discard are not valid for fix execute".into(), + ); + } + if !confirm_run { + return Err("fix execute requires --confirm-run".into()); + } + } + FixOperation::Inspect => { + if attempt_id.is_none() { + return Err("fix inspect requires --attempt-id".into()); + } + if run_id.is_some() + || !finding_ids.is_empty() + || confirm_run + || confirm_discard + || agent != "codex" + || timeout_ms != 30_000 + { + return Err("fix inspect accepts only --attempt-id and output options".into()); + } + } + FixOperation::Discard => { + if attempt_id.is_none() || !confirm_discard { + return Err("fix discard requires --attempt-id and --confirm-discard".into()); + } + if run_id.is_some() + || !finding_ids.is_empty() + || confirm_run + || agent != "codex" + || timeout_ms != 30_000 + { + return Err( + "fix discard accepts only --attempt-id, --confirm-discard, and output options" + .into(), + ); + } + } + } + if finding_ids.len() > 100 { + return Err("at most 100 --finding values are allowed".into()); + } + Ok(CliCommand::Fix(FixArguments { + operation, + run_id, + finding_ids, + attempt_id, + agent, + confirm_run, + confirm_discard, + timeout_ms, + output, + })) +} + +fn parse_xray(mut arguments: impl Iterator) -> Result { + let mut review_id = None; + let mut public_source = None; + let mut public_source_confirmed = false; + let mut approved_excerpt_finding_ids = Vec::new(); + let mut corpus_state = None; + let mut format = XrayFormat::Html; + let mut save_path = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--review-id" => review_id = Some(required_value(&mut arguments, "--review-id")?), + "--public-source" => { + public_source = Some(required_value(&mut arguments, "--public-source")?) + } + "--confirm-public" => public_source_confirmed = true, + "--approve-excerpt" => approved_excerpt_finding_ids + .push(required_value(&mut arguments, "--approve-excerpt")?), + "--corpus-state" => { + corpus_state = Some(required_value(&mut arguments, "--corpus-state")?) + } + "--format" => { + format = match required_value(&mut arguments, "--format")?.as_str() { + "json" => XrayFormat::Json, + "markdown" => XrayFormat::Markdown, + "html" => XrayFormat::Html, + _ => return Err("--format must be json, markdown, or html".into()), + } + } + "--save" => save_path = Some(required_value(&mut arguments, "--save")?), + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown xray argument `{argument}`")), + } + } + let review_id = review_id.ok_or_else(|| "--review-id is required".to_string())?; + if review_id.len() > 128 || review_id.trim() != review_id || review_id.contains('\0') { + return Err("--review-id must be a bounded non-empty identity".into()); + } + if approved_excerpt_finding_ids.len() > 100 { + return Err("at most 100 --approve-excerpt values are allowed".into()); + } + Ok(CliCommand::Xray(XrayArguments { + request: XrayRequest { + review_id, + public_source_confirmed, + public_source, + approved_excerpt_finding_ids, + corpus_state, + }, + format, + save_path, + output, + })) +} + +fn parse_rubrics(mut arguments: impl Iterator) -> Result { + let mut select = None; + let mut id = None; + let mut name = None; + let mut focus = None; + let mut checks = Vec::new(); + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--select" => select = Some(required_value(&mut arguments, "--select")?), + "--id" => id = Some(required_value(&mut arguments, "--id")?), + "--name" => name = Some(required_value(&mut arguments, "--name")?), + "--focus" => focus = Some(required_value(&mut arguments, "--focus")?), + "--check" => checks.push(required_value(&mut arguments, "--check")?), + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown rubrics argument `{argument}`")), + } + } + let has_upsert_input = id.is_some() || name.is_some() || focus.is_some() || !checks.is_empty(); + if select.is_some() && has_upsert_input { + return Err("--select cannot be combined with custom-pack fields".into()); + } + let upsert = if has_upsert_input { + Some(RubricPackInput { + id: id.ok_or_else(|| "custom rubric packs require --id".to_string())?, + name: name.ok_or_else(|| "custom rubric packs require --name".to_string())?, + focus: focus.ok_or_else(|| "custom rubric packs require --focus".to_string())?, + checks, + }) + } else { + None + }; + Ok(CliCommand::Rubrics(RubricsArguments { + select, + upsert, + output, + })) +} + +fn parse_retention(mut arguments: impl Iterator) -> Result { + let mut max_age_days = None; + let mut max_archive_mib = None; + let mut plan_id = None; + let mut checkpoint = false; + let mut vacuum = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--max-age-days" => { + max_age_days = Some(parse_number(&mut arguments, "--max-age-days")?) + } + "--max-archive-mib" => { + max_archive_mib = Some(parse_number(&mut arguments, "--max-archive-mib")?) + } + "--apply" => plan_id = Some(required_value(&mut arguments, "--apply")?), + "--checkpoint" => checkpoint = true, + "--vacuum" => vacuum = true, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown retention argument `{argument}`")), + } + } + if plan_id.is_some() && checkpoint { + return Err("choose exactly one of --apply or --checkpoint".into()); + } + if vacuum && !checkpoint { + return Err("--vacuum requires --checkpoint".into()); + } + let operation = if plan_id.is_some() { + SessionRetentionOperation::Apply + } else if checkpoint { + SessionRetentionOperation::Checkpoint + } else { + SessionRetentionOperation::Plan + }; + if operation == SessionRetentionOperation::Plan { + if max_age_days.is_none() && max_archive_mib.is_none() { + return Err("retention preview requires --max-age-days or --max-archive-mib".into()); + } + if max_age_days.is_some_and(|value| !(1..=3650).contains(&value)) { + return Err("--max-age-days must be between 1 and 3650".into()); + } + if max_archive_mib.is_some_and(|value| !(1..=524_288).contains(&value)) { + return Err("--max-archive-mib must be between 1 and 524288".into()); + } + } else if max_age_days.is_some() || max_archive_mib.is_some() { + return Err("policy flags are only valid for a retention preview".into()); + } + Ok(CliCommand::Retention(RetentionArguments { + operation, + max_age_days, + max_archive_mib, + plan_id, + vacuum, + output, + })) +} + +fn parse_usage(mut arguments: impl Iterator) -> Result { + let mut timezone = None; + let mut refresh = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--timezone" => timezone = Some(required_value(&mut arguments, "--timezone")?), + "--refresh" => refresh = true, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown usage argument `{argument}`")), + } + } + Ok(CliCommand::Usage(UsageArguments { + timezone, + refresh, + output, + })) +} + +fn parse_ops(mut arguments: impl Iterator) -> Result { + let mut window_days = 30_u32; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--window-days" => { + window_days = parse_number(&mut arguments, "--window-days")?; + if ![7, 30, 90].contains(&window_days) { + return Err("--window-days must be 7, 30, or 90".into()); + } + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown ops argument `{argument}`")), + } + } + Ok(CliCommand::Ops(OpsArguments { + window_days, + output, + })) +} + +fn parse_unpack(mut arguments: impl Iterator) -> Result { + let mut operation = None; + let mut repo_path = None; + let mut report_id = None; + let mut base_commit = None; + let mut head_commit = None; + let mut format = None; + let mut query_domain = None; + let mut query_mode = RepositoryQueryMode::Search; + let mut query_mode_set = false; + let mut query = None; + let mut query_target = None; + let mut query_direction = None; + let mut query_depth = None; + let mut history_selector = None; + let mut limit = 50_i64; + let mut limit_set = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--operation" => { + operation = Some( + match required_value(&mut arguments, "--operation")?.as_str() { + "list" => UnpackOperation::List, + "inspect" => UnpackOperation::Inspect, + "scan" => UnpackOperation::Scan, + "compare" => UnpackOperation::Compare, + "export" => UnpackOperation::Export, + "query" => UnpackOperation::Query, + "query-worker" => UnpackOperation::QueryWorker, + other => return Err(format!("unsupported unpack operation `{other}`")), + }, + ) + } + "--repo" => repo_path = Some(required_value(&mut arguments, "--repo")?), + "--report-id" => report_id = Some(required_value(&mut arguments, "--report-id")?), + "--base-commit" => base_commit = Some(required_value(&mut arguments, "--base-commit")?), + "--head-commit" => head_commit = Some(required_value(&mut arguments, "--head-commit")?), + "--format" => format = Some(required_value(&mut arguments, "--format")?), + "--query-domain" => { + query_domain = Some( + match required_value(&mut arguments, "--query-domain")?.as_str() { + "graph" => RepositoryQueryDomain::Graph, + "history" => RepositoryQueryDomain::History, + other => return Err(format!("unsupported query domain '{other}'")), + }, + ) + } + "--query-mode" => { + query_mode = match required_value(&mut arguments, "--query-mode")?.as_str() { + "search" => RepositoryQueryMode::Search, + "explain" => RepositoryQueryMode::Explain, + "impact" => RepositoryQueryMode::Impact, + "path" => RepositoryQueryMode::Path, + "trace" => RepositoryQueryMode::Trace, + other => return Err(format!("unsupported query mode '{other}'")), + }; + query_mode_set = true; + } + "--query" => query = Some(required_value(&mut arguments, "--query")?), + "--query-target" => { + query_target = Some(required_value(&mut arguments, "--query-target")?) + } + "--query-direction" => { + query_direction = Some( + match required_value(&mut arguments, "--query-direction")?.as_str() { + "incoming" => GraphDirection::Incoming, + "outgoing" => GraphDirection::Outgoing, + "both" => GraphDirection::Both, + other => return Err(format!("unsupported query direction '{other}'")), + }, + ) + } + "--query-depth" => query_depth = Some(parse_number(&mut arguments, "--query-depth")?), + "--history-selector" => { + history_selector = Some( + match required_value(&mut arguments, "--history-selector")?.as_str() { + "event" => RepositoryHistorySelectorKind::Event, + "entity" => RepositoryHistorySelectorKind::Entity, + "revision" => RepositoryHistorySelectorKind::Revision, + "release" => RepositoryHistorySelectorKind::Release, + "episode" => RepositoryHistorySelectorKind::Episode, + other => return Err(format!("unsupported history selector '{other}'")), + }, + ) + } + "--limit" => { + limit = parse_number(&mut arguments, "--limit")?; + limit_set = true; + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown unpack argument `{argument}`")), + } + } + if !(1..=100).contains(&limit) { + return Err("--limit must be between 1 and 100".into()); + } + let operation = operation.unwrap_or_else(|| { + if report_id.is_some() { + UnpackOperation::Inspect + } else { + UnpackOperation::List + } + }); + match operation { + UnpackOperation::List => { + if report_id.is_some() + || base_commit.is_some() + || head_commit.is_some() + || format.is_some() + || query_domain.is_some() + || query_mode_set + || query.is_some() + || query_target.is_some() + || query_direction.is_some() + || query_depth.is_some() + || history_selector.is_some() + { + return Err("unpack list accepts only --repo and --limit".into()); + } + } + UnpackOperation::Inspect => { + if report_id.is_none() { + return Err("--report-id is required for unpack inspect".into()); + } + if repo_path.is_some() + || limit_set + || base_commit.is_some() + || head_commit.is_some() + || format.is_some() + || query_domain.is_some() + || query_mode_set + || query.is_some() + || query_target.is_some() + || query_direction.is_some() + || query_depth.is_some() + || history_selector.is_some() + { + return Err("unpack inspect accepts --report-id but not --repo or --limit".into()); + } + } + UnpackOperation::Scan => { + if repo_path.is_none() { + return Err("--repo is required for unpack scan".into()); + } + if report_id.is_some() + || limit_set + || base_commit.is_some() + || head_commit.is_some() + || format.is_some() + || query_domain.is_some() + || query_mode_set + || query.is_some() + || query_target.is_some() + || query_direction.is_some() + || query_depth.is_some() + || history_selector.is_some() + { + return Err("unpack scan accepts --repo but not --report-id or --limit".into()); + } + } + UnpackOperation::Compare => { + if repo_path.is_none() || base_commit.is_none() || head_commit.is_none() { + return Err( + "unpack compare requires --repo, --base-commit, and --head-commit".into(), + ); + } + if report_id.is_some() + || limit_set + || format.is_some() + || query_domain.is_some() + || query_mode_set + || query.is_some() + || query_target.is_some() + || query_direction.is_some() + || query_depth.is_some() + || history_selector.is_some() + { + return Err( + "unpack compare does not accept --report-id, --limit, or --format".into(), + ); + } + } + UnpackOperation::Export => { + if report_id.is_none() || format.is_none() { + return Err("unpack export requires --report-id and --format".into()); + } + if repo_path.is_some() + || limit_set + || base_commit.is_some() + || head_commit.is_some() + || query_domain.is_some() + || query_mode_set + || query.is_some() + || query_target.is_some() + || query_direction.is_some() + || query_depth.is_some() + || history_selector.is_some() + { + return Err( + "unpack export does not accept --repo, --limit, or commit arguments".into(), + ); + } + if !matches!( + format.as_deref(), + Some( + "markdown" + | "html" + | "repo_graph_json" + | "agent_context_markdown" + | "repo_memory_markdown" + ) + ) { + return Err("unsupported unpack export format".into()); + } + } + UnpackOperation::Query => { + if repo_path.is_none() || query_domain.is_none() || query.is_none() { + return Err("unpack query requires --repo, --query-domain, and --query".into()); + } + if report_id.is_some() + || base_commit.is_some() + || head_commit.is_some() + || format.is_some() + { + return Err( + "unpack query does not accept snapshot, commit, or export arguments".into(), + ); + } + let domain = query_domain.expect("query domain checked"); + let valid_mode = match (domain, query_mode) { + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Search) + | (RepositoryQueryDomain::Graph, RepositoryQueryMode::Explain) => { + query_target.is_none() + && query_direction.is_none() + && query_depth.is_none() + && history_selector.is_none() + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Impact) => { + query_target.is_none() && history_selector.is_none() + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Path) => { + query_target.is_some() + && query_direction.is_none() + && query_depth.is_none() + && history_selector.is_none() + } + (RepositoryQueryDomain::History, RepositoryQueryMode::Search) => { + query_target.is_none() + && query_direction.is_none() + && query_depth.is_none() + && history_selector.is_none() + } + (RepositoryQueryDomain::History, RepositoryQueryMode::Trace) => { + query_target.is_none() + && query_direction.is_none() + && query_depth.is_none() + && history_selector.is_some() + } + _ => false, + }; + if !valid_mode { + return Err("unpack query fields do not match the selected domain and mode".into()); + } + if query_depth.is_some_and(|depth| !(1..=12).contains(&depth)) { + return Err("--query-depth must be between 1 and 12".into()); + } + } + UnpackOperation::QueryWorker => { + if repo_path.is_some() + || report_id.is_some() + || limit_set + || base_commit.is_some() + || head_commit.is_some() + || format.is_some() + || query_domain.is_some() + || query_mode_set + || query.is_some() + || query_target.is_some() + || query_direction.is_some() + || query_depth.is_some() + || history_selector.is_some() + || output != OutputMode::Json + { + return Err( + "unpack query-worker accepts only --operation query-worker --json".into(), + ); + } + } + } + Ok(CliCommand::Unpack(UnpackArguments { + operation, + repo_path, + report_id, + base_commit, + head_commit, + format, + query_domain, + query_mode, + query, + query_target, + query_direction, + query_depth, + history_selector, + limit, + output, + })) +} + +fn parse_qa(mut arguments: impl Iterator, cwd: &Path) -> Result { + let mut operation = QaOperation::Inspect; + let mut repo_path = cwd.to_path_buf(); + let mut workflow_id = None; + let mut workflow_name = None; + let mut base_url = None; + let mut loop_id = None; + let mut runner_type = None; + let mut goal = None; + let mut repo_spec_path = None; + let mut repo_trace_mode = None; + let mut target_route = None; + let mut allow_remote_target = false; + let mut target_id = None; + let mut target_name = None; + let mut fix_completed_at = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--operation" => { + operation = match required_value(&mut arguments, "--operation")?.as_str() { + "inspect" => QaOperation::Inspect, + "save-workflow" => QaOperation::SaveWorkflow, + "delete-workflow" => QaOperation::DeleteWorkflow, + "save-target" => QaOperation::SaveTarget, + "delete-target" => QaOperation::DeleteTarget, + other => return Err(format!("unsupported qa operation `{other}`")), + } + } + "--repo" => repo_path = PathBuf::from(required_value(&mut arguments, "--repo")?), + "--workflow-id" => workflow_id = Some(required_value(&mut arguments, "--workflow-id")?), + "--workflow-name" => { + workflow_name = Some(required_value(&mut arguments, "--workflow-name")?) + } + "--base-url" => base_url = Some(required_value(&mut arguments, "--base-url")?), + "--loop-id" => loop_id = Some(required_value(&mut arguments, "--loop-id")?), + "--runner" => runner_type = Some(required_value(&mut arguments, "--runner")?), + "--goal" => goal = Some(required_value(&mut arguments, "--goal")?), + "--repo-spec" => repo_spec_path = Some(required_value(&mut arguments, "--repo-spec")?), + "--trace" => repo_trace_mode = Some(required_value(&mut arguments, "--trace")?), + "--target-route" => { + target_route = Some(required_value(&mut arguments, "--target-route")?) + } + "--allow-remote-target" => allow_remote_target = true, + "--target-id" => target_id = Some(required_value(&mut arguments, "--target-id")?), + "--target-name" => target_name = Some(required_value(&mut arguments, "--target-name")?), + "--fix-completed-at" => { + fix_completed_at = Some(required_value(&mut arguments, "--fix-completed-at")?) + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown qa argument `{argument}`")), + } + } + match operation { + QaOperation::Inspect => {} + QaOperation::SaveWorkflow => { + if workflow_id.is_none() + || workflow_name.is_none() + || loop_id.is_none() + || runner_type.is_none() + || goal.is_none() + || target_route.is_none() + { + return Err("qa save-workflow requires --workflow-id, --workflow-name, --loop-id, --runner, --goal, and --target-route".into()); + } + } + QaOperation::DeleteWorkflow => { + if workflow_id.is_none() { + return Err("qa delete-workflow requires --workflow-id".into()); + } + } + QaOperation::SaveTarget => { + if workflow_id.is_none() + || target_id.is_none() + || target_name.is_none() + || target_route.is_none() + || goal.is_none() + { + return Err("qa save-target requires --workflow-id, --target-id, --target-name, --target-route, and --goal".into()); + } + } + QaOperation::DeleteTarget => { + if workflow_id.is_none() || target_id.is_none() { + return Err("qa delete-target requires --workflow-id and --target-id".into()); + } + } + } + Ok(CliCommand::Qa(QaArguments { + operation, + repo_path, + workflow_id, + workflow_name, + base_url, + loop_id, + runner_type, + goal, + repo_spec_path, + repo_trace_mode, + target_route, + allow_remote_target, + target_id, + target_name, + fix_completed_at, + output, + })) +} + +fn parse_settings(mut arguments: impl Iterator) -> Result { + let mut set = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--set" => { + if set.is_some() { + return Err("settings accepts at most one --set operation".into()); + } + let assignment = required_value(&mut arguments, "--set")?; + let (key, value) = assignment + .split_once('=') + .ok_or_else(|| "--set requires =".to_string())?; + if key.is_empty() { + return Err("--set requires a non-empty key".into()); + } + set = Some((key.to_string(), value.to_string())); + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown settings argument `{argument}`")), + } + } + Ok(CliCommand::Settings(SettingsArguments { set, output })) +} + +fn parse_history_roots(mut arguments: impl Iterator) -> Result { + let mut operation = HistoryRootsOperation::Read; + let mut path = None; + let mut mutation_supplied = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--add" | "--remove" => { + if mutation_supplied { + return Err("history-roots accepts only one add or remove operation".into()); + } + mutation_supplied = true; + operation = if argument == "--add" { + HistoryRootsOperation::Add + } else { + HistoryRootsOperation::Remove + }; + path = Some(PathBuf::from(required_value(&mut arguments, &argument)?)); + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown history-roots argument `{argument}`")), + } + } + Ok(CliCommand::HistoryRoots(HistoryRootsArguments { + operation, + path, + output, + })) +} + +fn parse_memories(mut arguments: impl Iterator) -> Result { + let mut source_id = None; + let mut diff = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--source" => { + if source_id.is_some() { + return Err("memories accepts at most one --source".into()); + } + source_id = Some(required_value(&mut arguments, "--source")?); + } + "--diff" => { + if diff { + return Err("memories accepts --diff only once".into()); + } + diff = true; + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown memories argument `{argument}`")), + } + } + if diff && source_id.is_none() { + return Err("memories --diff requires --source ".into()); + } + Ok(CliCommand::Memories(MemoriesArguments { + source_id, + diff, + output, + })) +} + +fn parse_onboarding(mut arguments: impl Iterator) -> Result { + let mut complete = false; + let mut default_adapter = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--complete" => { + if complete { + return Err("onboarding accepts --complete only once".into()); + } + complete = true; + } + "--default-adapter" => { + if default_adapter.is_some() { + return Err("onboarding accepts at most one --default-adapter".into()); + } + default_adapter = Some(required_value(&mut arguments, "--default-adapter")?); + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown onboarding argument `{argument}`")), + } + } + if complete != default_adapter.is_some() { + return Err("--complete and --default-adapter must be provided together".into()); + } + if let Some(adapter) = default_adapter.as_deref() { + if !matches!(adapter, "codex" | "claude-code") { + return Err("--default-adapter must be codex or claude-code".into()); + } + } + Ok(CliCommand::Onboarding(OnboardingArguments { + complete, + default_adapter, + output, + })) +} + +fn parse_mcp(mut arguments: impl Iterator) -> Result { + let mut repo_path = None; + let mut operation = McpSettingsOperation::Read; + let mut operation_set = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--enable" | "--disable" | "--clear-audit" => { + if operation_set { + return Err( + "choose at most one of --enable, --disable, or --clear-audit".into(), + ); + } + operation_set = true; + operation = match argument.as_str() { + "--enable" => McpSettingsOperation::Enable, + "--disable" => McpSettingsOperation::Disable, + _ => McpSettingsOperation::ClearAudit, + }; + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown mcp argument `{argument}`")), + } + } + Ok(CliCommand::Mcp(McpArguments { + repo_path: repo_path.ok_or_else(|| "--repo is required".to_string())?, + operation, + output, + })) +} + +fn parse_performance( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut operation = None; + let mut repo_path = None; + let mut adapter = None; + let mut target = None; + let mut name = None; + let mut request_id = None; + let mut subject_run_id = None; + let mut baseline_repo_path = None; + let mut samples = None; + let mut warmups = None; + let mut timeout_ms = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--operation" => { + operation = Some(parse_performance_operation(&required_value( + &mut arguments, + "--operation", + )?)?) + } + "--repo" => repo_path = Some(required_value(&mut arguments, "--repo")?), + "--adapter" => { + adapter = Some(parse_performance_adapter(&required_value( + &mut arguments, + "--adapter", + )?)?) + } + "--target" => target = Some(required_value(&mut arguments, "--target")?), + "--name" => name = Some(required_value(&mut arguments, "--name")?), + "--request-id" => request_id = Some(required_value(&mut arguments, "--request-id")?), + "--subject-run-id" => { + subject_run_id = Some(required_value(&mut arguments, "--subject-run-id")?) + } + "--baseline-repo" => { + baseline_repo_path = Some(required_value(&mut arguments, "--baseline-repo")?) + } + "--samples" => samples = Some(parse_number(&mut arguments, "--samples")?), + "--warmups" => warmups = Some(parse_number(&mut arguments, "--warmups")?), + "--timeout-ms" => timeout_ms = Some(parse_number(&mut arguments, "--timeout-ms")?), + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown performance argument `{argument}`")), + } + } + Ok(CliCommand::Performance(PerformanceArguments { + input: PerformanceRunInput { + request_id: request_id + .unwrap_or_else(|| format!("performance-{}", uuid::Uuid::new_v4())), + operation: operation.ok_or_else(|| "--operation is required".to_string())?, + repo_path: repo_path.unwrap_or_else(|| cwd.to_string_lossy().into_owned()), + adapter, + target, + name, + samples, + warmups, + timeout_ms, + subject_run_id, + baseline_repo_path, + }, + output, + })) +} + +fn parse_scope( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut consumer = None; + let mut scope = None; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(required_value(&mut arguments, "--repo")?), + "--consumer" => { + let value = required_value(&mut arguments, "--consumer")?; + consumer = Some(match value.as_str() { + "testing" => EvidenceScopeConsumer::Testing, + "performance" => EvidenceScopeConsumer::Performance, + _ => return Err("--consumer must be testing or performance".into()), + }); + } + "--flow" | "--change" | "--codebase" => { + if scope.is_some() { + return Err("choose exactly one of --flow, --change, or --codebase".into()); + } + scope = Some(match argument.as_str() { + "--flow" => ( + EvidenceScopeKind::Flow, + Some(required_value(&mut arguments, "--flow")?), + ), + "--change" => ( + EvidenceScopeKind::Change, + Some(required_value(&mut arguments, "--change")?), + ), + _ => (EvidenceScopeKind::Codebase, None), + }); + } + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown scope argument `{argument}`")), + } + } + let (kind, value) = + scope.ok_or_else(|| "choose exactly one of --flow, --change, or --codebase".to_string())?; + Ok(CliCommand::Scope(ScopeArguments { + input: EvidenceScopeInput { + repo_path: repo_path.unwrap_or_else(|| cwd.to_string_lossy().into_owned()), + kind, + value, + consumer: consumer.ok_or_else(|| "--consumer is required".to_string())?, + }, + output, + })) +} + +fn parse_performance_operation(value: &str) -> Result { + match value { + "plan" => Ok(PerformanceOperation::Plan), + "diagnose" => Ok(PerformanceOperation::Diagnose), + "verify-paired" => Ok(PerformanceOperation::VerifyPaired), + "inspect" => Ok(PerformanceOperation::Inspect), + _ => Err("--operation must be plan, diagnose, verify-paired, or inspect".into()), + } +} + +fn parse_performance_adapter(value: &str) -> Result { + match value { + "vitest" => Ok(PerformanceAdapter::Vitest), + "node-test" => Ok(PerformanceAdapter::NodeTest), + "node-script" => Ok(PerformanceAdapter::NodeScript), + "playwright" => Ok(PerformanceAdapter::Playwright), + "go-bench" => Ok(PerformanceAdapter::GoBench), + _ => { + Err("--adapter must be vitest, node-test, node-script, playwright, or go-bench".into()) + } + } +} + +fn parse_runs(mut arguments: impl Iterator) -> Result { + let mut repo_path = None; + let mut limit = 20usize; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--limit" => limit = parse_number(&mut arguments, "--limit")?, + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown runs argument `{argument}`")), + } + } + if !(1..=100).contains(&limit) { + return Err("--limit must be between 1 and 100".into()); + } + Ok(CliCommand::Runs(RunsArguments { + repo_path, + limit, + output, + })) +} + +fn parse_collect( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut range = None; + let mut collectors = Vec::new(); + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--range" => range = Some(required_value(&mut arguments, "--range")?), + "--collector" => collectors.push(CollectorKind::parse(&required_value( + &mut arguments, + "--collector", + )?)?), + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown collect argument `{argument}`")), + } + } + if collectors.is_empty() { + return Err("at least one --collector is required".into()); + } + Ok(CliCommand::Collect(CollectArguments { + repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), + change: range.ok_or_else(|| "--range is required".to_string())?, + collectors, + output, + })) +} + +fn parse_check( + mut arguments: impl Iterator, + cwd: &Path, +) -> Result { + let mut repo_path = None; + let mut pull_request = None; + let mut range = None; + let mut task = None; + let mut spec_paths = Vec::new(); + let mut selected_requirement_ids = Vec::new(); + let mut review_agent = "claude".to_string(); + let mut test_adapter = None; + let mut test_target = None; + let mut test_name = None; + let mut performance_adapter = None; + let mut performance_target = None; + let mut performance_name = None; + let mut baseline_repo_path = None; + let mut samples = 3; + let mut warmups = 1; + let mut timeout_ms = 30_000; + let mut request_id = None; + let mut preflight = false; + let mut progress_json = false; + let mut output = OutputMode::Human; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--repo" => repo_path = Some(PathBuf::from(required_value(&mut arguments, "--repo")?)), + "--pr" => pull_request = Some(required_value(&mut arguments, "--pr")?), + "--range" => range = Some(required_value(&mut arguments, "--range")?), + "--task" => task = Some(required_value(&mut arguments, "--task")?), + "--preflight" => preflight = true, + "--progress-json" => progress_json = true, + "--spec" => spec_paths.push(PathBuf::from(required_value(&mut arguments, "--spec")?)), + "--requirement" => { + selected_requirement_ids.push(required_value(&mut arguments, "--requirement")?) + } + "--agent" => review_agent = required_value(&mut arguments, "--agent")?, + "--test-adapter" => { + test_adapter = Some(required_value(&mut arguments, "--test-adapter")?) + } + "--test-target" => test_target = Some(required_value(&mut arguments, "--test-target")?), + "--test-name" => test_name = Some(required_value(&mut arguments, "--test-name")?), + "--perf-adapter" => { + performance_adapter = Some(required_value(&mut arguments, "--perf-adapter")?) + } + "--perf-target" => { + performance_target = Some(required_value(&mut arguments, "--perf-target")?) + } + "--perf-name" => { + performance_name = Some(required_value(&mut arguments, "--perf-name")?) + } + "--baseline-repo" => { + baseline_repo_path = Some(PathBuf::from(required_value( + &mut arguments, + "--baseline-repo", + )?)) + } + "--samples" => samples = parse_number(&mut arguments, "--samples")?, + "--warmups" => warmups = parse_number(&mut arguments, "--warmups")?, + "--timeout-ms" => timeout_ms = parse_number(&mut arguments, "--timeout-ms")?, + "--request-id" => request_id = Some(required_value(&mut arguments, "--request-id")?), + "--json" => output = OutputMode::Json, + "--help" | "-h" => return Ok(CliCommand::Help), + _ => return Err(format!("unknown check argument `{argument}`")), + } + } + let change = match (pull_request, range) { + (Some(value), None) | (None, Some(value)) => value, + (Some(_), Some(_)) => return Err("choose exactly one of --pr or --range".into()), + (None, None) => return Err("one of --pr or --range is required".into()), + }; + let test_target = paired_target("test", test_adapter, test_target, test_name)?; + let performance_target = paired_target( + "performance", + performance_adapter, + performance_target, + performance_name, + )?; + if spec_paths.is_empty() && !selected_requirement_ids.is_empty() { + return Err("--requirement requires at least one --spec".into()); + } + if progress_json && output != OutputMode::Json { + return Err("--progress-json requires --json".into()); + } + if progress_json && preflight { + return Err("--progress-json is only available for executable checks".into()); + } + Ok(CliCommand::Check(Box::new(CheckArguments { + repo_path: repo_path.unwrap_or_else(|| cwd.to_path_buf()), + change, + task: task.ok_or_else(|| "--task is required".to_string())?, + spec_paths, + selected_requirement_ids, + review_agent, + test_target, + performance_target, + baseline_repo_path, + samples, + warmups, + timeout_ms, + request_id, + preflight, + progress_json, + output, + }))) +} + +fn paired_target( + label: &str, + adapter: Option, + target: Option, + name: Option, +) -> Result, String> { + match (adapter, target) { + (Some(adapter), Some(target)) => Ok(Some(LocalCheckTarget { + adapter, + target, + name, + source: "explicit".into(), + })), + (None, None) if name.is_none() => Ok(None), + _ => Err(format!( + "--{label}-adapter and --{label}-target must be provided together" + )), + } +} + +fn parse_number( + arguments: &mut impl Iterator, + flag: &str, +) -> Result { + required_value(arguments, flag)? + .parse() + .map_err(|_| format!("{flag} requires a number")) +} + +fn required_value( + arguments: &mut impl Iterator, + flag: &str, +) -> Result { + let value = arguments + .next() + .ok_or_else(|| format!("{flag} requires a value"))?; + if value.trim().is_empty() || value.starts_with("--") { + return Err(format!("{flag} requires a value")); + } + Ok(value) +} + +fn default_app_data_dir() -> Result { + if let Some(override_dir) = std::env::var_os("CODEVETTER_APP_DATA_DIR") { + return Ok(PathBuf::from(override_dir)); + } + + #[cfg(target_os = "macos")] + { + let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; + Ok(PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("com.codevetter.desktop")) + } + #[cfg(target_os = "windows")] + { + let app_data = + std::env::var_os("APPDATA").ok_or_else(|| "APPDATA is unavailable".to_string())?; + Ok(PathBuf::from(app_data).join("com.codevetter.desktop")) + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home).join("com.codevetter.desktop")); + } + let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; + Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("com.codevetter.desktop")) + } +} + +fn open_read_only_app_database() -> Result, String> { + let database_path = default_app_data_dir()?.join("codevetter.db"); + if !database_path.is_file() { + return Ok(None); + } + rusqlite::Connection::open_with_flags( + &database_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map(Some) + .map_err(|error| { + format!( + "open CodeVetter database {} read-only: {error}", + database_path.display() + ) + }) +} + +fn verdict_exit_code(verdict: TrexPreviewVerdict) -> i32 { + match verdict { + TrexPreviewVerdict::PassedWithLimits => 0, + TrexPreviewVerdict::Failed => 1, + TrexPreviewVerdict::NoConfidence => 2, + } +} + +fn local_check_exit_code(verdict: LocalCheckVerdict) -> i32 { + match verdict { + LocalCheckVerdict::PassedWithLimits => 0, + LocalCheckVerdict::NeedsAttention | LocalCheckVerdict::Failed => 1, + LocalCheckVerdict::NoConfidence => 2, + } +} + +fn preflight_exit_code(status: LocalCheckStatus) -> i32 { + if status == LocalCheckStatus::Ready { + 0 + } else { + 2 + } +} + +fn collection_exit_code(receipt: &ToolCollectionReceipt) -> i32 { + if receipt.collectors.iter().any(|collector| { + matches!( + collector.status, + CollectorStatus::Unavailable | CollectorStatus::Error + ) + }) { + 2 + } else if receipt + .collectors + .iter() + .any(|collector| collector.status == CollectorStatus::Findings) + { + 1 + } else { + 0 + } +} + +fn render_human_collection(receipt: &ToolCollectionReceipt) -> String { + let mut output = format!("head: {}\n", receipt.source.head_sha); + for collector in &receipt.collectors { + let name = serde_json::to_value(collector.collector) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()); + let status = serde_json::to_value(collector.status) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()); + output.push_str(&format!( + "{name}: {status} ({} finding(s), {} ms)\n", + collector.finding_count, collector.duration_ms + )); + } + if !receipt.limitations.is_empty() { + output.push_str("limitations:\n"); + for limitation in &receipt.limitations { + output.push_str(&format!("- {limitation}\n")); + } + } + output +} + +fn render_human_preflight(receipt: &LocalCheckPreflightReceipt) -> String { + let target = |value: Option<&LocalCheckTarget>| { + value + .map(|target| format!("{} {}", target.adapter, target.target)) + .unwrap_or_else(|| "unavailable".into()) + }; + let mut output = format!( + "preflight: {}\nhead: {}\ncorrectness target: {}\nperformance target: {}\n", + local_status_text(receipt.status), + receipt.source.head_sha, + target(receipt.correctness_target.as_ref()), + target(receipt.performance_target.as_ref()), + ); + if let Some(spec) = receipt.spec_coverage.as_ref() { + output.push_str(&format!( + "specs: {} source(s), {} requirement(s), {} selected\n", + spec.sources.len(), + spec.summary.total_requirements, + spec.summary.selected_for_execution, + )); + } + if !receipt.limitations.is_empty() { + output.push_str("limitations:\n"); + for limitation in &receipt.limitations { + output.push_str(&format!("- {limitation}\n")); + } + } + if receipt.status == LocalCheckStatus::Ready { + output.push_str("next: rerun this command without --preflight to execute verification\n"); + } + output +} + +fn render_human_check(receipt: &LocalCheckReceipt) -> String { + let verdict = serde_json::to_value(receipt.verdict) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()); + let mut output = format!( + "verdict: {verdict}\nhead: {}\nreview: {}\ncorrectness: {}\nperformance: {}\noptimization: {}\n", + receipt.source.head_sha, + local_status_text(receipt.stages.review.status), + local_status_text(receipt.stages.correctness.status), + local_status_text(receipt.stages.performance.status), + local_status_text(receipt.stages.optimization.status), + ); + render_review_findings(&mut output, &receipt.stages.review.evidence); + if let Some(spec) = receipt.spec_coverage.as_ref() { + let percent = |value: Option| { + value + .map(|number| format!("{number}%")) + .unwrap_or_else(|| "n/a".into()) + }; + output.push_str(&format!( + "specs: {} source(s), {} requirement(s)\nspec review input: {}/{} ({})\nspec executable evidence: {}/{} ({})\nspec verified: {}/{} ({})\n", + spec.sources.len(), + spec.summary.total_requirements, + spec.summary.review_input_requirements, + spec.summary.total_requirements, + percent(spec.summary.review_input_coverage_percent), + spec.summary.verified + spec.summary.contradicted, + spec.summary.total_requirements, + percent(spec.summary.executable_evidence_coverage_percent), + spec.summary.verified, + spec.summary.total_requirements, + percent(spec.summary.verified_coverage_percent), + )); + if !spec.limitations.is_empty() { + output.push_str("spec limitations:\n"); + for limitation in spec.limitations.iter().take(8) { + output.push_str(&format!("- {limitation}\n")); + } + } + } + if let Some(command) = receipt + .stages + .optimization + .evidence + .get("candidate_command") + .and_then(serde_json::Value::as_str) + { + output.push_str(&format!("next: {command}\n")); + } + if !receipt.limitations.is_empty() { + output.push_str("limitations:\n"); + for limitation in &receipt.limitations { + output.push_str(&format!("- {limitation}\n")); + } + } + output +} + +fn local_status_text(status: LocalCheckStatus) -> String { + serde_json::to_value(status) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()) +} + +fn render_review_findings(output: &mut String, evidence: &serde_json::Value) { + let Some(findings) = evidence + .get("findings") + .and_then(serde_json::Value::as_array) + else { + return; + }; + let mut findings = findings.iter().collect::>(); + findings.sort_by_key(|finding| { + match finding.get("severity").and_then(serde_json::Value::as_str) { + Some("critical") => 0, + Some("high") => 1, + Some("medium") => 2, + Some("low") => 3, + _ => 4, + } + }); + if findings.is_empty() { + return; + } + output.push_str("review findings:\n"); + for finding in findings.into_iter().take(3) { + let severity = finding + .get("severity") + .and_then(serde_json::Value::as_str) + .map(|value| terminal_text(value, 16)) + .unwrap_or_else(|| "unknown".into()); + let title = finding + .get("title") + .and_then(serde_json::Value::as_str) + .map(|value| terminal_text(value, 180)) + .unwrap_or_else(|| "Untitled finding".into()); + let location = finding + .get("filePath") + .and_then(serde_json::Value::as_str) + .map(|path| { + let path = terminal_text(path, 180); + finding + .get("line") + .and_then(serde_json::Value::as_u64) + .map(|line| format!(" ({path}:{line})")) + .unwrap_or_else(|| format!(" ({path})")) + }) + .unwrap_or_default(); + output.push_str(&format!("- {severity}: {title}{location}\n")); + } +} + +fn terminal_text(value: &str, max_chars: usize) -> String { + value + .chars() + .filter_map(|character| match character { + '\n' | '\r' | '\t' => Some(' '), + value if value.is_control() => None, + value => Some(value), + }) + .take(max_chars) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn render_human_receipt(receipt: &TrexPreviewReceipt) -> String { + let verdict = match receipt.verdict { + TrexPreviewVerdict::PassedWithLimits => "passed_with_limits", + TrexPreviewVerdict::Failed => "failed", + TrexPreviewVerdict::NoConfidence => "no_confidence", + }; + let preview = serde_json::to_value(receipt.preview.status) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()); + let passed = receipt + .journeys + .iter() + .filter(|journey| journey.pass) + .count(); + let mut output = format!( + "verdict: {verdict}\nhead: {}\npreview: {preview}\njourneys: {passed}/{} passed\nsummary: {}\n", + receipt.source.head_sha, + receipt.routes.len(), + receipt.summary + ); + if !receipt.limitations.is_empty() { + output.push_str("limitations:\n"); + for limitation in &receipt.limitations { + output.push_str(&format!("- {limitation}\n")); + } + } + for journey in receipt.journeys.iter().filter(|journey| !journey.pass) { + output.push_str(&format!("failure {}: {}\n", journey.route, journey.notes)); + if let Some(path) = &journey.screenshot_path { + output.push_str(&format!("artifact: {path}\n")); + } + } + output +} + +fn render_human_scope(receipt: &EvidenceScopePlan) -> String { + let consumer = match receipt.consumer { + EvidenceScopeConsumer::Testing => "testing", + EvidenceScopeConsumer::Performance => "performance", + }; + let mut output = format!( + "Evidence scope · {consumer}\nstatus: {}\nrevision: {}{}\nplan: {}\ncandidates: {} · uncovered paths: {}\n", + receipt.status, + receipt.repository_revision, + if receipt.dirty { " · dirty" } else { " · clean" }, + receipt.plan_id, + receipt.candidates.len(), + receipt.uncovered_paths.len(), + ); + for candidate in &receipt.candidates { + let name = candidate + .name + .as_deref() + .map(|value| format!(" · {value}")) + .unwrap_or_default(); + output.push_str(&format!( + "- {} · {}{} · {:.1}% · {}\n", + candidate.adapter, + candidate.target, + name, + f64::from(candidate.confidence_milli) / 10.0, + candidate.reason, + )); + } + if !receipt.limitations.is_empty() { + output.push_str("limitations:\n"); + for limitation in &receipt.limitations { + output.push_str(&format!("- {limitation}\n")); + } + } + output +} + +fn render_human_performance(receipt: &PerformanceRunReceipt) -> String { + let operation = match receipt.operation { + PerformanceOperation::Test => "test", + PerformanceOperation::Plan => "plan", + PerformanceOperation::Diagnose => "diagnose", + PerformanceOperation::Inspect => "inspect", + PerformanceOperation::VerifyPaired => "verify-paired", + }; + let verdict = receipt + .result + .pointer("/verdict/status") + .or_else(|| receipt.result.pointer("/decision/status")) + .and_then(serde_json::Value::as_str) + .unwrap_or(&receipt.state); + let mut output = format!( + "operation: {operation}\nstate: {}\nverdict: {verdict}\nduration: {} ms\nrequest: {}\n", + receipt.state, receipt.duration_ms, receipt.request_id + ); + if let Some(limitations) = receipt + .result + .get("limitations") + .and_then(|value| value.as_array()) + { + let limitations = limitations + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + if !limitations.is_empty() { + output.push_str("limitations:\n"); + for limitation in limitations { + output.push_str(&format!("- {limitation}\n")); + } + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use codevetter_desktop::commands::local_check::{LocalCheckStage, LocalCheckStages}; + use codevetter_desktop::commands::synthetic_qa::{SyntheticQaRunResult, SyntheticQaTrace}; + use codevetter_desktop::commands::trex_preview::{ + TrexPreviewIdentity, TrexPreviewIdentityStatus, TrexPreviewRoute, TrexSourceReceipt, + }; + + const SURFACE_PARITY_FIXTURE: &str = + include_str!("../../tests/fixtures/surface-parity/evidence-scope-v1.json"); + const LOCAL_CHECK_PARITY_FIXTURE: &str = + include_str!("../../tests/fixtures/surface-parity/local-check-v1.json"); + + fn surface_parity_fixture() -> serde_json::Value { + serde_json::from_str(SURFACE_PARITY_FIXTURE).expect("surface parity fixture") + } + + fn local_check_parity_fixture() -> serde_json::Value { + serde_json::from_str(LOCAL_CHECK_PARITY_FIXTURE).expect("local-check parity fixture") + } + + fn fixture_receipt(verdict: TrexPreviewVerdict) -> TrexPreviewReceipt { + TrexPreviewReceipt { + schema_version: 1, + run_id: "trex-preview-cli-fixture".into(), + repo_path: "/tmp/widget".into(), + source: TrexSourceReceipt { + kind: TrexChangeKind::Range, + input: "main..HEAD".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/pages/index.tsx".into()], + }, + preview: TrexPreviewIdentity { + status: TrexPreviewIdentityStatus::Claimed, + requested_url: "https://preview.example.com".into(), + final_url: "https://preview.example.com".into(), + revision: None, + evidence: "No supported revision header was returned.".into(), + }, + routes: vec![TrexPreviewRoute { + route: "/".into(), + reason: "Required root smoke".into(), + goal: None, + }], + journeys: vec![SyntheticQaRunResult { + loop_id: "generic-page-smoke".into(), + route: "/".into(), + goal: "smoke".into(), + pass: verdict != TrexPreviewVerdict::Failed, + notes: "fixture journey".into(), + screenshot_path: None, + artifacts: Vec::new(), + duration_ms: 12, + trace: SyntheticQaTrace { + final_url: "https://preview.example.com/".into(), + page_title: "Preview".into(), + console_errors: Vec::new(), + stage_timings_ms: Default::default(), + runner_rss_bytes: None, + }, + error: None, + runner_type: Some("chromiumoxide_builtin".into()), + }], + verdict, + summary: "Fixture summary.".into(), + limitations: vec!["Preview identity is claimed.".into()], + duration_ms: 42, + ran_at: "2026-07-29T00:00:00Z".into(), + } + } + + fn fixture_local_check(verdict: LocalCheckVerdict) -> LocalCheckReceipt { + let stage = |status| LocalCheckStage { + status, + duration_ms: 12, + target: None, + evidence: serde_json::json!({}), + limitations: Vec::new(), + }; + LocalCheckReceipt { + schema_version: "codevetter.local-check/v1".into(), + request_id: None, + run_id: "local-check-fixture".into(), + ran_at: "2026-08-24T00:00:00Z".into(), + repo_path: "/tmp/widget".into(), + task: "Preserve behavior".into(), + standards_pack: Some("product-safety".into()), + source: TrexSourceReceipt { + kind: TrexChangeKind::Range, + input: "main...HEAD".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/parser.ts".into()], + }, + stages: LocalCheckStages { + review: stage(LocalCheckStatus::Completed), + correctness: stage(LocalCheckStatus::Passed), + performance: stage(LocalCheckStatus::Completed), + optimization: LocalCheckStage { + status: LocalCheckStatus::Ready, + duration_ms: 0, + target: None, + evidence: serde_json::json!({"candidate_command": "codevetter check --repo "}), + limitations: vec!["Candidate edits remain external.".into()], + }, + }, + spec_coverage: None, + verdict, + limitations: vec!["Candidate edits remain external.".into()], + } + } + + #[test] + fn parser_defaults_to_current_repo_and_requires_one_source() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Trex(arguments) = parse_arguments( + [ + "trex".into(), + "--range".into(), + "main..HEAD".into(), + "--preview".into(), + "https://preview.example.com".into(), + "--route".into(), + "/checkout".into(), + "--journey-goal".into(), + "Complete checkout".into(), + ], + cwd, + ) + .expect("arguments") else { + panic!("expected trex"); + }; + assert_eq!(arguments.repo_path, cwd); + assert_eq!(arguments.change_kind, TrexChangeKind::Range); + assert_eq!(arguments.target_route.as_deref(), Some("/checkout")); + assert_eq!(arguments.target_goal.as_deref(), Some("Complete checkout")); + assert_eq!(arguments.output, OutputMode::Human); + + assert!(parse_arguments( + [ + "trex".into(), + "--pr".into(), + "https://github.com/acme/widget/pull/1".into(), + "--range".into(), + "main..HEAD".into(), + "--preview".into(), + "https://preview.example.com".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "trex".into(), + "--preview".into(), + "https://preview.example.com".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(compare) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "compare".into(), + "--repo".into(), + "/tmp/widget".into(), + "--base-commit".into(), + "1".repeat(40), + "--head-commit".into(), + "2".repeat(40), + "--json".into(), + ], + cwd, + ) + .expect("compare arguments") else { + panic!("expected unpack compare"); + }; + assert_eq!(compare.operation, UnpackOperation::Compare); + assert_eq!( + compare.base_commit.as_deref(), + Some("1111111111111111111111111111111111111111") + ); + assert_eq!( + compare.head_commit.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + let CliCommand::Unpack(export) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "export".into(), + "--report-id".into(), + "snapshot-1".into(), + "--format".into(), + "repo_memory_markdown".into(), + "--json".into(), + ], + cwd, + ) + .expect("export arguments") else { + panic!("expected unpack export"); + }; + assert_eq!(export.operation, UnpackOperation::Export); + assert_eq!(export.report_id.as_deref(), Some("snapshot-1")); + assert_eq!(export.format.as_deref(), Some("repo_memory_markdown")); + assert!(parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "export".into(), + "--report-id".into(), + "snapshot-1".into(), + "--format".into(), + "pdf".into(), + ], + cwd, + ) + .is_err()); + + assert!(parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "retry".into(), + "--pr-number".into(), + "42".into(), + ], + cwd, + ) + .is_err()); + let CliCommand::Watcher(retry) = parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "retry".into(), + "--pr-number".into(), + "42".into(), + "--confirm-run".into(), + ], + cwd, + ) + .expect("confirmed watcher retry") else { + panic!("expected watcher"); + }; + assert_eq!(retry.pr_number, Some(42)); + assert!(retry.confirm_run); + } + + #[test] + fn qa_parser_preserves_inspect_and_safe_workflow_fields() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Qa(inspect) = + parse_arguments(["qa".into(), "--json".into()], cwd).expect("qa inspect") + else { + panic!("expected qa"); + }; + assert_eq!(inspect.operation, QaOperation::Inspect); + assert_eq!(inspect.repo_path, cwd); + + let CliCommand::Qa(save) = parse_arguments( + [ + "qa".into(), + "--operation".into(), + "save-workflow".into(), + "--workflow-id".into(), + "checkout".into(), + "--workflow-name".into(), + "Checkout".into(), + "--loop-id".into(), + "checkout".into(), + "--runner".into(), + "repo_playwright".into(), + "--goal".into(), + "Complete checkout".into(), + "--target-route".into(), + "/checkout".into(), + ], + cwd, + ) + .expect("qa save") else { + panic!("expected qa"); + }; + assert_eq!(save.operation, QaOperation::SaveWorkflow); + assert_eq!(save.workflow_id.as_deref(), Some("checkout")); + assert!(parse_arguments( + [ + "qa".into(), + "--operation".into(), + "save-workflow".into(), + "--workflow-id".into(), + "incomplete".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(query) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query-domain".into(), + "graph".into(), + "--query".into(), + "verification service".into(), + "--limit".into(), + "24".into(), + "--json".into(), + ], + cwd, + ) + .expect("query arguments") else { + panic!("expected unpack query"); + }; + assert_eq!(query.operation, UnpackOperation::Query); + assert_eq!(query.query_domain, Some(RepositoryQueryDomain::Graph)); + assert_eq!(query.query_mode, RepositoryQueryMode::Search); + assert_eq!(query.query.as_deref(), Some("verification service")); + assert_eq!(query.limit, 24); + let CliCommand::Unpack(path_query) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query-domain".into(), + "graph".into(), + "--query-mode".into(), + "path".into(), + "--query".into(), + "node:start".into(), + "--query-target".into(), + "node:end".into(), + "--json".into(), + ], + cwd, + ) + .expect("path query arguments") else { + panic!("expected unpack path query"); + }; + assert_eq!(path_query.query_mode, RepositoryQueryMode::Path); + assert_eq!(path_query.query_target.as_deref(), Some("node:end")); + let CliCommand::Unpack(trace_query) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query-domain".into(), + "history".into(), + "--query-mode".into(), + "trace".into(), + "--history-selector".into(), + "event".into(), + "--query".into(), + "event:verification".into(), + "--json".into(), + ], + cwd, + ) + .expect("trace query arguments") else { + panic!("expected unpack trace query"); + }; + assert_eq!(trace_query.query_mode, RepositoryQueryMode::Trace); + assert_eq!( + trace_query.history_selector, + Some(RepositoryHistorySelectorKind::Event) + ); + assert!(parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query".into(), + "missing domain".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(worker) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query-worker".into(), + "--json".into(), + ], + cwd, + ) + .expect("query worker arguments") else { + panic!("expected unpack query worker"); + }; + assert_eq!(worker.operation, UnpackOperation::QueryWorker); + assert!(parse_arguments( + ["unpack".into(), "--operation".into(), "query-worker".into(),], + cwd, + ) + .is_err()); + } + + #[test] + fn warm_parser_preserves_lifecycle_run_and_cleanup_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Warm(run) = parse_arguments( + [ + "warm".into(), + "--operation".into(), + "run".into(), + "--run-id".into(), + "warm-native-1".into(), + "--detailed".into(), + "--json".into(), + ], + cwd, + ) + .expect("warm run") else { + panic!("expected warm command"); + }; + assert_eq!(run.repo_path, cwd); + assert_eq!(run.operation, WarmOperation::Run); + assert_eq!(run.run_id.as_deref(), Some("warm-native-1")); + assert!(run.detailed); + assert_eq!(run.output, OutputMode::Json); + + let CliCommand::Warm(cleanup) = parse_arguments( + [ + "warm".into(), + "--operation".into(), + "cleanup".into(), + "--dry-run".into(), + ], + cwd, + ) + .expect("warm cleanup preview") else { + panic!("expected warm cleanup"); + }; + assert!(cleanup.dry_run); + assert!( + parse_arguments(["warm".into(), "--operation".into(), "cleanup".into()], cwd,).is_err() + ); + assert!(parse_arguments( + [ + "warm".into(), + "--operation".into(), + "cleanup".into(), + "--dry-run".into(), + "--apply-cleanup".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn differential_parser_preserves_exact_pair_and_cleanup_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Differential(arguments) = parse_arguments( + [ + "differential".into(), + "--operation".into(), + "run".into(), + "--run-id".into(), + "diff-native-1".into(), + "--reference".into(), + "main".into(), + "--candidate".into(), + "range".into(), + "--revision".into(), + "main...HEAD".into(), + "--json".into(), + ], + cwd, + ) + .expect("differential run") else { + panic!("expected differential command"); + }; + assert_eq!(arguments.operation, DifferentialOperation::Run); + assert_eq!(arguments.reference.as_deref(), Some("main")); + assert_eq!(arguments.candidate_kind.as_deref(), Some("range")); + assert_eq!(arguments.candidate_revision.as_deref(), Some("main...HEAD")); + assert!(parse_arguments( + [ + "differential".into(), + "--operation".into(), + "run".into(), + "--run-id".into(), + "diff-1".into(), + "--reference".into(), + "main".into(), + "--candidate".into(), + "range".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "differential".into(), + "--operation".into(), + "cleanup".into(), + "--dry-run".into(), + ], + cwd, + ) + .is_ok()); + } + + #[test] + fn scenario_parser_separates_generation_dry_run_and_file_acceptance() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Scenario(generate) = parse_arguments( + [ + "scenario".into(), + "--operation".into(), + "generate".into(), + "--spec".into(), + "docs/checkout.md".into(), + "--model".into(), + "qwen2.5-coder:7b".into(), + "--route".into(), + "/checkout".into(), + "--request-policy".into(), + "--json".into(), + ], + cwd, + ) + .expect("scenario generate") else { + panic!("expected scenario"); + }; + let ScenarioCompilerAction::Generate { + provider, context, .. + } = generate.action + else { + panic!("expected generate"); + }; + assert_eq!(provider.provider, "local"); + assert_eq!(context.routes, ["/checkout"]); + assert!(context.include_request_policy); + + let CliCommand::Scenario(accept) = parse_arguments( + [ + "scenario".into(), + "--operation".into(), + "accept".into(), + "--candidate-id".into(), + "candidate-1".into(), + "--candidate-hash".into(), + "a".repeat(64), + "--destination".into(), + ".codevetter/scenarios/checkout.yaml".into(), + "--approve-replacements".into(), + ], + cwd, + ) + .expect("scenario accept") else { + panic!("expected scenario"); + }; + let ScenarioCompilerAction::Accept { + selected_destinations, + approve_replacements, + .. + } = accept.action + else { + panic!("expected accept"); + }; + assert_eq!( + selected_destinations, + [".codevetter/scenarios/checkout.yaml"] + ); + assert!(approve_replacements); + assert!(parse_arguments( + ["scenario".into(), "--operation".into(), "cleanup".into()], + cwd, + ) + .is_err()); + } + + #[test] + fn watcher_parser_separates_configuration_from_confirmed_execution() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Watcher(enable) = parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "enable".into(), + "--interval-secs".into(), + "120".into(), + "--base-branch".into(), + "main".into(), + "--json".into(), + ], + cwd, + ) + .expect("watcher enable") else { + panic!("expected watcher"); + }; + assert_eq!(enable.repo_path.as_deref(), Some(cwd)); + assert_eq!(enable.interval_secs, Some(120)); + assert_eq!(enable.base_branch.as_deref(), Some("main")); + assert!(!enable.confirm_run); + assert_eq!(enable.output, OutputMode::Json); + + assert!( + parse_arguments(["watcher".into(), "--operation".into(), "poll".into()], cwd,).is_err() + ); + let CliCommand::Watcher(poll) = parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "poll".into(), + "--confirm-run".into(), + ], + cwd, + ) + .expect("confirmed watcher poll") else { + panic!("expected watcher"); + }; + assert!(poll.confirm_run); + assert!(parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "disable".into(), + "--confirm-run".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn parser_preserves_explicit_repo_pr_and_json_mode() { + let CliCommand::Trex(arguments) = parse_arguments( + [ + "trex".into(), + "--repo".into(), + "/tmp/other".into(), + "--pr".into(), + "https://github.com/acme/widget/pull/42".into(), + "--preview".into(), + "https://preview.example.com".into(), + "--json".into(), + ], + Path::new("/tmp/widget"), + ) + .expect("arguments") else { + panic!("expected trex"); + }; + assert_eq!(arguments.repo_path, Path::new("/tmp/other")); + assert_eq!(arguments.change_kind, TrexChangeKind::PullRequest); + assert_eq!(arguments.output, OutputMode::Json); + } + + #[test] + fn capabilities_parser_and_human_output_share_the_registry() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["capabilities".into(), "--json".into()], cwd).expect("capabilities"), + CliCommand::Capabilities(OutputMode::Json) + )); + assert!(matches!( + parse_arguments(["capabilities".into(), "--schema".into()], cwd) + .expect("capability schema"), + CliCommand::CapabilitySchema + )); + assert!(parse_arguments(["capabilities".into(), "--unknown".into()], cwd).is_err()); + + let output = render_human_capabilities(&capability_registry()); + assert!(output.contains("verification.local_check")); + assert!(output.contains("native.evidence_workbench")); + assert!(output.contains("UI: building | CLI: unavailable | agent: unavailable")); + } + + #[test] + fn runs_parser_and_human_output_are_bounded() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Runs(arguments) = parse_arguments( + [ + "runs".into(), + "--repo".into(), + "/tmp/widget".into(), + "--limit".into(), + "7".into(), + "--json".into(), + ], + cwd, + ) + .expect("runs arguments") else { + panic!("expected runs"); + }; + assert_eq!(arguments.repo_path, Some(PathBuf::from("/tmp/widget"))); + assert_eq!(arguments.limit, 7); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["runs".into(), "--limit".into(), "101".into()], cwd).is_err()); + + let receipt = fixture_local_check(LocalCheckVerdict::PassedWithLimits); + let history = RunHistoryReceipt { + schema_version: "codevetter.run-history/v1".into(), + generated_at: "2026-08-31T00:00:00Z".into(), + repo_path: None, + limit: 7, + returned: 1, + runs: vec![ + codevetter_desktop::commands::run_history::RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: receipt.run_id.clone(), + kind: codevetter_desktop::commands::run_history::RunKind::LocalCheck, + repo_path: Some(receipt.repo_path.clone()), + recorded_at: receipt.ran_at.clone(), + title: receipt.task.clone(), + outcome: "passed_with_limits".into(), + receipt_schema: receipt.schema_version.clone(), + source_label: Some("bbbbbbbbbbbb".into()), + limitations: receipt.limitations.clone(), + receipt: serde_json::to_value(receipt).expect("receipt JSON"), + }, + ], + }; + let output = render_human_runs(&history); + assert!(output.contains("passed_with_limits")); + assert!(output.contains("bbbbbbbbbbbb")); + assert!(output.contains("LocalCheck")); + } + + #[test] + fn usage_parser_and_human_output_preserve_provider_boundaries() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Usage(arguments) = parse_arguments( + [ + "usage".into(), + "--timezone".into(), + "Asia/Kolkata".into(), + "--refresh".into(), + "--json".into(), + ], + cwd, + ) + .expect("usage arguments") else { + panic!("expected usage"); + }; + assert_eq!(arguments.timezone.as_deref(), Some("Asia/Kolkata")); + assert!(arguments.refresh); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["usage".into(), "--unknown".into()], cwd).is_err()); + + let report: LocalUsageReport = serde_json::from_value(serde_json::json!({ + "status": "ready", + "stale": false, + "error": null, + "provenance": { + "engine": "ccusage", + "version": "20.0.20", + "generated_at": "2026-08-31T00:00:00Z", + "timezone": "Asia/Kolkata", + "window": "all", + "detected_agents": ["claude", "codex", "grok"], + "excluded_agents": ["devin"], + "codex_roots": ["/tmp/codex"], + "source_fingerprint": "sha256:fixture", + "pricing_complete": true, + "fallback_models": [], + "unpriced_models": [] + }, + "daily": [], + "weekly": [], + "monthly": [], + "sessions": [], + "totals": { + "input_tokens": 100, + "cache_creation_tokens": 20, + "cache_read_tokens": 300, + "output_tokens": 40, + "total_tokens": 460, + "cost_usd": 1.25 + }, + "devin": { + "status": "ready", + "source": "CodeVetter SQLite", + "sessions": 3, + "generated_tokens": 1200, + "cache_read_tokens": 400, + "output_tokens": 100, + "cost_usd": 0.52, + "models": [], + "windows": [{ + "window": "1w", + "since": "2026-08-26", + "sessions": 2, + "generated_tokens": 800, + "cache_read_tokens": 250, + "cost_usd": 0.31, + "models": [] + }], + "limitations": ["Devin remains separate from ccusage totals."] + } + })) + .expect("usage fixture"); + let output = render_human_usage(&report); + assert!(output.contains("Local usage · ccusage 20.0.20 · Asia/Kolkata")); + assert!(output.contains("tokens: 460 total · 160 generated · 300 cache read")); + assert!(output.contains("Devin and live provider quotas are separate")); + assert!(output.contains("devin windows: 1w 2 sessions / 800 generated / $0.31")); + assert!(output.contains("excluded: devin")); + } + + #[test] + fn ops_parser_and_human_output_preserve_read_only_secret_boundary() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Ops(arguments) = parse_arguments( + [ + "ops".into(), + "--window-days".into(), + "90".into(), + "--json".into(), + ], + cwd, + ) + .expect("Ops arguments") else { + panic!("expected Ops"); + }; + assert_eq!(arguments.window_days, 90); + assert_eq!(arguments.output, OutputMode::Json); + assert!( + parse_arguments(["ops".into(), "--window-days".into(), "365".into()], cwd).is_err() + ); + + let receipt = OpsStatusReceipt { + schema_version: "codevetter.ops-status/v1".into(), + generated_at: "2026-09-02T00:00:00Z".into(), + database_available: true, + window_days: 90, + billing: OpsBillingStatus { + anthropic_configured: true, + openai_configured: false, + }, + webhook: OpsWebhookStatus { + configured: true, + flavor: "slack".into(), + }, + observability: Vec::new(), + excluded_sensitive_keys: vec!["anthropic_admin_key".into()], + limitations: vec!["read only".into()], + }; + let output = render_human_ops(&receipt); + assert!(output.contains("Ops · 90 days")); + assert!(output.contains("Anthropic configured · OpenAI not configured")); + assert!(output.contains("credentials and endpoint values: excluded")); + } + + #[test] + fn unpack_parser_bounds_history_inspection_and_explicit_scan_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Unpack(arguments) = parse_arguments( + [ + "unpack".into(), + "--repo".into(), + "/tmp/widget".into(), + "--limit".into(), + "25".into(), + "--json".into(), + ], + cwd, + ) + .expect("unpack arguments") else { + panic!("expected unpack"); + }; + assert_eq!(arguments.operation, UnpackOperation::List); + assert_eq!(arguments.repo_path.as_deref(), Some("/tmp/widget")); + assert_eq!(arguments.limit, 25); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["unpack".into(), "--limit".into(), "101".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "unpack".into(), + "--repo".into(), + "/tmp/widget".into(), + "--report-id".into(), + "snapshot-1".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(scan) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "scan".into(), + "--repo".into(), + "/tmp/widget".into(), + "--json".into(), + ], + cwd, + ) + .expect("scan arguments") else { + panic!("expected unpack scan"); + }; + assert_eq!(scan.operation, UnpackOperation::Scan); + assert_eq!(scan.repo_path.as_deref(), Some("/tmp/widget")); + assert!(scan.report_id.is_none()); + assert!(parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "scan".into(), + "--repo".into(), + "/tmp/widget".into(), + "--limit".into(), + "1".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn settings_parser_preserves_one_explicit_non_secret_assignment() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Settings(arguments) = parse_arguments( + [ + "settings".into(), + "--set".into(), + "review_tone=strict".into(), + "--json".into(), + ], + cwd, + ) + .expect("settings arguments") else { + panic!("expected settings"); + }; + assert_eq!(arguments.set, Some(("review_tone".into(), "strict".into()))); + assert_eq!(arguments.output, OutputMode::Json); + assert!( + parse_arguments(["settings".into(), "--set".into(), "missing".into()], cwd,).is_err() + ); + assert!(parse_arguments( + [ + "settings".into(), + "--set".into(), + "review_tone=strict".into(), + "--set".into(), + "compact_mode=true".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn history_roots_parser_separates_read_add_and_remove() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["history-roots".into(), "--json".into()], cwd).expect("read roots"), + CliCommand::HistoryRoots(HistoryRootsArguments { + operation: HistoryRootsOperation::Read, + path: None, + output: OutputMode::Json, + }) + )); + + let CliCommand::HistoryRoots(add) = parse_arguments( + [ + "history-roots".into(), + "--add".into(), + "/tmp/codex".into(), + "--json".into(), + ], + cwd, + ) + .expect("add root") else { + panic!("expected history-roots add"); + }; + assert_eq!(add.operation, HistoryRootsOperation::Add); + assert_eq!(add.path, Some(PathBuf::from("/tmp/codex"))); + + assert!(parse_arguments( + [ + "history-roots".into(), + "--add".into(), + "/tmp/one".into(), + "--remove".into(), + "/tmp/two".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn memories_parser_separates_list_read_and_redacted_diff() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["memories".into(), "--json".into()], cwd).expect("list memories"), + CliCommand::Memories(MemoriesArguments { + source_id: None, + diff: false, + output: OutputMode::Json, + }) + )); + + let CliCommand::Memories(read) = parse_arguments( + [ + "memories".into(), + "--source".into(), + "memory:sha256:fixture".into(), + "--json".into(), + ], + cwd, + ) + .expect("read memory") else { + panic!("expected memories read"); + }; + assert_eq!(read.source_id.as_deref(), Some("memory:sha256:fixture")); + assert!(!read.diff); + + let CliCommand::Memories(diff) = parse_arguments( + [ + "memories".into(), + "--source".into(), + "memory:sha256:fixture".into(), + "--diff".into(), + ], + cwd, + ) + .expect("memory diff") else { + panic!("expected memories diff"); + }; + assert!(diff.diff); + assert!(parse_arguments(["memories".into(), "--diff".into()], cwd).is_err()); + } + + #[test] + fn onboarding_parser_separates_inspection_from_explicit_completion() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["onboarding".into(), "--json".into()], cwd) + .expect("inspect onboarding"), + CliCommand::Onboarding(OnboardingArguments { + complete: false, + default_adapter: None, + output: OutputMode::Json, + }) + )); + + let CliCommand::Onboarding(arguments) = parse_arguments( + [ + "onboarding".into(), + "--complete".into(), + "--default-adapter".into(), + "codex".into(), + "--json".into(), + ], + cwd, + ) + .expect("complete onboarding") else { + panic!("expected onboarding"); + }; + assert!(arguments.complete); + assert_eq!(arguments.default_adapter.as_deref(), Some("codex")); + assert!(parse_arguments(["onboarding".into(), "--complete".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "onboarding".into(), + "--complete".into(), + "--default-adapter".into(), + "unknown".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn mcp_parser_requires_one_repository_and_at_most_one_authority_change() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Mcp(arguments) = parse_arguments( + [ + "mcp".into(), + "--repo".into(), + "/tmp/widget".into(), + "--enable".into(), + "--json".into(), + ], + cwd, + ) + .expect("mcp arguments") else { + panic!("expected mcp"); + }; + assert_eq!(arguments.repo_path, Path::new("/tmp/widget")); + assert_eq!(arguments.operation, McpSettingsOperation::Enable); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["mcp".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "mcp".into(), + "--repo".into(), + "/tmp/widget".into(), + "--enable".into(), + "--disable".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn retention_parser_separates_preview_apply_and_checkpoint_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Retention(preview) = parse_arguments( + [ + "retention".into(), + "--max-age-days".into(), + "90".into(), + "--max-archive-mib".into(), + "2048".into(), + "--json".into(), + ], + cwd, + ) + .expect("retention preview") else { + panic!("expected retention"); + }; + assert_eq!(preview.operation, SessionRetentionOperation::Plan); + assert_eq!(preview.max_age_days, Some(90)); + assert_eq!(preview.max_archive_mib, Some(2048)); + assert_eq!(preview.output, OutputMode::Json); + + let CliCommand::Retention(apply) = parse_arguments( + [ + "retention".into(), + "--apply".into(), + "retention-plan:abc".into(), + ], + cwd, + ) + .expect("retention apply") else { + panic!("expected retention"); + }; + assert_eq!(apply.operation, SessionRetentionOperation::Apply); + assert_eq!(apply.plan_id.as_deref(), Some("retention-plan:abc")); + + assert!(parse_arguments(["retention".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "retention".into(), + "--checkpoint".into(), + "--apply".into(), + "retention-plan:abc".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments(["retention".into(), "--vacuum".into()], cwd).is_err()); + } + + #[test] + fn rubrics_parser_separates_read_select_and_validated_custom_pack_input() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["rubrics".into(), "--json".into()], cwd).expect("read rubrics"), + CliCommand::Rubrics(RubricsArguments { + select: None, + upsert: None, + output: OutputMode::Json, + }) + )); + + let CliCommand::Rubrics(arguments) = parse_arguments( + [ + "rubrics".into(), + "--id".into(), + "performance-proof".into(), + "--name".into(), + "Performance Proof".into(), + "--focus".into(), + "Measured regressions".into(), + "--check".into(), + "Require a baseline".into(), + "--check".into(), + "Reject unsupported claims".into(), + ], + cwd, + ) + .expect("custom rubric") else { + panic!("expected rubrics"); + }; + let pack = arguments.upsert.expect("upsert"); + assert_eq!(pack.id, "performance-proof"); + assert_eq!(pack.checks.len(), 2); + + assert!(parse_arguments( + [ + "rubrics".into(), + "--select".into(), + "product-safety".into(), + "--id".into(), + "invalid".into(), + ], + cwd, + ) + .is_err()); + assert!( + parse_arguments(["rubrics".into(), "--id".into(), "incomplete".into(),], cwd,).is_err() + ); + } + + #[test] + fn xray_parser_preserves_public_confirmation_excerpt_and_save_identity() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Xray(arguments) = parse_arguments( + [ + "xray".into(), + "--review-id".into(), + "review-7".into(), + "--public-source".into(), + "owner/repo#7".into(), + "--confirm-public".into(), + "--approve-excerpt".into(), + "finding-1".into(), + "--format".into(), + "markdown".into(), + "--save".into(), + "/tmp/xray.md".into(), + "--json".into(), + ], + cwd, + ) + .expect("xray arguments") else { + panic!("expected xray"); + }; + assert_eq!(arguments.request.review_id, "review-7"); + assert_eq!( + arguments.request.public_source.as_deref(), + Some("owner/repo#7") + ); + assert!(arguments.request.public_source_confirmed); + assert_eq!( + arguments.request.approved_excerpt_finding_ids, + vec!["finding-1"] + ); + assert!(matches!(arguments.format, XrayFormat::Markdown)); + assert_eq!(arguments.save_path.as_deref(), Some("/tmp/xray.md")); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments(["xray".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "xray".into(), + "--review-id".into(), + "review-7".into(), + "--format".into(), + "pdf".into(), + ], + cwd, + ) + .is_err()); + } + + #[test] + fn fix_packet_parser_preserves_bounded_finding_selection() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::FixPacket(arguments) = parse_arguments( + [ + "fix-packet".into(), + "--run-id".into(), + "local-check-7".into(), + "--finding".into(), + "finding-2".into(), + "--finding".into(), + "finding-1".into(), + "--json".into(), + ], + cwd, + ) + .expect("fix packet arguments") else { + panic!("expected fix packet"); }; - LocalCheckReceipt { - schema_version: "codevetter.local-check/v1".into(), - run_id: "local-check-fixture".into(), - ran_at: "2026-08-24T00:00:00Z".into(), - repo_path: "/tmp/widget".into(), - task: "Preserve behavior".into(), - source: TrexSourceReceipt { - kind: TrexChangeKind::Range, - input: "main...HEAD".into(), - base_sha: "a".repeat(40), - head_sha: "b".repeat(40), - commits: vec!["b".repeat(40)], - changed_paths: vec!["src/parser.ts".into()], - }, - stages: LocalCheckStages { - review: stage(LocalCheckStatus::Completed), - correctness: stage(LocalCheckStatus::Passed), - performance: stage(LocalCheckStatus::Completed), - optimization: LocalCheckStage { - status: LocalCheckStatus::Ready, - duration_ms: 0, - target: None, - evidence: serde_json::json!({"candidate_command": "codevetter check --repo "}), - limitations: vec!["Candidate edits remain external.".into()], - }, - }, - spec_coverage: None, - verdict, - limitations: vec!["Candidate edits remain external.".into()], - } + assert_eq!(arguments.run_id, "local-check-7"); + assert_eq!(arguments.finding_ids, vec!["finding-2", "finding-1"]); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["fix-packet".into()], cwd).is_err()); } #[test] - fn parser_defaults_to_current_repo_and_requires_one_source() { + fn fix_parser_separates_execute_inspect_and_confirmed_discard() { let cwd = Path::new("/tmp/widget"); - let CliCommand::Trex(arguments) = parse_arguments( + let CliCommand::Fix(execute) = parse_arguments( [ - "trex".into(), - "--range".into(), - "main..HEAD".into(), - "--preview".into(), - "https://preview.example.com".into(), + "fix".into(), + "--operation".into(), + "execute".into(), + "--run-id".into(), + "local-check-7".into(), + "--finding".into(), + "finding-2".into(), + "--agent".into(), + "claude".into(), + "--confirm-run".into(), + "--timeout-ms".into(), + "45000".into(), + "--json".into(), ], cwd, ) - .expect("arguments") else { - panic!("expected trex"); + .expect("fix execute arguments") else { + panic!("expected fix command"); }; - assert_eq!(arguments.repo_path, cwd); - assert_eq!(arguments.change_kind, TrexChangeKind::Range); - assert_eq!(arguments.output, OutputMode::Human); + assert_eq!(execute.operation, FixOperation::Execute); + assert_eq!(execute.run_id.as_deref(), Some("local-check-7")); + assert_eq!(execute.finding_ids, vec!["finding-2"]); + assert_eq!(execute.agent, "claude"); + assert!(execute.confirm_run); + assert_eq!(execute.timeout_ms, 45_000); + assert_eq!(execute.output, OutputMode::Json); + + let CliCommand::Fix(inspect) = parse_arguments( + [ + "fix".into(), + "--operation".into(), + "inspect".into(), + "--attempt-id".into(), + "fix-attempt-abc123".into(), + ], + cwd, + ) + .expect("fix inspect arguments") else { + panic!("expected fix command"); + }; + assert_eq!(inspect.operation, FixOperation::Inspect); + assert_eq!(inspect.attempt_id.as_deref(), Some("fix-attempt-abc123")); + + let CliCommand::Fix(discard) = parse_arguments( + [ + "fix".into(), + "--operation".into(), + "discard".into(), + "--attempt-id".into(), + "fix-attempt-abc123".into(), + "--confirm-discard".into(), + ], + cwd, + ) + .expect("fix discard arguments") else { + panic!("expected fix command"); + }; + assert_eq!(discard.operation, FixOperation::Discard); + assert!(discard.confirm_discard); assert!(parse_arguments( [ - "trex".into(), - "--pr".into(), - "https://github.com/acme/widget/pull/1".into(), - "--range".into(), - "main..HEAD".into(), - "--preview".into(), - "https://preview.example.com".into(), + "fix".into(), + "--operation".into(), + "execute".into(), + "--run-id".into(), + "local-check-7".into(), + "--finding".into(), + "finding-2".into(), ], cwd, ) .is_err()); assert!(parse_arguments( [ - "trex".into(), - "--preview".into(), - "https://preview.example.com".into(), + "fix".into(), + "--operation".into(), + "discard".into(), + "--attempt-id".into(), + "fix-attempt-abc123".into(), ], cwd, ) @@ -794,26 +5735,239 @@ mod tests { } #[test] - fn parser_preserves_explicit_repo_pr_and_json_mode() { - let CliCommand::Trex(arguments) = parse_arguments( + fn scope_parser_preserves_one_closed_consumer_and_scope_contract() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Scope(arguments) = parse_arguments( [ - "trex".into(), + "scope".into(), + "--consumer".into(), + "performance".into(), + "--change".into(), + "main...HEAD".into(), + "--json".into(), + ], + cwd, + ) + .expect("scope arguments") else { + panic!("expected scope"); + }; + assert_eq!(arguments.input.repo_path, "/tmp/widget"); + assert_eq!(arguments.input.consumer, EvidenceScopeConsumer::Performance); + assert_eq!(arguments.input.kind, EvidenceScopeKind::Change); + assert_eq!(arguments.input.value.as_deref(), Some("main...HEAD")); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments( + [ + "scope".into(), + "--consumer".into(), + "testing".into(), + "--flow".into(), + "checkout".into(), + "--codebase".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments(["scope".into(), "--codebase".into()], cwd,).is_err()); + } + + #[test] + fn scope_cli_projects_the_shared_surface_parity_fixture_without_schema_drift() { + let fixture = surface_parity_fixture(); + let request = &fixture["request"]; + let cwd = Path::new("/fixture/repo"); + let CliCommand::Scope(arguments) = parse_arguments( + [ + "scope".into(), + "--consumer".into(), + request["consumer"] + .as_str() + .expect("fixture consumer") + .into(), "--repo".into(), - "/tmp/other".into(), - "--pr".into(), - "https://github.com/acme/widget/pull/42".into(), - "--preview".into(), - "https://preview.example.com".into(), + cwd.to_string_lossy().into_owned(), + "--flow".into(), + request["value"].as_str().expect("fixture value").into(), "--json".into(), ], - Path::new("/tmp/widget"), + cwd, ) - .expect("arguments") else { - panic!("expected trex"); + .expect("fixture CLI arguments") else { + panic!("expected fixture scope command"); }; - assert_eq!(arguments.repo_path, Path::new("/tmp/other")); - assert_eq!(arguments.change_kind, TrexChangeKind::PullRequest); + assert_eq!(arguments.input.repo_path, "/fixture/repo"); + assert_eq!(arguments.input.consumer, EvidenceScopeConsumer::Performance); + assert_eq!(arguments.input.kind, EvidenceScopeKind::Flow); + assert_eq!(arguments.input.value.as_deref(), Some("coupon total")); + assert_eq!(arguments.output, OutputMode::Json); + assert_eq!(fixture["authority"]["cli"], "supervised_projection"); + + let receipt: EvidenceScopePlan = + serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("fixture canonical receipt"); + let encoded = serde_json::to_string(&receipt).expect("serialize fixture receipt"); + let decoded: EvidenceScopePlan = + serde_json::from_str(&encoded).expect("decode CLI fixture receipt"); + assert_eq!( + decoded.schema_version, + fixture["expected"]["schema_version"] + ); + assert_eq!(decoded.status, fixture["expected"]["status"]); + assert_eq!( + decoded.candidates[0].target, + fixture["expected"]["first_candidate"]["target"] + ); + let human = render_human_scope(&decoded); + assert!(human.contains("Evidence scope · performance\nstatus: ready")); + assert!(human.contains("src/cart/coupon.test.ts")); + } + + #[test] + fn check_cli_preserves_the_shared_local_check_receipt_and_exit_semantics() { + let fixture = local_check_parity_fixture(); + let request = &fixture["request"]; + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--request-id".into(), + request["request_id"].as_str().expect("request id").into(), + "--repo".into(), + request["repo_path"].as_str().expect("repository").into(), + "--range".into(), + request["change"].as_str().expect("change").into(), + "--task".into(), + request["task"].as_str().expect("task").into(), + "--json".into(), + ], + Path::new("/ignored"), + ) + .expect("local-check parity CLI arguments") else { + panic!("expected local-check fixture command"); + }; + assert_eq!(fixture["authority"]["cli"], "supervised_execution"); + assert_eq!(arguments.repo_path, Path::new("/fixture/repo")); + assert_eq!(arguments.change, "main...HEAD"); + assert_eq!(arguments.task, "Preserve checkout totals"); + assert_eq!( + arguments.request_id.as_deref(), + request["request_id"].as_str() + ); + + let receipt: LocalCheckReceipt = + serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("fixture canonical local-check receipt"); + assert_eq!( + receipt.schema_version, + fixture["expected"]["receipt_schema"] + ); + assert_eq!( + receipt.request_id.as_deref(), + request["request_id"].as_str() + ); + assert_eq!(receipt.verdict, LocalCheckVerdict::NoConfidence); + assert_eq!( + local_check_exit_code(receipt.verdict), + fixture["expected"]["exit_code"] + .as_i64() + .expect("fixture exit code") as i32 + ); + let human = render_human_check(&receipt); + assert!(human.contains("verdict: no_confidence")); + assert!(human.contains( + fixture["expected"]["limitation"] + .as_str() + .expect("fixture limitation") + )); + } + + #[test] + fn performance_parser_and_human_output_preserve_the_closed_contract() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Performance(arguments) = parse_arguments( + [ + "performance".into(), + "--operation".into(), + "verify-paired".into(), + "--repo".into(), + "/tmp/candidate".into(), + "--baseline-repo".into(), + "/tmp/baseline".into(), + "--adapter".into(), + "go-bench".into(), + "--target".into(), + "bench/parser_test.go".into(), + "--name".into(), + "BenchmarkParser".into(), + "--samples".into(), + "5".into(), + "--warmups".into(), + "2".into(), + "--timeout-ms".into(), + "45000".into(), + "--request-id".into(), + "performance-fixture".into(), + "--json".into(), + ], + cwd, + ) + .expect("performance arguments") else { + panic!("expected performance"); + }; + assert_eq!( + arguments.input.operation, + PerformanceOperation::VerifyPaired + ); + assert_eq!(arguments.input.adapter, Some(PerformanceAdapter::GoBench)); + assert_eq!( + arguments.input.target.as_deref(), + Some("bench/parser_test.go") + ); + assert_eq!(arguments.input.samples, Some(5)); + assert_eq!(arguments.input.warmups, Some(2)); + assert_eq!(arguments.input.timeout_ms, Some(45_000)); + assert_eq!(arguments.input.request_id, "performance-fixture"); assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments( + ["performance".into(), "--operation".into(), "guess".into(),], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "performance".into(), + "--operation".into(), + "plan".into(), + "--adapter".into(), + "shell".into(), + ], + cwd, + ) + .is_err()); + + let receipt = PerformanceRunReceipt { + schema_version: 1, + request_id: "performance-fixture".into(), + operation: PerformanceOperation::Plan, + state: "succeeded".into(), + exit_code: Some(0), + duration_ms: 17, + result: serde_json::json!({ + "decision": { "status": "admitted" }, + "limitations": ["Exact fixture scope only."] + }), + stderr_summary: None, + cleanup: codevetter_desktop::commands::performance_bridge::PerformanceCleanupReceipt { + owned_process_reaped: true, + temporary_profiles_retained: false, + }, + resources: Default::default(), + }; + let output = render_human_performance(&receipt); + assert!(output.contains("operation: plan")); + assert!(output.contains("verdict: admitted")); + assert!(output.contains("Exact fixture scope only.")); } #[test] @@ -825,6 +5979,8 @@ mod tests { "main...HEAD".into(), "--task".into(), "Reduce parser latency without changing output".into(), + "--request-id".into(), + "native-review-fixture".into(), "--preflight".into(), "--spec".into(), "docs/parser.md".into(), @@ -851,6 +6007,10 @@ mod tests { }; assert_eq!(arguments.repo_path, Path::new("/tmp/widget")); assert_eq!(arguments.change, "main...HEAD"); + assert_eq!( + arguments.request_id.as_deref(), + Some("native-review-fixture") + ); assert_eq!(arguments.review_agent, "claude"); assert_eq!( arguments.spec_paths, @@ -865,6 +6025,7 @@ mod tests { ); assert_eq!(arguments.samples, 5); assert!(arguments.preflight); + assert!(!arguments.progress_json); assert_eq!(arguments.output, OutputMode::Json); assert_eq!( arguments @@ -875,6 +6036,91 @@ mod tests { ); } + #[test] + fn check_parser_preserves_the_independent_cross_review_strategy() { + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Reject incomplete composite reviews".into(), + "--agent".into(), + "cross".into(), + "--json".into(), + ], + Path::new("/tmp/widget"), + ) + .expect("arguments") else { + panic!("expected check"); + }; + assert_eq!(arguments.review_agent, "cross"); + assert_eq!(arguments.output, OutputMode::Json); + } + + #[test] + fn check_parser_bounds_machine_readable_progress_to_executable_json_checks() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Prove the change".into(), + "--json".into(), + "--progress-json".into(), + ], + cwd, + ) + .expect("progress arguments") else { + panic!("expected check"); + }; + assert!(arguments.progress_json); + assert_eq!(arguments.output, OutputMode::Json); + + for invalid in [ + vec![ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Prove the change".into(), + "--progress-json".into(), + ], + vec![ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Prove the change".into(), + "--preflight".into(), + "--json".into(), + "--progress-json".into(), + ], + ] { + assert!(parse_arguments(invalid, cwd).is_err()); + } + } + + #[test] + fn machine_readable_progress_preserves_the_versioned_stderr_contract() { + let event = VerificationProgress { + schema_version: "codevetter.progress/v2".into(), + request_id: "native-review-fixture".into(), + sequence: 4, + stage: "correctness".into(), + state: "running".into(), + }; + let progress: serde_json::Value = + serde_json::from_str(&render_progress_json(&event)).expect("progress JSON"); + assert_eq!(progress["schema_version"], "codevetter.progress/v2"); + assert_eq!(progress["request_id"], "native-review-fixture"); + assert_eq!(progress["sequence"], 4); + assert_eq!(progress["stage"], "correctness"); + assert_eq!(progress["state"], "running"); + } + #[test] fn check_parser_rejects_partial_targets_and_ambiguous_sources() { let cwd = Path::new("/tmp/widget"); @@ -919,6 +6165,51 @@ mod tests { .is_err()); } + #[test] + fn collect_parser_requires_a_range_and_explicit_supported_collectors() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Collect(arguments) = parse_arguments( + [ + "collect".into(), + "--range".into(), + "main..HEAD".into(), + "--collector".into(), + "gitleaks".into(), + "--collector".into(), + "cargo-audit".into(), + "--json".into(), + ], + cwd, + ) + .expect("collect arguments") else { + panic!("expected collect command") + }; + assert_eq!(arguments.repo_path, cwd); + assert_eq!(arguments.change, "main..HEAD"); + assert_eq!( + arguments.collectors, + vec![CollectorKind::Gitleaks, CollectorKind::CargoAudit] + ); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments( + ["collect".into(), "--range".into(), "main..HEAD".into()], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "collect".into(), + "--range".into(), + "main..HEAD".into(), + "--collector".into(), + "unknown".into(), + ], + cwd, + ) + .is_err()); + } + #[test] fn output_and_exit_codes_preserve_receipt_meaning() { let config: serde_json::Value = @@ -1012,6 +6303,7 @@ mod tests { let preflight = LocalCheckPreflightReceipt { schema_version: "codevetter.local-check-preflight/v1".into(), + request_id: None, ran_at: "2026-08-29T00:00:00Z".into(), repo_path: passed.repo_path.clone(), task: passed.task.clone(), diff --git a/apps/desktop/src-tauri/src/capabilities.rs b/apps/desktop/src-tauri/src/capabilities.rs new file mode 100644 index 00000000..c8983987 --- /dev/null +++ b/apps/desktop/src-tauri/src/capabilities.rs @@ -0,0 +1,928 @@ +//! Canonical product-capability catalog shared by every CodeVetter surface. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +pub const CAPABILITY_SCHEMA_VERSION: &str = "codevetter.capabilities.v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityStage { + Current, + Building, + Future, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Availability { + Available, + Building, + Planned, + Unavailable, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Authority { + None, + Read, + Execute, + ReadExecute, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Qualification { + Qualified, + Partial, + Unqualified, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SurfaceProjection { + pub availability: Availability, + pub authority: Authority, + pub entrypoints: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SurfaceMatrix { + pub ui: SurfaceProjection, + pub cli: SurfaceProjection, + pub agent: SurfaceProjection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UnderlyingTool { + pub name: String, + pub role: String, + pub requirement: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Capability { + pub id: String, + pub name: String, + pub purpose: String, + pub stage: CapabilityStage, + pub surfaces: SurfaceMatrix, + pub underlying_tools: Vec, + pub data_boundary: String, + pub qualification: Qualification, + pub limitations: Vec, + pub next_step: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CapabilityRegistry { + pub schema_version: String, + pub authority: String, + pub capabilities: Vec, +} + +pub fn capability_registry() -> CapabilityRegistry { + let registry = CapabilityRegistry { + schema_version: CAPABILITY_SCHEMA_VERSION.to_string(), + authority: "codevetter-rust-core".to_string(), + capabilities: vec![ + capability( + "verification.local_check", + "Local verification", + "Bind one exact task and change to executable correctness, performance, review, and receipt evidence.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Review", + "Native Review (acceptance binding, plan, execute, findings, proof map, intent diagnostic, recorded-QA artifacts, isolated fix/recheck, source, X-Ray, export)", + ], + ), + projection(Availability::Available, Authority::ReadExecute, &["codevetter check", "codevetter fix-packet", "codevetter fix", "codevetter xray"]), + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "MCP verification_get_receipt (read-only persisted receipt)", + "codevetter fix through an explicit local CLI consent boundary", + ], + ), + ), + &[ + tool("CodeVetter Rust core", "Owns planning, execution order, verdicts, and receipts", "bundled"), + tool("Configured coding-agent CLI", "Produces the model review stage", "optional local tool"), + ], + "Selected local repository; evidence stays on the Mac unless the configured agent provider is invoked.", + Qualification::Partial, + &["The Tauri-independent verification-command/v1 service correlates native/CLI commands, ordered progress/v2 events, request-scoped verification-cancel/v1 termination, and terminal receipts with one bounded request id.", "Cancellation is supervised at the process boundary but is not yet a canonical engine event.", "An unavailable runtime collector yields an explicit limitation rather than a passing claim.", "Native source opening uses the recorded repository-relative path; line positioning depends on the user's default editor.", "Fix execution is deliberately limited to one retained detached worktree. It never commits, merges, pushes, or modifies the selected checkout; discard requires a separate confirmation.", "The repository-scoped MCP server can read one persisted canonical local-check receipt by bounded run id, but it cannot start or cancel verification. Agent execution remains an explicit local CLI consent boundary."], + "Qualify a real isolated fix/recheck plus saved-flow post-fix rerun without duplicating execution authority in Review.", + ), + capability( + "verification.runtime_preview", + "Runtime preview verification", + "Exercise changed browser behavior against an exact preview and preserve executable evidence.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Testing", + "Native Testing (secret-safe journey workspace + scope discovery + direct preview + warm + differential + scenarios + PR watcher)", + ], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "codevetter scope --consumer testing", + "codevetter trex", + "codevetter qa", + "codevetter warm", + "codevetter differential", + "codevetter scenario", + "codevetter watcher", + ], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "MCP resolve_evidence_scope", + "MCP qa_workspace_inspect", + "MCP prepare_review verification_targets", + ], + ), + ), + &[ + tool("Playwright", "Runs browser journeys and captures runtime evidence", "project or bundled runtime"), + tool("CodeVetter Rust QA workspace", "Owns secret-safe saved workflow projection, repository spec discovery, explicit target handoff, and deterministic post-fix comparison setup", "bundled"), + ], + "Selected repository and explicitly supplied preview URL. Watcher polls may contact GitHub, execute project code and a configured agent, and post commit statuses only after foreground confirmation.", + Qualification::Partial, + &["A preview must already exist for direct preview verification; warm, differential, and scenario workflows instead require one repository-owned verify script and supported lockfile.", "Fixture execution is contract evidence, not production-change proof.", "Native and CLI consume one codevetter.qa-workspace/v1 receipt. It imports only non-secret legacy fields into a separate native preference, discovers repository Playwright specs without execution, passes the selected route and goal into the canonical T-REX receipt, and never restores preview network consent. The scoped MCP projection is read-only.", "Native and CLI scenario authoring share the incumbent Rust bridge: free/local generation creates only expiring candidates; validation and dry-run are non-persistent; acceptance is hash-bound and destination-selective. Differential evidence never creates pass evidence. Native PR watcher scheduling exists only for the current app lifetime; every foreground poll is explicitly confirmed and remains alive until new head-SHA receipts persist. The watcher fetches the immutable GitHub PR ref without changing the checkout, uses the repository-declared Node package manager, and resolves status authentication ephemerally from existing local authority. MCP target discovery remains read-only and never starts these runtimes.", "One repository-owned evidence-scope fixture now passes the authoritative Rust resolver, CLI projection, native supervised runner, and real read-only MCP protocol without semantic drift.", "A bounded live PR qualification proved exact-head materialization, pnpm frozen installation, repository-owned lint, conservative NEEDS_REVIEW classification, retained receipt identity, and a GitHub commit status without exposing a token."], + "Qualify one real saved-flow post-fix rerun and complete owner visual acceptance while keeping MCP inspection read-only.", + ), + capability( + "verification.performance", + "Performance verification", + "Compare bounded workloads and prevent unsupported optimization claims.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Performance", + "Native Performance (scope discovery + exact workload)", + ], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "codevetter scope --consumer performance", + "codevetter performance", + "codevetter check --perf-adapter", + ], + ), + projection( + Availability::Available, + Authority::Read, + &["MCP resolve_evidence_scope", "MCP prepare_review verification_targets"], + ), + ), + &[tool("CodeVetter performance capsule", "Supervises samples, warmups, timeouts, and paired evidence", "bundled")], + "Explicit local workload and optional clean baseline checkout.", + Qualification::Partial, + &["Results are workload-specific.", "A missing clean baseline blocks paired-improvement claims.", "Native intent/change/codebase scope resolution, digest-validated recorded-run inspection, the diagnosis-to-paired campaign handoff, and periodic owned-process-tree RSS/process evidence are available. Sampling can miss peaks between 75 ms observations.", "The shared evidence-scope fixture passes Rust, CLI, native, and read-only MCP projections; only UI and CLI retain workload-execution authority.", "Native release launch, steady app RSS, bridge latency, 1,000-event progress throughput, cancellation, worker crash recovery, and five large-receipt decode/render surfaces pass explicit gates."], + "Refresh launch, settled RSS, responsiveness, energy, and long-session evidence on the exact current package with owner-approved foreground qualification.", + ), + capability( + "evidence.local_usage", + "Local agent usage", + "Inspect local token, cache, cost, model, and session evidence without conflating it with cloud quota telemetry.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &[ + "Tauri Usage", + "Native Usage (ccusage plus separate indexed Devin history)", + ], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter usage"], + ), + projection(Availability::Planned, Authority::None, &[]), + ), + &[ + tool( + "ccusage 20.0.20", + "Normalizes offline Claude, Codex, and Grok local usage logs", + "bundled pinned sidecar", + ), + tool( + "CodeVetter Rust core", + "Owns provider boundaries, ccusage normalization, separate SQLite Devin history, caching, and stale/unavailable states", + "bundled", + ), + ], + "Local agent logs, optional read-only imported Codex roots, and indexed Devin sessions from the existing SQLite database; no provider credential or network access.", + Qualification::Partial, + &[ + "Indexed Devin sessions, generated/cache tokens, cost, and model rows follow 1w, 30d, 90d, and all-time windows through a separate Rust projection and are never included in ccusage totals.", + "Live provider quotas remain separate telemetry and are never inferred from local spend.", + "Native 1w, 30d, 90d, and all-time selection keeps ccusage chart, totals, models, and sessions aligned while the separate Devin desk follows the same selected window.", + ], + "Migrate live provider telemetry as a credential-safe separate projection, then expose the bounded report through scoped MCP.", + ), + capability( + "usage.history_roots", + "Additional Codex history roots", + "Restore Codex sessions stored outside the active CODEX_HOME without reading or deleting transcript content during configuration.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Usage settings", "Native Usage settings"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter history-roots"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns path normalization, Codex-home validation, deduplication, the 16-root bound, SQLite preference persistence, and the versioned receipt", + "bundled", + )], + "Absolute local directory identities and availability metadata only; configuration never reads transcript content and removal never deletes provider files.", + Qualification::Qualified, + &[ + "The active CODEX_HOME remains automatic and is not duplicated in the additional-root receipt.", + "A selected sessions or archived_sessions directory is normalized to its containing Codex home.", + "Reconciliation remains a separate explicit Usage action.", + "Agent and MCP surfaces receive no local history-root authority.", + ], + "Keep local history-root mutation out of agent authority and preserve the bounded receipt as usage importers evolve.", + ), + capability( + "configuration.native_settings", + "Native non-secret settings", + "Read and save declared local preferences without projecting credentials or provider tokens into Swift.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Building, + Authority::ReadExecute, + &["Native Settings", "Native first-run onboarding"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter settings", "codevetter onboarding"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns the allowlist, validation, SQLite persistence, and versioned receipt", + "bundled", + )], + "Twenty-eight declared non-secret local preferences plus the shared onboarding completion flag and default adapter; github_token and all undeclared values are excluded from every receipt.", + Qualification::Partial, + &[ + "Integration credentials remain in their incumbent owner until secure native storage is separately qualified.", + "Native onboarding reuses the incumbent completion flag, checks executable presence without inspecting authentication, and changes only the declared default adapter plus completion state.", + "Ops read-only aggregate status now has a bounded shared contract; credential writes, live provider refresh, and webhook operations remain with the incumbent owner.", + "About now reports native version and bundle identity, and Sparkle is locally packaged but remains disabled until production signing, appcast, EdDSA, and installed-upgrade gates pass.", + ], + "Prove secure native credential ownership and production updater behavior separately without widening the non-secret settings receipt.", + ), + capability( + "operations.local_status", + "Local operations status", + "Inspect bounded local billing readiness, webhook readiness, and aggregate agent-run evidence without exposing credentials or contacting providers.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Available, + Authority::Read, + &["Native Settings / Ops"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter ops"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core and SQLite", + "Own the fixed time windows, configuration-presence projection, aggregate observability query, secret exclusion, and versioned receipt", + "bundled", + )], + "Local aggregate counts, rates, durations, and configuration-presence booleans for 7, 30, or 90 days. Credentials, webhook URLs, absolute paths, and provider responses never enter the receipt.", + Qualification::Partial, + &[ + "This surface never refreshes live provider billing or sends a webhook.", + "Credential and endpoint writes remain in the incumbent settings surface.", + "Indexed-session success remains an explicitly labelled aggregate proxy because the stored source has no failure signal.", + "Agent and MCP surfaces receive no operations authority.", + ], + "Transfer credential storage and live provider or webhook operations only after a separately reviewed secure-native contract is qualified.", + ), + capability( + "presentation.agent_island", + "Agent Island", + "Configure the optional native agent-status presentation without exposing provider content, credentials, or action authority.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Building, + Authority::ReadExecute, + &["Tauri Agent Island runtime", "Native Agent Island settings"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter settings"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool( + "CodeVetter Rust core", + "Owns the twelve-setting allowlist, validation, SQLite persistence, and helper runtime authority", + "bundled", + ), + tool( + "AppKit and SwiftUI Agent Island helper", + "Owns non-activating presentation and local system speech only", + "bundled", + ), + ], + "Twelve non-secret presentation and speech preferences. Live session snapshots, prompts, output, commands, paths, provider responses, and credentials never enter the settings receipt.", + Qualification::Partial, + &[ + "The feature remains off by default.", + "Native UI, CLI, and the retained helper share the exact persisted preference keys, defaults, and options.", + "The new Evidence Workbench stores configuration only; it does not yet launch the helper or action live agent requests.", + "Agent and MCP surfaces receive no Agent Island authority.", + ], + "Integrate and requalify the supervised helper in the new native host before claiming live runtime parity.", + ), + capability( + "evidence.agent_memories", + "Local agent memories", + "Inspect bounded local agent instruction and memory sources without granting edit, deletion, credential, or agent authority.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &["Tauri Memories", "Native Memories"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter memories"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns source discovery, opaque identity, canonical path admission, redaction, byte and character bounds, and Git diff supervision", + "bundled", + )], + "Explicitly selected local agent memory content. Receipts expose display paths rather than absolute paths and apply line-based secret redaction before content leaves Rust.", + Qualification::Qualified, + &[ + "The source catalog is capped at 128 entries; one document is capped at 512 KiB and 120,000 output characters.", + "Redaction is heuristic, so displayed memory remains private operator data.", + "The native UI and CLI can list, read, search, copy, and inspect a redacted Git diff; neither can edit or delete a source.", + "MCP and agent surfaces receive no memory content or read authority.", + ], + "Preserve the read-only boundary while adding explicit source-format fixtures as new agent tools are supported.", + ), + capability( + "maintenance.session_retention", + "Session archive retention", + "Preview and explicitly maintain CodeVetter indexed session rows without deleting provider transcripts or source sessions.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Usage settings", "Native Usage settings"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter retention"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns policy validation, protected-reference discovery, stable plan identity, fail-closed apply, checkpoint, and VACUUM", + "bundled", + )], + "Local CodeVetter archive and FTS rows only; provider transcripts, source sessions, and protected references are retained.", + Qualification::Qualified, + &[ + "Preview persists a plan receipt but deletes no archive rows.", + "Apply and VACUUM require explicit UI or CLI authority and are intentionally not exposed to agents.", + ], + "Keep destructive maintenance out of agent authority; add separate read-only recovery diagnostics when the history-root transfer is implemented.", + ), + capability( + "configuration.review_rubrics", + "Review rubric packs", + "Keep the exact review standards, active selection, prompt context, and usage attribution consistent across product and agent surfaces.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Rubrics", "Native Rubrics"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter rubrics", "codevetter check"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter check", "codevetter rubrics"], + ), + ), + &[tool( + "CodeVetter Rust core", + "Owns built-in definitions, validation, active selection, custom packs, usage attribution, and exact prompt rendering", + "bundled", + )], + "Non-secret rubric definitions and local review-attribution counts; no provider credentials or review evidence content.", + Qualification::Partial, + &[ + "The incumbent Tauri shell attempts the bounded WebView-local migration on every startup until Rust owns a canonical preference; opening Rubrics also retries and reports sync errors.", + "Built-in packs are immutable; custom packs can be created or replaced within declared bounds.", + ], + "Qualify the startup bridge against an installed upgrade with custom packs before retiring the Tauri rubric owner.", + ), + capability( + "machine.repository_mcp", + "Repository-scoped MCP", + "Expose bounded local history, graph, archaeology, and review-preparation context to agents without granting file-write or provider authority.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Agent MCP", "Native Agent MCP"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter mcp"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter-mcp stdio server"], + ), + ), + &[ + tool( + "CodeVetter MCP server", + "Serves repository-scoped resources and tools over local stdio", + "bundled companion executable", + ), + tool( + "CodeVetter Rust core", + "Owns scope enablement, redaction, limits, audit metadata, and client configuration", + "bundled", + ), + ], + "One explicitly selected, history-indexed local repository; operational audit rows never store arguments, prompts, queries, credentials, or evidence content.", + Qualification::Partial, + &[ + "Enabling requires an existing release-history index.", + "The server uses local stdio only and cannot write files, refresh indexes, call providers, or listen on the network.", + "The native local package gate bundles and smokes codevetter-mcp beside the app; Developer ID and notarized archive proof remain release gates.", + ], + "Add scoped MCP projections for remaining non-local-check receipt families and repeat companion qualification in the notarized production archive.", + ), + capability( + "evidence.tool_collectors", + "External evidence collectors", + "Attach narrowly scoped security and coverage receipts without treating tool presence as proof.", + CapabilityStage::Current, + surfaces( + projection(Availability::Planned, Authority::None, &[]), + projection(Availability::Available, Authority::ReadExecute, &["codevetter collect"]), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool("Gitleaks", "Scans the selected change for secret exposure", "optional local tool"), + tool("cargo-audit", "Checks Rust advisories using available local data", "optional local tool"), + tool("cargo-llvm-cov", "Captures Rust coverage evidence", "optional local tool"), + ], + "Explicit change range in the selected local repository; collectors receive only their declared inputs.", + Qualification::Partial, + &["The native glossary reports declared collectors and limitations but does not execute them.", "Collectors are not installed or network-enabled automatically.", "Unavailable offline data keeps the claim closed."], + "Add bounded collector receipt inspection to the native UI and scoped MCP without granting either surface collector-execution authority.", + ), + capability( + "repository.snapshot_scan", + "Deterministic repository snapshot", + "Create and inspect one local, bounded source, history, health, and topology snapshot without invoking a model.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Repo Unpack", + "Native Repo Unpack (scan + bounded inspectors)", + ], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter unpack --operation scan", "codevetter unpack --operation inspect"], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "MCP graph and history tools over an explicitly enabled stored index", + ], + ), + ), + &[ + tool( + "CodeVetter Rust core", + "Owns the deterministic scan, bounded projection, persistence, and receipt", + "bundled", + ), + tool( + "Git", + "Supplies local revision and bounded history evidence when available", + "optional local tool", + ), + tool( + "rusqlite", + "Persists the canonical local snapshot", + "bundled", + ), + ], + "One explicitly selected local directory and the local SQLite evidence store; the scan does not call a provider or require network access.", + Qualification::Partial, + &[ + "The client receipt omits the raw full-file list while the bounded canonical snapshot remains local.", + "Topology, history, and deterministic health are navigation evidence, not executable verification.", + "Native model synthesis execution and cleanup remain migration gaps.", + ], + "Migrate the remaining synthesis and cleanup workflows while keeping every Rust receipt authoritative.", + ), + capability( + "repository.structural_graph", + "Structural repository graph", + "Navigate source-backed symbols, relationships, impact, and history without presenting topology as runtime proof.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &[ + "Tauri Repo Unpack", + "Native Repo Unpack (bounded snapshot + canonical query desk)", + ], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "codevetter unpack --operation query --query-domain graph --query-mode search|explain|impact|path", + "codevetter-graph", + ], + ), + projection(Availability::Available, Authority::Read, &["graph_query", "graph_impact", "graph_path"]), + ), + &[tool("Tree-sitter", "Extracts syntax-aware source identities across the qualified language set", "bundled")], + "Selected local repository and its local SQLite evidence store.", + Qualification::Qualified, + &[ + "Graph relationships are navigation evidence, not executable verification.", + "Native search, node explanation, impact, and directed path stay bounded and fail closed when the canonical structural index is unavailable.", + "Native retains one read-only search projection per worker, upgrades it in place with compact traversal edges only when required, hydrates bounded result evidence, rechecks live Git freshness and latest snapshot identity on every query, and falls back to the exact supervised one-shot CLI contract when the worker transport is unavailable.", + ], + "Add source-opening and richer graph filtering without moving ranking or traversal semantics into Swift.", + ), + capability( + "repository.history", + "Evidence-backed repository history", + "Explain bounded historical state and lineage using stable evidence identities and explicit gaps.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &[ + "Tauri Repo Unpack", + "Native Repo Unpack (bounded snapshot + canonical query desk)", + ], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "codevetter unpack --operation query --query-domain history --query-mode search|trace", + ], + ), + projection(Availability::Available, Authority::Read, &["history_search", "history_explain", "history_trace"]), + ), + &[tool("Git", "Supplies exact local revision and tag identity", "required local tool")], + "Authorized repository scope and read-only local SQLite evidence.", + Qualification::Qualified, + &[ + "Explanations remain bounded by indexed evidence and disclose missing causal proof.", + "Native history search and causal trace share the canonical Rust index, preserve evidenced versus qualified-lead links, and fail closed when temporal coverage is unavailable.", + "Repeated native history queries reuse the same scoped read-only worker and preserve the exact one-shot CLI fallback.", + ], + "Add native source lineage without duplicating temporal semantics in Swift.", + ), + capability( + "native.evidence_workbench", + "Native Evidence Workbench", + "Provide a fast, accessible macOS operating surface over the canonical Rust verification loop.", + CapabilityStage::Building, + surfaces( + projection(Availability::Building, Authority::ReadExecute, &["apps/macos"]), + projection(Availability::Unavailable, Authority::None, &[]), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool("AppKit", "Owns windows, menus, split views, keyboard behavior, and lifecycle", "Apple platform"), + tool("SwiftUI", "Composes feature, inspector, and settings views", "Apple platform"), + tool("CodeVetter Rust core", "Owns verification execution, persistence, verdicts, and canonical receipts", "bundled CLI and MCP companions"), + tool("Sparkle 2.9.6", "Owns signed update discovery, installation, and relaunch after production configuration", "exact Swift package; disabled in preview"), + tool("XcodeBuildMCP 2.7.0", "Provides reproducible project build and test automation", "development only"), + ], + "User-selected local repositories; no ambient credential authority. Sparkle remains inactive unless a production HTTPS appcast and EdDSA public key are present.", + Qualification::Partial, + &[ + "The native client does not replace Tauri until feature, output, performance, accessibility, visual, installed-upgrade, and owner gates pass.", + "The local package is hardened, non-sandboxed, and ad-hoc signed; Developer ID signing, notarization, production updater inputs, and rollback proof remain open.", + ], + "Close retained feature and owner-interaction gaps, refresh exact-package performance with owner-approved foreground qualification, then qualify a notarized installed upgrade before the owner retirement decision.", + ), + capability( + "evidence.local_runs", + "Verification run ledger", + "Inspect one bounded chronology of local-check, preview, T-Rex PR, synthetic QA, warm, differential, and audience evidence without rewriting originating receipts.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Building, + Authority::Read, + &["Native Runs (building)"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter runs"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool( + "CodeVetter Rust core", + "Owns receipt schemas and persistence", + "bundled", + ), + tool( + "rusqlite", + "Stores complete canonical receipts in the existing local database", + "bundled", + ), + ], + "Local SQLite database; list results are bounded to at most 100 receipts.", + Qualification::Partial, + &[ + "The ledger is read-only; watcher, QA, and audience workflow controls remain on their originating surfaces until those workspaces migrate.", + "The Swift host-render gate excludes window-server frame pacing and interactive scrolling.", + "Foreground XCUITest and owner interaction acceptance remain open.", + ], + "Complete foreground UI automation and owner interaction acceptance, then use the ledger as shared evidence infrastructure for Testing and Performance.", + ), + capability( + "runtime.hardened_isolation", + "Hardened execution isolation", + "Run untrusted project checks with stronger process, filesystem, resource, and network containment.", + CapabilityStage::Future, + surfaces( + projection(Availability::Planned, Authority::None, &[]), + projection(Availability::Planned, Authority::None, &[]), + projection(Availability::Planned, Authority::None, &[]), + ), + &[tool("Apple Containerization or measured alternative", "Candidate containment boundary", "not selected")], + "Not yet defined; no isolation claim is made.", + Qualification::Unqualified, + &["No production isolation backend has passed the runtime and compatibility gates."], + "Benchmark candidates against real CodeVetter workloads before selecting a dependency.", + ), + ], + }; + debug_assert!(validate_registry(®istry).is_ok()); + registry +} + +pub fn capability_registry_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codevetter.com/schemas/capabilities.v1.json", + "title": "CodeVetter capability registry", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "authority", "capabilities"], + "properties": { + "schema_version": {"const": CAPABILITY_SCHEMA_VERSION}, + "authority": {"const": "codevetter-rust-core"}, + "capabilities": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "purpose", "stage", "surfaces", "underlying_tools", "data_boundary", "qualification", "limitations", "next_step"] + } + } + } + }) +} + +pub fn validate_registry(registry: &CapabilityRegistry) -> Result<(), String> { + if registry.schema_version != CAPABILITY_SCHEMA_VERSION { + return Err("Capability registry schema version is invalid".to_string()); + } + let mut ids = HashSet::new(); + for capability in ®istry.capabilities { + if capability.id.trim().is_empty() || !ids.insert(capability.id.as_str()) { + return Err(format!( + "Capability id '{}' is empty or duplicated", + capability.id + )); + } + if capability.name.trim().is_empty() + || capability.purpose.trim().is_empty() + || capability.data_boundary.trim().is_empty() + || capability.next_step.trim().is_empty() + { + return Err(format!("Capability '{}' is incomplete", capability.id)); + } + for projection in [ + &capability.surfaces.ui, + &capability.surfaces.cli, + &capability.surfaces.agent, + ] { + let visible = matches!( + projection.availability, + Availability::Available | Availability::Building + ); + if visible && projection.entrypoints.is_empty() { + return Err(format!( + "Capability '{}' has a visible surface without an entrypoint", + capability.id + )); + } + if !visible && projection.authority != Authority::None { + return Err(format!( + "Capability '{}' grants authority on an unavailable surface", + capability.id + )); + } + } + } + Ok(()) +} + +fn projection( + availability: Availability, + authority: Authority, + entrypoints: &[&str], +) -> SurfaceProjection { + SurfaceProjection { + availability, + authority, + entrypoints: entrypoints + .iter() + .map(|value| (*value).to_string()) + .collect(), + } +} + +fn surfaces( + ui: SurfaceProjection, + cli: SurfaceProjection, + agent: SurfaceProjection, +) -> SurfaceMatrix { + SurfaceMatrix { ui, cli, agent } +} + +fn tool(name: &str, role: &str, requirement: &str) -> UnderlyingTool { + UnderlyingTool { + name: name.to_string(), + role: role.to_string(), + requirement: requirement.to_string(), + } +} + +#[allow(clippy::too_many_arguments)] +fn capability( + id: &str, + name: &str, + purpose: &str, + stage: CapabilityStage, + surfaces: SurfaceMatrix, + underlying_tools: &[UnderlyingTool], + data_boundary: &str, + qualification: Qualification, + limitations: &[&str], + next_step: &str, +) -> Capability { + Capability { + id: id.to_string(), + name: name.to_string(), + purpose: purpose.to_string(), + stage, + surfaces, + underlying_tools: underlying_tools.to_vec(), + data_boundary: data_boundary.to_string(), + qualification, + limitations: limitations + .iter() + .map(|value| (*value).to_string()) + .collect(), + next_step: next_step.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_registry_is_complete_and_duplicate_safe() { + let registry = capability_registry(); + validate_registry(®istry).expect("valid registry"); + assert!(registry + .capabilities + .iter() + .any(|capability| capability.stage == CapabilityStage::Future)); + assert!(registry + .capabilities + .iter() + .all(|capability| !capability.underlying_tools.is_empty())); + } + + #[test] + fn schema_and_payload_share_the_exact_version() { + let schema = capability_registry_schema(); + assert_eq!( + schema["properties"]["schema_version"]["const"], + CAPABILITY_SCHEMA_VERSION + ); + assert_eq!( + capability_registry().schema_version, + CAPABILITY_SCHEMA_VERSION + ); + } + + #[test] + fn external_collectors_keep_surface_authority_explicit() { + let registry = capability_registry(); + let collectors = registry + .capabilities + .iter() + .find(|capability| capability.id == "evidence.tool_collectors") + .expect("external collector capability"); + + assert_eq!(collectors.surfaces.ui.availability, Availability::Planned); + assert_eq!(collectors.surfaces.ui.authority, Authority::None); + assert!(collectors.surfaces.ui.entrypoints.is_empty()); + assert_eq!(collectors.surfaces.cli.authority, Authority::ReadExecute); + assert_eq!(collectors.surfaces.agent.authority, Authority::None); + } +} diff --git a/apps/desktop/src-tauri/src/commands/agent_memories.rs b/apps/desktop/src-tauri/src/commands/agent_memories.rs index cf2ee3b6..b442fce1 100644 --- a/apps/desktop/src-tauri/src/commands/agent_memories.rs +++ b/apps/desktop/src-tauri/src/commands/agent_memories.rs @@ -1,15 +1,17 @@ use serde::Serialize; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::borrow::Cow; -use std::collections::{hash_map::DefaultHasher, HashSet}; +use std::collections::HashSet; use std::env; use std::fs; -use std::hash::{Hash, Hasher}; use std::io::Read; use std::path::{Path, PathBuf}; const MAX_READ_BYTES: u64 = 512 * 1024; const MAX_OUTPUT_CHARS: usize = 120_000; +const MAX_RECEIPT_SOURCES: usize = 128; +pub const MEMORY_RECEIPT_SCHEMA_VERSION: &str = "codevetter.memories/v1"; #[derive(Clone)] struct Candidate { @@ -20,27 +22,89 @@ struct Candidate { note: &'static str, } -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct AgentMemorySource { - id: String, - tool: String, - label: String, - path: String, - exists: bool, - readable: bool, - file_size_bytes: Option, - modified_at: Option, - source_kind: String, - preview: String, - note: String, + pub id: String, + pub tool: String, + pub label: String, + pub path: String, + pub exists: bool, + pub readable: bool, + pub file_size_bytes: Option, + pub modified_at: Option, + pub source_kind: String, + pub preview: String, + pub note: String, } -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct AgentMemoryDocument { - source: AgentMemorySource, - content: String, - truncated: bool, - extraction_note: String, + pub source: AgentMemorySource, + pub content: String, + pub truncated: bool, + pub extraction_note: String, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryReceiptOperation { + List, + Read, + Diff, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct MemorySourceReceipt { + pub id: String, + pub tool: String, + pub label: String, + pub display_path: String, + pub exists: bool, + pub readable: bool, + pub file_size_bytes: Option, + pub modified_at: Option, + pub source_kind: String, + pub preview: String, + pub note: String, +} + +#[derive(Debug, Serialize)] +pub struct MemoryDocumentReceipt { + pub source_id: String, + pub content: String, + pub truncated: bool, + pub extraction_note: String, +} + +#[derive(Debug, Serialize)] +pub struct MemoryDiffReceipt { + pub source_id: String, + pub has_changes: bool, + pub status: String, + pub diff: String, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct MemoryReceiptLimits { + pub max_sources: usize, + pub max_read_bytes: u64, + pub max_output_chars: usize, + pub sources_truncated: bool, +} + +#[derive(Debug, Serialize)] +pub struct MemoryReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: MemoryReceiptOperation, + pub selected_source_id: Option, + pub candidate_locations_checked: usize, + pub sources_total: usize, + pub sources: Vec, + pub document: Option, + pub diff: Option, + pub limits: MemoryReceiptLimits, + pub limitations: Vec, } #[tauri::command] @@ -52,6 +116,135 @@ pub fn list_agent_memory_sources() -> Result, String> { Ok(out) } +pub fn run_memory_receipt( + operation: MemoryReceiptOperation, + source_id: Option<&str>, +) -> Result { + let mut all_sources = list_agent_memory_sources()?; + let candidate_locations_checked = all_sources.len(); + all_sources.retain(|source| source.exists); + all_sources.sort_by(|left, right| { + right + .readable + .cmp(&left.readable) + .then_with(|| right.exists.cmp(&left.exists)) + .then_with(|| left.tool.cmp(&right.tool)) + .then_with(|| left.label.cmp(&right.label)) + .then_with(|| left.path.cmp(&right.path)) + }); + let sources_total = all_sources.len(); + all_sources.truncate(MAX_RECEIPT_SOURCES); + let sources = all_sources + .iter() + .map(memory_source_receipt) + .collect::>(); + + let selected = match operation { + MemoryReceiptOperation::List => { + if source_id.is_some() { + return Err("memory list does not accept a source id".to_string()); + } + None + } + MemoryReceiptOperation::Read | MemoryReceiptOperation::Diff => { + let source_id = source_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "memory read and diff require one source id".to_string())?; + Some( + all_sources + .iter() + .find(|source| source.id == source_id) + .ok_or_else(|| { + "Memory source is unavailable or outside the bounded source catalog." + .to_string() + })?, + ) + } + }; + + let document = if operation == MemoryReceiptOperation::Read { + let selected = selected.expect("read operation has a selected source"); + let document = read_agent_memory_source(selected.path.clone())?; + Some(MemoryDocumentReceipt { + source_id: selected.id.clone(), + content: document.content, + truncated: document.truncated, + extraction_note: document.extraction_note, + }) + } else { + None + }; + let diff = if operation == MemoryReceiptOperation::Diff { + let selected = selected.expect("diff operation has a selected source"); + let diff = get_memory_file_git_diff(selected.path.clone())?; + Some(MemoryDiffReceipt { + source_id: selected.id.clone(), + has_changes: diff.has_changes, + status: diff.status, + diff: diff.diff, + }) + } else { + None + }; + + Ok(MemoryReceipt { + schema_version: MEMORY_RECEIPT_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + operation, + selected_source_id: selected.map(|source| source.id.clone()), + candidate_locations_checked, + sources_total, + sources, + document, + diff, + limits: MemoryReceiptLimits { + max_sources: MAX_RECEIPT_SOURCES, + max_read_bytes: MAX_READ_BYTES, + max_output_chars: MAX_OUTPUT_CHARS, + sources_truncated: sources_total > MAX_RECEIPT_SOURCES, + }, + limitations: vec![ + "This surface is read-only and cannot edit, create, or delete memory sources." + .to_string(), + "Source selection uses an opaque bounded identity; absolute paths are not emitted by this receipt." + .to_string(), + "Secret-like lines are redacted heuristically; operators should still treat displayed memory as private." + .to_string(), + "Agent and MCP projections are unavailable; only the local UI and explicit CLI can read content." + .to_string(), + ], + }) +} + +fn memory_source_receipt(source: &AgentMemorySource) -> MemorySourceReceipt { + MemorySourceReceipt { + id: source.id.clone(), + tool: source.tool.clone(), + label: source.label.clone(), + display_path: receipt_display_path(Path::new(&source.path)), + exists: source.exists, + readable: source.readable, + file_size_bytes: source.file_size_bytes, + modified_at: source.modified_at.clone(), + source_kind: source.source_kind.clone(), + preview: source.preview.clone(), + note: source.note.clone(), + } +} + +fn receipt_display_path(path: &Path) -> String { + let display = display_path(path); + if Path::new(&display).is_absolute() { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("memory-source"); + format!("/{name}") + } else { + display + } +} + #[tauri::command] pub fn read_agent_memory_source(path: String) -> Result { let requested = PathBuf::from(&path); @@ -694,9 +887,10 @@ fn display_path(path: &Path) -> String { } fn stable_id(path: &Path) -> String { - let mut hasher = DefaultHasher::new(); - path.to_string_lossy().hash(&mut hasher); - format!("{:x}", hasher.finish()) + format!( + "memory:sha256:{:x}", + Sha256::digest(path.to_string_lossy().as_bytes()) + ) } fn push_unique_path(paths: &mut Vec, path: PathBuf) { @@ -867,3 +1061,67 @@ fn redact_diff(diff: &str) -> String { .collect::>() .join("\n") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opaque_memory_identity_is_deterministic_and_does_not_embed_the_path() { + let path = Path::new("/private/example/.codex/memories/MEMORY.md"); + let first = stable_id(path); + let second = stable_id(path); + + assert_eq!(first, second); + assert!(first.starts_with("memory:sha256:")); + assert_eq!(first.len(), "memory:sha256:".len() + 64); + assert!(!first.contains(".codex")); + assert_ne!( + first, + stable_id(Path::new("/private/example/.codex/AGENTS.md")) + ); + } + + #[test] + fn receipt_projection_replaces_absolute_paths_with_display_paths() { + let source = AgentMemorySource { + id: "memory:sha256:fixture".to_string(), + tool: "Codex".to_string(), + label: "Codex memory".to_string(), + path: "/private/example/.codex/memories/MEMORY.md".to_string(), + exists: true, + readable: true, + file_size_bytes: Some(42), + modified_at: Some("2026-09-01T00:00:00Z".to_string()), + source_kind: "markdown".to_string(), + preview: "Verification evidence only".to_string(), + note: "Full Markdown memory registry.".to_string(), + }; + + let receipt = memory_source_receipt(&source); + assert_eq!(receipt.id, source.id); + assert_eq!(receipt.display_path, "/MEMORY.md"); + assert!(!serde_json::to_string(&receipt) + .unwrap() + .contains("\"path\"")); + } + + #[test] + fn content_and_diff_redaction_preserve_structure_without_secret_like_lines() { + let content = redact_content( + "# Working memory\nKeep runtime evidence.\napi_key = should-not-appear\nNext step.", + ); + assert!(content.contains("Keep runtime evidence.")); + assert!(content.contains("[redacted secret-like line]")); + assert!(!content.contains("should-not-appear")); + + let diff = redact_diff( + "diff --git a/MEMORY.md b/MEMORY.md\n@@ -1 +1 @@\n-api_key = old\n+api_key = new", + ); + assert!(diff.contains("diff --git")); + assert!(diff.contains("@@ -1 +1 @@")); + assert!(diff.contains("-[redacted secret-like line]")); + assert!(diff.contains("+[redacted secret-like line]")); + assert!(!diff.contains("api_key")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/agent_terminal.rs b/apps/desktop/src-tauri/src/commands/agent_terminal.rs index 54c91aba..9cbe0a40 100644 --- a/apps/desktop/src-tauri/src/commands/agent_terminal.rs +++ b/apps/desktop/src-tauri/src/commands/agent_terminal.rs @@ -570,9 +570,7 @@ fn start_agent_terminal_impl( ) { Ok(result) => return Ok(result), Err(error) => { - eprintln!( - "Codex app-server unavailable for {session_id}; falling back to PTY: {error}" - ); + eprintln!("Codex app-server unavailable; falling back to PTY: {error}"); } } } diff --git a/apps/desktop/src-tauri/src/commands/cross_review.rs b/apps/desktop/src-tauri/src/commands/cross_review.rs new file mode 100644 index 00000000..6a106f5f --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/cross_review.rs @@ -0,0 +1,601 @@ +//! Deterministic reconciliation for independent Claude and Codex review passes. +//! +//! Each provider receives the original immutable target and context. This +//! module never invokes an LLM to merge results: only source-qualified identity +//! (path, resolved line, and source anchor) can correlate findings. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::db::queries::{self, LocalReviewFindingInput, LocalReviewInput, LocalReviewUpdate}; +use crate::DbState; + +use super::review::resolve_agent_cli_path; + +pub const CROSS_REVIEW_SCHEMA: &str = "codevetter.cross-review/v1"; + +pub fn coordinator_policy_binding( + repo_path: &str, + diff_range: &str, + task: &str, + runtime_context: &[Value], +) -> Result { + let canonical = serde_json::to_vec(&json!({ + "schema_version": CROSS_REVIEW_SCHEMA, + "repository": repo_path, + "diff_range": diff_range, + "task": task, + "runtime_context": runtime_context, + })) + .map_err(|error| format!("Could not bind the cross-review policy: {error}"))?; + Ok(format!("{:x}", Sha256::digest(canonical))) +} + +pub fn attach_coordinator_binding(evidence: &mut Value, binding: &str) -> Result<(), String> { + evidence + .as_object_mut() + .ok_or_else(|| "Review pass evidence is not an object".to_string())? + .insert( + "cross_review_policy_binding".into(), + Value::String(binding.into()), + ); + Ok(()) +} + +pub fn missing_executors() -> Vec { + ["claude", "codex"] + .into_iter() + .filter(|agent| !Path::new(&resolve_agent_cli_path(agent)).is_file()) + .map(str::to_string) + .collect() +} + +pub fn reconcile_complete(claude: Value, codex: Value) -> Result { + let claude_target = target_identity(&claude)?; + let codex_target = target_identity(&codex)?; + if claude_target != codex_target { + return Err("Cross-review passes do not bind the same immutable target".into()); + } + let claude_policy = policy_binding(&claude)?; + let codex_policy = policy_binding(&codex)?; + if claude_policy != codex_policy { + return Err("Cross-review passes do not bind the same coordinator policy".into()); + } + let claude_units = unit_plan_identity(&claude)?; + let codex_units = unit_plan_identity(&codex)?; + if claude_units != codex_units { + return Err("Cross-review passes do not cover the same review units".into()); + } + let mut grouped = BTreeMap::>::new(); + let mut unresolved = Vec::new(); + for (reviewer, evidence) in [("claude", &claude), ("codex", &codex)] { + for finding in evidence + .get("findings") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(identity) = finding_identity(finding) { + grouped + .entry(identity) + .or_default() + .push((reviewer, finding.clone())); + } else { + unresolved.push(json!({ + "reviewer": reviewer, + "reason": "missing_source_qualified_identity" + })); + } + } + } + + let mut findings = Vec::new(); + let mut counts = BTreeMap::from([ + ("corroborated", 0_u64), + ("claude_only", 0), + ("codex_only", 0), + ("conflicting", 0), + ( + "rejected", + qualification_total(&claude, "rejected") + qualification_total(&codex, "rejected"), + ), + ( + "stale", + qualification_total(&claude, "stale") + qualification_total(&codex, "stale"), + ), + ( + "unresolved", + qualification_total(&claude, "unresolved") + qualification_total(&codex, "unresolved"), + ), + ]); + for candidates in grouped.into_values() { + let reviewers = candidates + .iter() + .map(|(reviewer, _)| *reviewer) + .collect::>(); + let classification = match reviewers.iter().copied().collect::>().as_slice() { + ["claude"] => "claude_only", + ["codex"] => "codex_only", + _ if severities(&candidates).len() > 1 => "conflicting", + _ => "corroborated", + }; + *counts.entry(classification).or_default() += 1; + let mut selected = candidates + .iter() + .max_by_key(|(_, finding)| finding_rank(finding)) + .map(|(_, finding)| finding.clone()) + .ok_or_else(|| { + "Cross-review reconciliation received an empty finding group".to_string() + })?; + let object = selected + .as_object_mut() + .ok_or_else(|| "Qualified review finding is not an object".to_string())?; + object.insert( + "cross_review_class".into(), + Value::String(classification.into()), + ); + object.insert( + "reviewers".into(), + Value::Array( + reviewers + .into_iter() + .map(|reviewer| Value::String(reviewer.into())) + .collect(), + ), + ); + findings.push(selected); + } + + let claude_ready = review_ready(&claude); + let codex_ready = review_ready(&codex); + if let Some(count) = counts.get_mut("unresolved") { + *count += unresolved.len() as u64; + } + let complete = claude_ready && codex_ready && unresolved.is_empty(); + if !complete { + findings.clear(); + } + let limitations = if complete { + Vec::new() + } else { + vec!["Both independent passes and every source-qualified identity are required".to_string()] + }; + Ok(json!({ + "schema_version": CROSS_REVIEW_SCHEMA, + "strategy": "claude_then_codex_independent", + "status": if complete { "completed" } else { "incomplete" }, + "target_identity": claude_target, + "policy_binding": claude_policy, + "unit_plan_identity": claude_units, + "passes": [pass_summary("claude", &claude), pass_summary("codex", &codex)], + "counts": counts, + "findings": findings, + "unresolved": unresolved, + "limitations": limitations, + "authority": "deterministic_source_qualified_union", + "proof_boundary": "Reviewer agreement is review coverage, never executable proof." + })) +} + +pub fn incomplete_after_pass( + completed_reviewer: Option<(&str, Value)>, + failed_reviewer: &str, + error: &str, +) -> Value { + let passes = completed_reviewer + .map(|(reviewer, evidence)| vec![pass_summary(reviewer, &evidence)]) + .unwrap_or_default(); + json!({ + "schema_version": CROSS_REVIEW_SCHEMA, + "strategy": "claude_then_codex_independent", + "status": "incomplete", + "passes": passes, + "failed_reviewer": failed_reviewer, + "findings": [], + "limitations": [format!("{failed_reviewer} pass did not complete: {}", bounded(error, 320))], + "authority": "deterministic_source_qualified_union", + "proof_boundary": "A partial run cannot produce a composite cross-review claim." + }) +} + +pub fn project_stage_evidence(cross_review: Value) -> Value { + let findings = cross_review + .get("findings") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let complete = cross_review.get("status").and_then(Value::as_str) == Some("completed"); + let limitations = cross_review + .get("limitations") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + json!({ + "agent": "cross", + "review_status": if complete { "completed" } else { "incomplete" }, + "review_readiness": { + "status": if complete { "ready" } else { "incomplete" }, + "complete_coverage": complete, + "limitations": limitations, + }, + "findings_count": findings.as_array().map_or(0, Vec::len), + "findings": findings, + "cross_review": cross_review, + "summary": "Independent Claude and Codex passes reconciled by source-qualified identity." + }) +} + +pub fn persist_composite_review( + db: &DbState, + repo_path: &str, + diff_range: &str, + standards_pack: Option, + evidence: &mut Value, +) -> Result<(), String> { + let status = evidence + .get("review_status") + .and_then(Value::as_str) + .unwrap_or("incomplete") + .to_string(); + let connection = db.0.lock().map_err(|error| error.to_string())?; + let review_id = queries::create_local_review( + &connection, + &LocalReviewInput { + review_type: Some("cross_review".into()), + source_label: Some(format!("cli:cross:{diff_range}")), + repo_path: Some(repo_path.into()), + repo_full_name: None, + pr_number: None, + agent_used: Some("claude+codex".into()), + status: Some(status.clone()), + standards_pack, + }, + ) + .map_err(|error| error.to_string())?; + let findings = evidence + .get_mut("findings") + .and_then(Value::as_array_mut) + .ok_or_else(|| "Cross-review evidence omitted its finding union".to_string())?; + for finding in findings.iter_mut() { + let fingerprint = finding_identity(finding); + let object = finding + .as_object_mut() + .ok_or_else(|| "Cross-review finding is not an object".to_string())?; + let finding_id = queries::insert_review_finding( + &connection, + &LocalReviewFindingInput { + review_id: review_id.clone(), + severity: string(object, "severity").unwrap_or("medium").into(), + title: string(object, "title").unwrap_or("Untitled").into(), + summary: string(object, "summary").unwrap_or("").into(), + suggestion: string(object, "suggestion").map(str::to_string), + file_path: string(object, "filePath").map(str::to_string), + line: object.get("line").and_then(Value::as_i64), + confidence: object.get("confidence").and_then(Value::as_f64), + fingerprint, + discovery_method: Some("independent_cross_review".into()), + }, + ) + .map_err(|error| error.to_string())?; + object.insert("id".into(), Value::String(finding_id)); + } + queries::update_local_review( + &connection, + &review_id, + &LocalReviewUpdate { + findings_count: Some(findings.len() as i64), + summary_markdown: Some( + "Independent Claude then Codex review; source-qualified union only. Agreement does not create executable proof." + .into(), + ), + status: Some(status), + completed_at: Some(chrono::Utc::now().to_rfc3339()), + ..LocalReviewUpdate::default() + }, + ) + .map_err(|error| error.to_string())?; + evidence + .as_object_mut() + .ok_or_else(|| "Cross-review stage evidence is not an object".to_string())? + .insert("review_id".into(), Value::String(review_id)); + Ok(()) +} + +fn target_identity(evidence: &Value) -> Result { + evidence + .pointer("/review_manifest/target/identity") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Review pass omitted its immutable target identity".into()) +} + +fn policy_binding(evidence: &Value) -> Result { + evidence + .get("cross_review_policy_binding") + .and_then(Value::as_str) + .filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .map(str::to_string) + .ok_or_else(|| "Review pass omitted its shared coordinator policy binding".into()) +} + +fn unit_plan_identity(evidence: &Value) -> Result { + let units = evidence + .pointer("/review_manifest/units") + .and_then(Value::as_array) + .ok_or_else(|| "Review pass omitted its bounded unit plan".to_string())?; + let canonical = units + .iter() + .map(|unit| { + json!({ + "file_path": unit.get("file_path"), + "file_status": unit.get("file_status"), + "diff_bytes": unit.get("diff_bytes"), + "prompt_budget_bytes": unit.get("prompt_budget_bytes"), + }) + }) + .collect::>(); + let bytes = serde_json::to_vec(&canonical) + .map_err(|error| format!("Could not bind the review unit plan: {error}"))?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +fn finding_identity(finding: &Value) -> Option { + let path = finding.get("filePath").and_then(Value::as_str)?.trim(); + let line = finding.get("line").and_then(Value::as_i64)?; + let anchor = finding.get("sourceAnchor").and_then(Value::as_str)?.trim(); + (!path.is_empty() && line > 0 && !anchor.is_empty()) + .then(|| format!("{path}\0{line}\0{anchor}")) +} + +fn severities(candidates: &[(&str, Value)]) -> BTreeSet { + candidates + .iter() + .filter_map(|(_, finding)| finding.get("severity").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + +fn finding_rank(finding: &Value) -> (u8, u64) { + let severity = match finding.get("severity").and_then(Value::as_str) { + Some("critical") => 4, + Some("high") => 3, + Some("medium") => 2, + Some("low") => 1, + _ => 0, + }; + let confidence = finding + .get("confidence") + .and_then(Value::as_f64) + .map_or(0, |value| (value.clamp(0.0, 1.0) * 1_000_000.0) as u64); + (severity, confidence) +} + +fn review_ready(evidence: &Value) -> bool { + evidence + .pointer("/review_readiness/status") + .and_then(Value::as_str) + == Some("ready") + && evidence.get("review_status").and_then(Value::as_str) == Some("completed") +} + +fn qualification_total(evidence: &Value, state: &str) -> u64 { + evidence + .pointer(&format!("/review_manifest/qualification_counts/{state}")) + .and_then(Value::as_u64) + .unwrap_or(0) +} + +fn pass_summary(reviewer: &str, evidence: &Value) -> Value { + json!({ + "reviewer": reviewer, + "status": evidence.get("review_status").cloned().unwrap_or(Value::String("incomplete".into())), + "review_id": evidence.get("review_id").cloned().unwrap_or(Value::Null), + "duration_ms": evidence.get("duration_ms").cloned().unwrap_or(Value::Null), + "findings_count": evidence.get("findings_count").cloned().unwrap_or(Value::from(0)), + "qualified_findings": evidence.get("findings").cloned().unwrap_or_else(|| Value::Array(Vec::new())), + "review_readiness": evidence.get("review_readiness").cloned().unwrap_or(Value::Null), + "review_manifest": evidence.get("review_manifest").cloned().unwrap_or(Value::Null), + "usage": evidence.get("usage").cloned().unwrap_or(Value::Null), + "raw_candidate_access": "not_exposed_by_review_contract; qualification diagnostics remain in review_manifest", + }) +} + +fn string<'a>(object: &'a serde_json::Map, key: &str) -> Option<&'a str> { + object.get(key).and_then(Value::as_str) +} + +fn bounded(value: &str, max: usize) -> String { + value.chars().take(max).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pass(reviewer: &str, findings: Value) -> Value { + json!({ + "cross_review_policy_binding": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "review_id": format!("{reviewer}-review"), + "review_status": "completed", + "review_readiness": {"status": "ready"}, + "findings": findings, + "findings_count": findings.as_array().map_or(0, Vec::len), + "duration_ms": 25, + "review_manifest": { + "target": {"identity": "immutable-target"}, + "executor_id": reviewer, + "policy_fingerprint": format!("{reviewer}-policy"), + "units": [{ + "file_path": "src/a.rs", + "file_status": "M", + "diff_bytes": 128, + "prompt_budget_bytes": 81920 + }] + } + }) + } + + fn finding(severity: &str, title: &str, path: &str, line: i64, anchor: &str) -> Value { + json!({ + "severity": severity, + "title": title, + "summary": format!("{title} evidence"), + "filePath": path, + "line": line, + "sourceAnchor": anchor, + "confidence": 0.9 + }) + } + + #[test] + fn source_identity_reconciles_corroborated_unique_and_conflicting_findings() { + let shared = finding("high", "Claude title", "src/a.rs", 8, "danger();"); + let mut codex_shared = shared.clone(); + codex_shared["title"] = Value::String("Different title, same exact source".into()); + let conflicting = finding("medium", "Claude severity", "src/b.rs", 9, "other();"); + let mut codex_conflicting = conflicting.clone(); + codex_conflicting["severity"] = Value::String("critical".into()); + let receipt = reconcile_complete( + pass( + "claude", + json!([ + shared, + conflicting, + finding("low", "Claude only", "src/c.rs", 2, "c();") + ]), + ), + pass( + "codex", + json!([ + codex_shared, + codex_conflicting, + finding("high", "Codex only", "src/d.rs", 3, "d();") + ]), + ), + ) + .expect("cross review"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["counts"]["corroborated"], 1); + assert_eq!(receipt["counts"]["conflicting"], 1); + assert_eq!(receipt["counts"]["claude_only"], 1); + assert_eq!(receipt["counts"]["codex_only"], 1); + assert_eq!(receipt["findings"].as_array().map(Vec::len), Some(4)); + assert_eq!( + receipt["findings"] + .as_array() + .and_then(|items| items.iter().find(|item| item["filePath"] == "src/b.rs")) + .map(|item| &item["severity"]), + Some(&Value::String("critical".into())) + ); + } + + #[test] + fn title_similarity_never_merges_different_source_locations() { + let receipt = reconcile_complete( + pass( + "claude", + json!([finding("high", "Same title", "src/a.rs", 1, "a();")]), + ), + pass( + "codex", + json!([finding("high", "Same title", "src/b.rs", 1, "b();")]), + ), + ) + .expect("cross review"); + assert_eq!(receipt["findings"].as_array().map(Vec::len), Some(2)); + } + + #[test] + fn missing_anchor_and_partial_execution_fail_closed() { + let receipt = reconcile_complete( + pass( + "claude", + json!([{"severity":"high","title":"x","summary":"x"}]), + ), + pass("codex", json!([])), + ) + .expect("cross review"); + assert_eq!(receipt["status"], "incomplete"); + assert!(receipt["findings"].as_array().is_some_and(Vec::is_empty)); + assert_eq!( + receipt["passes"][0]["qualified_findings"] + .as_array() + .map(Vec::len), + Some(1) + ); + assert_eq!(receipt["passes"][0]["review_readiness"]["status"], "ready"); + + let partial = incomplete_after_pass( + Some(("claude", pass("claude", json!([])))), + "codex", + "cancelled", + ); + assert_eq!(partial["status"], "incomplete"); + assert_eq!(partial["passes"].as_array().map(Vec::len), Some(1)); + assert!(partial["findings"].as_array().is_some_and(Vec::is_empty)); + } + + #[test] + fn different_targets_never_form_a_composite() { + let claude = pass("claude", json!([])); + let mut codex = pass("codex", json!([])); + codex["review_manifest"]["target"]["identity"] = Value::String("other".into()); + assert!(reconcile_complete(claude, codex).is_err()); + } + + #[test] + fn different_coordinator_policy_or_unit_plan_never_forms_a_composite() { + let claude = pass("claude", json!([])); + let mut codex = pass("codex", json!([])); + codex["cross_review_policy_binding"] = Value::String("b".repeat(64)); + assert!(reconcile_complete(claude.clone(), codex).is_err()); + + let mut different_units = pass("codex", json!([])); + different_units["review_manifest"]["units"][0]["diff_bytes"] = Value::from(129); + assert!(reconcile_complete(claude, different_units).is_err()); + } + + #[test] + fn composite_and_qualified_union_persist_under_one_review_identity() { + let connection = rusqlite::Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let db = DbState(std::sync::Arc::new(std::sync::Mutex::new(connection))); + let receipt = reconcile_complete( + pass( + "claude", + json!([finding( + "high", + "Shared finding", + "src/a.rs", + 8, + "danger();" + )]), + ), + pass( + "codex", + json!([finding( + "high", + "Shared finding", + "src/a.rs", + 8, + "danger();" + )]), + ), + ) + .expect("cross review"); + let mut evidence = project_stage_evidence(receipt); + persist_composite_review(&db, "/fixture/repo", "main...HEAD", None, &mut evidence) + .expect("persist composite"); + + let review_id = evidence["review_id"].as_str().expect("review id"); + let connection = db.0.lock().expect("database lock"); + let (review, findings) = + queries::get_local_review_with_findings(&connection, review_id).expect("stored review"); + assert_eq!(review.review_type.as_deref(), Some("cross_review")); + assert_eq!(review.agent_used, "claude+codex"); + assert_eq!(review.findings_count, Some(1)); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file_path.as_deref(), Some("src/a.rs")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/deterministic_review.rs b/apps/desktop/src-tauri/src/commands/deterministic_review.rs index 445d9f9c..61ba5966 100644 --- a/apps/desktop/src-tauri/src/commands/deterministic_review.rs +++ b/apps/desktop/src-tauri/src/commands/deterministic_review.rs @@ -1637,7 +1637,8 @@ mod tests { #[test] fn recorded_benchmark_never_emits_an_invalid_position_after_qualification() { - let benchmark = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../benchmark"); + let benchmark = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../benchmarks/public-catch-rate"); let raw_dir = benchmark.join("reviews-raw"); let mut raw_candidates = 0usize; let mut qualified_candidates = 0usize; @@ -1683,11 +1684,11 @@ mod tests { qualified_candidates += qualified.findings.len(); } assert!( - raw_candidates >= 29, + raw_candidates >= 27, "recorded corpus is unexpectedly small" ); assert!( - qualified_candidates >= 29, + qualified_candidates >= 27, "qualification removed too much evidence" ); } diff --git a/apps/desktop/src-tauri/src/commands/evidence_scope.rs b/apps/desktop/src-tauri/src/commands/evidence_scope.rs index 62d7221a..6645e9de 100644 --- a/apps/desktop/src-tauri/src/commands/evidence_scope.rs +++ b/apps/desktop/src-tauri/src/commands/evidence_scope.rs @@ -38,7 +38,7 @@ pub enum EvidenceScopeConsumer { Performance, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EvidenceScopeInput { pub repo_path: String, pub kind: EvidenceScopeKind, @@ -657,20 +657,24 @@ mod tests { use super::*; use std::process::Command as StdCommand; + const SURFACE_PARITY_FIXTURE: &str = + include_str!("../../tests/fixtures/surface-parity/evidence-scope-v1.json"); + + fn surface_parity_fixture() -> serde_json::Value { + serde_json::from_str(SURFACE_PARITY_FIXTURE).expect("surface parity fixture") + } + fn fixture_repository() -> tempfile::TempDir { + let fixture = surface_parity_fixture(); let repo = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(repo.path().join("src/cart")).unwrap(); - std::fs::write(repo.path().join("vitest.config.ts"), "export default {};\n").unwrap(); - std::fs::write( - repo.path().join("src/cart/coupon.ts"), - "export const couponTotal = (value: number) => value;\n", - ) - .unwrap(); - std::fs::write( - repo.path().join("src/cart/coupon.test.ts"), - "import { couponTotal } from './coupon';\ntest('coupon total', () => couponTotal(2));\n", - ) - .unwrap(); + for (relative_path, content) in fixture["repository"]["files"] + .as_object() + .expect("fixture files") + { + let path = repo.path().join(relative_path); + std::fs::create_dir_all(path.parent().expect("fixture file parent")).unwrap(); + std::fs::write(path, content.as_str().expect("fixture file content")).unwrap(); + } for args in [ vec!["init", "-q"], vec!["add", "."], @@ -812,6 +816,62 @@ mod tests { assert!(portfolio.limitations[0].contains("bounded")); } + #[tokio::test] + async fn authoritative_resolver_matches_the_shared_surface_parity_fixture() { + let fixture = surface_parity_fixture(); + let request = &fixture["request"]; + let expected = &fixture["expected"]; + let repo = fixture_repository(); + let plan = resolve(EvidenceScopeInput { + repo_path: repo.path().to_string_lossy().into_owned(), + kind: serde_json::from_value(request["kind"].clone()).expect("fixture kind"), + value: request["value"].as_str().map(str::to_string), + consumer: serde_json::from_value(request["consumer"].clone()) + .expect("fixture consumer"), + }) + .await + .expect("surface parity plan"); + + assert_eq!(plan.schema_version, expected["schema_version"]); + assert_eq!(plan.status, expected["status"]); + assert_eq!(plan.candidates.len(), expected["candidate_count"]); + let candidate = plan.candidates.first().expect("fixture candidate"); + let expected_candidate = &expected["first_candidate"]; + assert_eq!(candidate.id, expected_candidate["id"]); + assert_eq!(candidate.adapter, expected_candidate["adapter"]); + assert_eq!(candidate.target, expected_candidate["target"]); + assert_eq!( + candidate.confidence_milli, + expected_candidate["confidence_milli"] + ); + assert_eq!( + candidate.testing_supported, + expected_candidate["testing_supported"] + ); + assert_eq!( + candidate.performance_supported, + expected_candidate["performance_supported"] + ); + assert!(plan + .limitations + .iter() + .any(|limitation| limitation.contains( + expected["limitation_contains"] + .as_str() + .expect("fixture limitation") + ))); + + let canonical: EvidenceScopePlan = + serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("canonical fixture receipt"); + assert_eq!(canonical.schema_version, plan.schema_version); + assert_eq!(canonical.kind, plan.kind); + assert_eq!(canonical.consumer, plan.consumer); + assert_eq!(canonical.status, plan.status); + assert_eq!(canonical.candidates[0].id, candidate.id); + assert_eq!(canonical.candidates[0].target, candidate.target); + } + #[tokio::test] async fn generic_flow_words_fail_closed() { let repo = fixture_repository(); diff --git a/apps/desktop/src-tauri/src/commands/fix_attempt.rs b/apps/desktop/src-tauri/src/commands/fix_attempt.rs new file mode 100644 index 00000000..c947eed8 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/fix_attempt.rs @@ -0,0 +1,1442 @@ +//! Explicit, isolated agent-fix execution followed by executable and review rechecks. +//! +//! A fix attempt never edits, commits, or merges into the selected checkout. It +//! materializes the exact source receipt head as a detached Git worktree under +//! CodeVetter's app-data directory, lets one explicitly confirmed coding agent +//! edit there, reruns the recorded correctness target, and reviews only the +//! resulting worktree diff. The retained worktree remains owner-inspectable +//! until a separately confirmed discard operation removes it. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command as StdCommand, Stdio}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command as TokioCommand; + +use crate::{db, DbState}; + +use super::fix_packet::{ + build_agent_fix_packet, load_local_check_receipt, AgentFixPacketReceipt, FixPacketFinding, +}; +use super::local_check::{ + rerun_fix_correctness_target, LocalCheckReceipt, LocalCheckStage, LocalCheckStatus, +}; +use super::review::{resolve_cli_path, run_cli_review_core}; + +const SCHEMA_VERSION: &str = "codevetter.fix-attempt/v1"; +const MAX_AGENT_OUTPUT_BYTES: usize = 512 * 1024; +const MAX_DIFF_BYTES: usize = 1024 * 1024; +const MAX_DIFF_PREVIEW_BYTES: usize = 128 * 1024; +const MAX_CHANGED_FILES: usize = 100; +const MAX_FINDINGS: usize = 100; +const AGENT_DEADLINE: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptInput { + pub run_id: String, + pub finding_ids: Vec, + pub agent: String, + pub confirmed: bool, + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DiscardFixAttemptInput { + pub attempt_id: String, + pub confirmed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptReceipt { + pub schema_version: String, + pub attempt_id: String, + pub operation: String, + pub state: String, + pub source_run_id: String, + pub repository_path: String, + pub source: FixAttemptSource, + pub worktree: FixAttemptWorktree, + pub agent: FixAttemptAgent, + pub change: FixAttemptChange, + pub recheck: FixAttemptRecheck, + pub limitations: Vec, + pub started_at: String, + pub completed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptSource { + pub input: String, + pub base_sha: String, + pub head_sha: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptWorktree { + pub path: String, + pub detached: bool, + pub retained: bool, + pub source_head_sha: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptAgent { + pub id: String, + pub status: String, + pub duration_ms: u64, + pub diagnostic: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptChange { + pub changed_files: Vec, + pub diff_sha256: Option, + pub diff_bytes: usize, + pub diff_preview: String, + pub preview_truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptRecheck { + pub diff_check: FixAttemptGate, + pub correctness: FixAttemptCorrectness, + pub review: FixAttemptReview, + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptGate { + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptCorrectness { + pub status: String, + pub target: Option, + pub duration_ms: u64, + pub evidence: Value, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptReview { + pub status: String, + pub review_id: Option, + pub summary: Option, + pub findings: Vec, + pub limitation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixFindingRecheck { + pub finding_id: String, + pub status: String, + pub reason: String, +} + +struct AgentExecution { + success: bool, + duration_ms: u64, + diagnostic: Option, +} + +pub async fn execute_fix_attempt( + app_data_dir: PathBuf, + input: FixAttemptInput, +) -> Result { + validate_execute_input(&input)?; + let connection = db::init_db(app_data_dir.clone()) + .map_err(|error| format!("Open CodeVetter database: {error}"))?; + let packet = build_agent_fix_packet(&connection, &input.run_id, &input.finding_ids)?; + let source_receipt = load_local_check_receipt(&connection, &input.run_id)?; + drop(connection); + validate_packet_source(&packet, &source_receipt)?; + + let repository = canonical_git_repository(Path::new(&packet.repo_path))?; + require_commit(&repository, &packet.source.head_sha)?; + let attempt_id = format!("fix-attempt-{}", uuid::Uuid::new_v4().simple()); + let attempt_root = attempt_root(&app_data_dir, &attempt_id)?; + let worktree_path = attempt_root.join("worktree"); + create_detached_worktree(&repository, &worktree_path, &packet.source.head_sha)?; + + let started_at = chrono::Utc::now().to_rfc3339(); + let mut execution = + run_fix_agent(&input.agent, &worktree_path, &render_agent_prompt(&packet)).await; + if !execution.success { + let receipt = terminal_receipt( + &attempt_id, + "failed", + &input, + &packet, + &repository, + &worktree_path, + execution, + empty_change(), + unchecked_recheck(&packet, "The coding agent did not complete successfully."), + vec![ + "The isolated worktree is retained for inspection; CodeVetter did not merge or commit any change." + .into(), + ], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + + match exact_worktree_head(&worktree_path) { + Ok(head) if head == packet.source.head_sha => {} + Ok(head) => { + execution.success = false; + execution.diagnostic = Some(format!( + "The coding agent changed Git HEAD from {} to {head}; commits and branch movement are outside the fix-attempt contract", + packet.source.head_sha + )); + let receipt = terminal_receipt( + &attempt_id, + "failed", + &input, + &packet, + &repository, + &worktree_path, + execution, + empty_change(), + unchecked_recheck( + &packet, + "Rechecks were blocked because the coding agent changed Git history.", + ), + vec![ + "The isolated worktree is retained for inspection; CodeVetter did not merge or push the unsupported commit." + .into(), + ], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + Err(error) => { + execution.success = false; + execution.diagnostic = Some(error.clone()); + let receipt = terminal_receipt( + &attempt_id, + "failed", + &input, + &packet, + &repository, + &worktree_path, + execution, + empty_change(), + unchecked_recheck( + &packet, + "Rechecks were blocked because Git HEAD was unreadable.", + ), + vec![error], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + } + + expose_untracked_diff(&worktree_path)?; + let change = collect_change(&worktree_path)?; + if change.changed_files.is_empty() || change.diff_bytes == 0 { + let receipt = terminal_receipt( + &attempt_id, + "no_changes", + &input, + &packet, + &repository, + &worktree_path, + execution, + change, + unchecked_recheck(&packet, "The coding agent produced no inspectable worktree diff."), + vec![ + "No source change was produced, so CodeVetter issued no fixed finding or correctness claim." + .into(), + "The isolated worktree is retained for inspection; CodeVetter did not merge or commit any change." + .into(), + ], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + + let diff_check = run_diff_check(&worktree_path); + let correctness = rerun_fix_correctness_target( + &worktree_path, + source_receipt.stages.correctness.target.clone(), + input.timeout_ms, + ) + .await; + let correctness_projection = project_correctness(&correctness); + let review = if diff_check.status == "passed" { + run_fix_review( + &app_data_dir, + &worktree_path, + &input.agent, + &packet, + &source_receipt, + &correctness, + ) + .await + } else { + FixAttemptReview { + status: "unchecked".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some("Review was skipped because git diff --check failed.".into()), + } + }; + let finding_rechecks = classify_findings(&packet.findings, &review, &correctness_projection); + let state = classify_attempt_state( + &diff_check, + &correctness_projection, + &review, + &finding_rechecks, + ); + let mut limitations = vec![ + "The isolated worktree is retained for owner inspection; CodeVetter did not commit, merge, push, or modify the selected checkout." + .into(), + "A fixed status is bounded to the recorded correctness target and source-qualified re-review; it is not a general proof of the repository." + .into(), + ]; + if correctness_projection.status == "no_confidence" { + limitations.push( + "The source verification receipt had no runnable correctness target, or its recheck produced no executable confidence." + .into(), + ); + } + if let Some(limitation) = review.limitation.clone() { + limitations.push(limitation); + } + let receipt = terminal_receipt( + &attempt_id, + &state, + &input, + &packet, + &repository, + &worktree_path, + execution, + change, + FixAttemptRecheck { + diff_check, + correctness: correctness_projection, + review, + findings: finding_rechecks, + }, + limitations, + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + Ok(receipt) +} + +pub fn inspect_fix_attempt( + app_data_dir: &Path, + attempt_id: &str, +) -> Result { + let root = attempt_root(app_data_dir, attempt_id)?; + let bytes = fs::read(root.join("receipt.json")) + .map_err(|error| format!("Read fix-attempt receipt: {error}"))?; + let receipt: FixAttemptReceipt = serde_json::from_slice(&bytes) + .map_err(|error| format!("Decode fix-attempt receipt: {error}"))?; + if receipt.schema_version != SCHEMA_VERSION || receipt.attempt_id != attempt_id { + return Err("The fix-attempt receipt identity is invalid".into()); + } + let expected_worktree = root.join("worktree"); + if Path::new(&receipt.worktree.path) != expected_worktree { + return Err("The fix-attempt worktree escaped its app-data scope".into()); + } + Ok(receipt) +} + +pub fn discard_fix_attempt( + app_data_dir: &Path, + input: DiscardFixAttemptInput, +) -> Result { + if !input.confirmed { + return Err("Discard requires explicit confirmation because unmerged worktree changes will be removed".into()); + } + let mut receipt = inspect_fix_attempt(app_data_dir, &input.attempt_id)?; + if !receipt.worktree.retained { + return Ok(receipt); + } + let repository = canonical_git_repository(Path::new(&receipt.repository_path))?; + let worktree = PathBuf::from(&receipt.worktree.path); + let output = StdCommand::new("git") + .args(["worktree", "remove", "--force"]) + .arg(&worktree) + .current_dir(&repository) + .output() + .map_err(|error| format!("Start git worktree remove: {error}"))?; + if !output.status.success() { + return Err(format!( + "Discard isolated worktree: {}", + bounded_diagnostic(&output.stderr, 4_096) + )); + } + let _ = StdCommand::new("git") + .args(["worktree", "prune"]) + .current_dir(&repository) + .output(); + receipt.operation = "discard".into(); + receipt.state = "discarded".into(); + receipt.worktree.retained = false; + receipt.completed_at = chrono::Utc::now().to_rfc3339(); + receipt.limitations.push( + "The separately confirmed discard removed the isolated unmerged worktree; the source checkout was not modified." + .into(), + ); + persist_receipt(&attempt_root(app_data_dir, &input.attempt_id)?, &receipt)?; + Ok(receipt) +} + +fn validate_execute_input(input: &FixAttemptInput) -> Result<(), String> { + validate_identity(&input.run_id, "run id")?; + if input.finding_ids.is_empty() || input.finding_ids.len() > MAX_FINDINGS { + return Err(format!("Select between 1 and {MAX_FINDINGS} findings")); + } + let unique = input.finding_ids.iter().collect::>(); + if unique.len() != input.finding_ids.len() { + return Err("Finding selection contains duplicate identities".into()); + } + for finding_id in &input.finding_ids { + validate_identity(finding_id, "finding id")?; + } + if !matches!(input.agent.as_str(), "claude" | "gemini" | "codex") { + return Err("Fix agent must be `claude`, `gemini`, or `codex`".into()); + } + if !(100..=120_000).contains(&input.timeout_ms) { + return Err("Correctness timeout must be between 100 and 120,000 milliseconds".into()); + } + if !input.confirmed { + return Err( + "Fix execution requires explicit confirmation because it invokes an agent and edits an isolated worktree" + .into(), + ); + } + Ok(()) +} + +fn validate_packet_source( + packet: &AgentFixPacketReceipt, + receipt: &LocalCheckReceipt, +) -> Result<(), String> { + if packet.run_id != receipt.run_id + || packet.repo_path != receipt.repo_path + || packet.source.input != receipt.source.input + || packet.source.base_sha != receipt.source.base_sha + || packet.source.head_sha != receipt.source.head_sha + { + return Err("The fix packet drifted from its persisted local-check source identity".into()); + } + Ok(()) +} + +fn canonical_git_repository(path: &Path) -> Result { + let canonical = fs::canonicalize(path) + .map_err(|error| format!("Repository {} is unavailable: {error}", path.display()))?; + let output = StdCommand::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(&canonical) + .output() + .map_err(|error| format!("Inspect repository: {error}"))?; + if !output.status.success() { + return Err("Fix execution requires a readable Git repository".into()); + } + let top = fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim()) + .map_err(|_| "The Git repository root is unavailable".to_string())?; + if top != canonical { + return Err("Fix execution requires the exact Git repository root".into()); + } + Ok(canonical) +} + +fn require_commit(repository: &Path, sha: &str) -> Result<(), String> { + if !valid_sha(sha) { + return Err("The source receipt head is not an exact Git SHA".into()); + } + let output = StdCommand::new("git") + .args(["cat-file", "-e", &format!("{sha}^{{commit}}")]) + .current_dir(repository) + .output() + .map_err(|error| format!("Inspect source commit: {error}"))?; + if !output.status.success() { + return Err("The exact source receipt head is no longer available locally".into()); + } + Ok(()) +} + +fn create_detached_worktree(repository: &Path, worktree: &Path, sha: &str) -> Result<(), String> { + if worktree.exists() { + return Err("The generated fix-attempt worktree already exists".into()); + } + let parent = worktree + .parent() + .ok_or_else(|| "The fix-attempt directory is invalid".to_string())?; + fs::create_dir_all(parent).map_err(|error| format!("Create fix-attempt directory: {error}"))?; + let output = StdCommand::new("git") + .args(["worktree", "add", "--detach"]) + .arg(worktree) + .arg(sha) + .current_dir(repository) + .output() + .map_err(|error| format!("Create isolated worktree: {error}"))?; + if !output.status.success() { + return Err(format!( + "Create isolated worktree: {}", + bounded_diagnostic(&output.stderr, 4_096) + )); + } + Ok(()) +} + +async fn run_fix_agent(agent: &str, worktree: &Path, prompt: &str) -> AgentExecution { + let cli_path = resolve_cli_path(agent); + if cli_path == agent { + return AgentExecution { + success: false, + duration_ms: 0, + diagnostic: Some(format!("Coding agent `{agent}` is unavailable")), + }; + } + run_fix_agent_at(agent, Path::new(&cli_path), worktree, prompt).await +} + +async fn run_fix_agent_at( + agent: &str, + cli_path: &Path, + worktree: &Path, + prompt: &str, +) -> AgentExecution { + let started = Instant::now(); + let mut command = TokioCommand::new(cli_path); + command + .args(fix_agent_arguments(agent, prompt)) + .current_dir(worktree) + .env("CODEVETTER_FIX_ATTEMPT", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "push.default") + .env("GIT_CONFIG_VALUE_0", "nothing") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + return AgentExecution { + success: false, + duration_ms: elapsed_ms(started), + diagnostic: Some(format!("Start coding agent `{agent}`: {error}")), + }; + } + }; + let pid = child.id(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_task = stdout.map(|stream| tokio::spawn(read_bounded(stream))); + let stderr_task = stderr.map(|stream| tokio::spawn(read_bounded(stream))); + let status = match tokio::time::timeout(AGENT_DEADLINE, child.wait()).await { + Ok(Ok(status)) => Some(status), + Ok(Err(error)) => { + return AgentExecution { + success: false, + duration_ms: elapsed_ms(started), + diagnostic: Some(format!("Wait for coding agent `{agent}`: {error}")), + }; + } + Err(_) => { + #[cfg(unix)] + if let Some(pid) = pid { + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + } + let _ = child.kill().await; + let _ = child.wait().await; + if let Some(task) = stdout_task { + task.abort(); + } + if let Some(task) = stderr_task { + task.abort(); + } + return AgentExecution { + success: false, + duration_ms: elapsed_ms(started), + diagnostic: Some("Coding agent exceeded the 30 minute deadline".into()), + }; + } + }; + let stdout = join_output(stdout_task).await; + let stderr = join_output(stderr_task).await; + let status = status.expect("completed process has a status"); + AgentExecution { + success: status.success(), + duration_ms: elapsed_ms(started), + diagnostic: if status.success() { + None + } else { + Some(agent_failure_detail(&stdout, &stderr, status.code())) + }, + } +} + +fn fix_agent_arguments(agent: &str, prompt: &str) -> Vec { + match agent { + "claude" => [ + "--setting-sources", + "user", + "--permission-mode", + "acceptEdits", + "--no-session-persistence", + "--no-chrome", + "--strict-mcp-config", + "--mcp-config", + "{\"mcpServers\":{}}", + "-p", + prompt, + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + "codex" => [ + "exec", + "--ephemeral", + "--ignore-user-config", + "-c", + "model_reasoning_effort=\"medium\"", + "--sandbox", + "workspace-write", + "--color", + "never", + prompt, + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + _ => [ + "--sandbox", + "--approval-mode", + "auto_edit", + "--extensions", + "none", + "-p", + prompt, + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + } +} + +fn render_agent_prompt(packet: &AgentFixPacketReceipt) -> String { + format!( + "You are executing a bounded CodeVetter fix attempt in an isolated detached Git worktree. Edit the actual source files in this worktree; do not commit, create branches, merge, push, or modify another checkout. Keep the patch minimal, obey repository instructions, and preserve the recorded evidence contract. Do not claim completion merely by describing a fix.\n\n{}", + packet.markdown + ) +} + +async fn read_bounded(mut stream: R) -> Result, String> { + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .await + .map_err(|error| format!("Read coding-agent output: {error}"))?; + if bytes.len() > MAX_AGENT_OUTPUT_BYTES { + return Err(format!( + "Coding-agent output exceeded {MAX_AGENT_OUTPUT_BYTES} bytes" + )); + } + Ok(bytes) +} + +async fn join_output(task: Option, String>>>) -> Vec { + match task { + Some(task) => task.await.ok().and_then(Result::ok).unwrap_or_default(), + None => Vec::new(), + } +} + +fn agent_failure_detail(stdout: &[u8], stderr: &[u8], code: Option) -> String { + let stderr = bounded_diagnostic(stderr, 2_048); + let stdout = bounded_diagnostic(stdout, 2_048); + let detail = match (stderr.is_empty(), stdout.is_empty()) { + (false, false) => format!("stderr: {stderr}; stdout: {stdout}"), + (false, true) => stderr, + (true, false) => stdout, + (true, true) => "Coding agent returned no diagnostic output".into(), + }; + format!("Coding agent failed with exit {code:?}: {detail}") +} + +fn expose_untracked_diff(worktree: &Path) -> Result<(), String> { + let output = StdCommand::new("git") + .args(["add", "--intent-to-add", "--all"]) + .current_dir(worktree) + .output() + .map_err(|error| format!("Prepare isolated diff: {error}"))?; + if !output.status.success() { + return Err(format!( + "Prepare isolated diff: {}", + bounded_diagnostic(&output.stderr, 4_096) + )); + } + Ok(()) +} + +fn exact_worktree_head(worktree: &Path) -> Result { + let output = git_output(worktree, &["rev-parse", "HEAD"], 1_024)?; + let head = String::from_utf8(output) + .map_err(|_| "The isolated worktree HEAD is not UTF-8".to_string())? + .trim() + .to_string(); + if !valid_sha(&head) { + return Err("The isolated worktree no longer has an exact Git HEAD".into()); + } + Ok(head) +} + +fn collect_change(worktree: &Path) -> Result { + let names = git_output( + worktree, + &["diff", "--name-only", "-z", "HEAD"], + MAX_DIFF_BYTES, + )?; + let changed_files = names + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .map(|path| String::from_utf8(path.to_vec()).map_err(|_| "Changed path is not UTF-8")) + .collect::, _>>()?; + if changed_files.len() > MAX_CHANGED_FILES { + return Err(format!( + "The fix attempt changed more than {MAX_CHANGED_FILES} files" + )); + } + for path in &changed_files { + validate_relative_path(path)?; + } + let diff = git_output(worktree, &["diff", "--binary", "HEAD"], MAX_DIFF_BYTES)?; + let diff_sha256 = (!diff.is_empty()).then(|| format!("sha256:{:x}", Sha256::digest(&diff))); + let preview_bytes = diff.len().min(MAX_DIFF_PREVIEW_BYTES); + let mut preview_end = preview_bytes; + while preview_end > 0 && std::str::from_utf8(&diff[..preview_end]).is_err() { + preview_end -= 1; + } + Ok(FixAttemptChange { + changed_files, + diff_sha256, + diff_bytes: diff.len(), + diff_preview: String::from_utf8_lossy(&diff[..preview_end]).into_owned(), + preview_truncated: diff.len() > preview_end, + }) +} + +fn run_diff_check(worktree: &Path) -> FixAttemptGate { + match StdCommand::new("git") + .args(["diff", "--check", "HEAD"]) + .current_dir(worktree) + .output() + { + Ok(output) if output.status.success() => FixAttemptGate { + status: "passed".into(), + detail: "git diff --check passed for the isolated worktree".into(), + }, + Ok(output) => FixAttemptGate { + status: "failed".into(), + detail: bounded_diagnostic(&[output.stdout, output.stderr].concat(), 4_096), + }, + Err(error) => FixAttemptGate { + status: "no_confidence".into(), + detail: format!("Could not run git diff --check: {error}"), + }, + } +} + +async fn run_fix_review( + app_data_dir: &Path, + worktree: &Path, + agent: &str, + packet: &AgentFixPacketReceipt, + source_receipt: &LocalCheckReceipt, + correctness: &LocalCheckStage, +) -> FixAttemptReview { + let connection = match db::init_db(app_data_dir.to_path_buf()) { + Ok(connection) => connection, + Err(error) => { + return FixAttemptReview { + status: "no_confidence".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some(format!("Open recheck database: {error}")), + }; + } + }; + let db = DbState(std::sync::Arc::new(std::sync::Mutex::new(connection))); + let acceptance = if packet.task.acceptance_criteria.is_empty() { + String::new() + } else { + format!( + "\n\nAcceptance criteria:\n{}", + packet + .task + .acceptance_criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + ) + }; + let qa = json!({ + "kind": "fix_correctness_recheck", + "status": status_name(correctness.status), + "target": correctness.target, + "limitations": correctness.limitations, + }); + match run_cli_review_core( + db, + worktree.to_string_lossy().into_owned(), + "WORKTREE".into(), + format!("Isolated fix attempt for local-check run {}", packet.run_id), + format!( + "Recheck whether the worktree diff resolves only the selected findings while preserving the original task: {}{}", + packet.task.goal, acceptance + ), + Some(agent.into()), + Some(vec![qa]), + source_receipt.standards_pack.clone(), + ) + .await + { + Ok(value) => { + let complete = value.get("review_status").and_then(Value::as_str) + == Some("completed") + && value + .pointer("/review_manifest/complete_coverage") + .and_then(Value::as_bool) + == Some(true); + FixAttemptReview { + status: if complete { + "completed" + } else { + "no_confidence" + } + .into(), + review_id: value + .get("review_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + summary: value + .get("summary") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + findings: value + .get("findings") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .take(MAX_FINDINGS) + .collect(), + limitation: (!complete).then(|| { + "Source-qualified re-review completed with incomplete coverage or readiness limitations." + .into() + }), + } + } + Err(error) => FixAttemptReview { + status: "no_confidence".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some(format!("Source-qualified re-review did not complete: {error}")), + }, + } +} + +fn project_correctness(stage: &LocalCheckStage) -> FixAttemptCorrectness { + FixAttemptCorrectness { + status: status_name(stage.status).into(), + target: stage + .target + .as_ref() + .map(|target| format!("{} · {}", target.adapter, target.target)), + duration_ms: stage.duration_ms, + evidence: stage.evidence.clone(), + limitations: stage.limitations.clone(), + } +} + +fn classify_findings( + originals: &[FixPacketFinding], + review: &FixAttemptReview, + correctness: &FixAttemptCorrectness, +) -> Vec { + originals + .iter() + .map(|finding| { + if review.status != "completed" || correctness.status == "no_confidence" { + return FixFindingRecheck { + finding_id: finding.id.clone(), + status: "unchecked".into(), + reason: "Executable or source-qualified recheck confidence is incomplete".into(), + }; + } + if review + .findings + .iter() + .any(|candidate| finding_matches(finding, candidate)) + || correctness.status == "failed" + { + FixFindingRecheck { + finding_id: finding.id.clone(), + status: "reproduced".into(), + reason: if correctness.status == "failed" { + "The recorded correctness target still fails".into() + } else { + "Source-qualified re-review reproduced the finding".into() + }, + } + } else if correctness.status == "passed" { + FixFindingRecheck { + finding_id: finding.id.clone(), + status: "fixed".into(), + reason: "The recorded correctness target passed and re-review did not reproduce the finding" + .into(), + } + } else { + FixFindingRecheck { + finding_id: finding.id.clone(), + status: "unchecked".into(), + reason: "The correctness recheck did not produce a passing executable result".into(), + } + } + }) + .collect() +} + +fn finding_matches(original: &FixPacketFinding, candidate: &Value) -> bool { + let candidate_path = candidate + .get("filePath") + .or_else(|| candidate.get("file_path")) + .and_then(Value::as_str) + .unwrap_or_default(); + if candidate_path != original.file_path { + return false; + } + let candidate_line = candidate.get("line").and_then(Value::as_i64); + if original + .line + .zip(candidate_line) + .is_some_and(|(left, right)| left.abs_diff(right) <= 5) + { + return true; + } + let candidate_title = candidate + .get("title") + .and_then(Value::as_str) + .unwrap_or_default(); + token_similarity(&original.title, candidate_title) >= 0.5 +} + +fn token_similarity(left: &str, right: &str) -> f64 { + let left = title_tokens(left); + let right = title_tokens(right); + if left.is_empty() || right.is_empty() { + return 0.0; + } + let intersection = left.intersection(&right).count() as f64; + let union = left.union(&right).count() as f64; + intersection / union +} + +fn title_tokens(value: &str) -> BTreeSet { + value + .split(|character: char| !character.is_ascii_alphanumeric()) + .map(str::to_ascii_lowercase) + .filter(|token| token.len() >= 3) + .collect() +} + +fn classify_attempt_state( + diff_check: &FixAttemptGate, + correctness: &FixAttemptCorrectness, + review: &FixAttemptReview, + findings: &[FixFindingRecheck], +) -> String { + if diff_check.status == "failed" || correctness.status == "failed" { + return "reproduced".into(); + } + if diff_check.status != "passed" + || correctness.status != "passed" + || review.status != "completed" + || findings.iter().any(|finding| finding.status == "unchecked") + { + return "no_confidence".into(); + } + if findings + .iter() + .any(|finding| finding.status == "reproduced") + { + "reproduced".into() + } else if !review.findings.is_empty() { + "needs_attention".into() + } else { + "verified_fixed".into() + } +} + +#[allow(clippy::too_many_arguments)] +fn terminal_receipt( + attempt_id: &str, + state: &str, + input: &FixAttemptInput, + packet: &AgentFixPacketReceipt, + repository: &Path, + worktree: &Path, + execution: AgentExecution, + change: FixAttemptChange, + recheck: FixAttemptRecheck, + limitations: Vec, + started_at: String, +) -> FixAttemptReceipt { + FixAttemptReceipt { + schema_version: SCHEMA_VERSION.into(), + attempt_id: attempt_id.into(), + operation: "execute".into(), + state: state.into(), + source_run_id: input.run_id.clone(), + repository_path: repository.to_string_lossy().into_owned(), + source: FixAttemptSource { + input: packet.source.input.clone(), + base_sha: packet.source.base_sha.clone(), + head_sha: packet.source.head_sha.clone(), + }, + worktree: FixAttemptWorktree { + path: worktree.to_string_lossy().into_owned(), + detached: true, + retained: true, + source_head_sha: packet.source.head_sha.clone(), + }, + agent: FixAttemptAgent { + id: input.agent.clone(), + status: if execution.success { + "completed" + } else { + "failed" + } + .into(), + duration_ms: execution.duration_ms, + diagnostic: execution.diagnostic, + }, + change, + recheck, + limitations, + started_at, + completed_at: chrono::Utc::now().to_rfc3339(), + } +} + +fn unchecked_recheck(packet: &AgentFixPacketReceipt, reason: &str) -> FixAttemptRecheck { + FixAttemptRecheck { + diff_check: FixAttemptGate { + status: "unchecked".into(), + detail: reason.into(), + }, + correctness: FixAttemptCorrectness { + status: "unchecked".into(), + target: None, + duration_ms: 0, + evidence: Value::Null, + limitations: vec![reason.into()], + }, + review: FixAttemptReview { + status: "unchecked".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some(reason.into()), + }, + findings: packet + .findings + .iter() + .map(|finding| FixFindingRecheck { + finding_id: finding.id.clone(), + status: "unchecked".into(), + reason: reason.into(), + }) + .collect(), + } +} + +fn empty_change() -> FixAttemptChange { + FixAttemptChange { + changed_files: Vec::new(), + diff_sha256: None, + diff_bytes: 0, + diff_preview: String::new(), + preview_truncated: false, + } +} + +fn persist_receipt(root: &Path, receipt: &FixAttemptReceipt) -> Result<(), String> { + fs::create_dir_all(root) + .map_err(|error| format!("Create fix-attempt receipt directory: {error}"))?; + let bytes = serde_json::to_vec_pretty(receipt) + .map_err(|error| format!("Encode fix-attempt receipt: {error}"))?; + let temporary = root.join("receipt.json.tmp"); + let destination = root.join("receipt.json"); + fs::write(&temporary, bytes).map_err(|error| format!("Write fix-attempt receipt: {error}"))?; + fs::rename(&temporary, &destination) + .map_err(|error| format!("Publish fix-attempt receipt: {error}"))?; + Ok(()) +} + +fn attempt_root(app_data_dir: &Path, attempt_id: &str) -> Result { + validate_identity(attempt_id, "attempt id")?; + if !attempt_id.starts_with("fix-attempt-") { + return Err("Fix-attempt identity has an unsupported prefix".into()); + } + Ok(app_data_dir.join("fix-attempts").join(attempt_id)) +} + +fn git_output(worktree: &Path, arguments: &[&str], max_bytes: usize) -> Result, String> { + let output = StdCommand::new("git") + .args(arguments) + .current_dir(worktree) + .output() + .map_err(|error| format!("Run git {}: {error}", arguments.join(" ")))?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + arguments.join(" "), + bounded_diagnostic(&output.stderr, 4_096) + )); + } + if output.stdout.len() > max_bytes || output.stderr.len() > max_bytes { + return Err(format!( + "git {} output exceeded its bound", + arguments.join(" ") + )); + } + Ok(output.stdout) +} + +fn validate_identity(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 160 + || value.trim() != value + || value.contains('\0') + || value.contains(['\r', '\n']) + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + return Err(format!("{label} must be a bounded lowercase-safe identity")); + } + Ok(()) +} + +fn validate_relative_path(value: &str) -> Result<(), String> { + let path = Path::new(value); + if value.is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err("The fix attempt produced an unsafe changed path".into()); + } + Ok(()) +} + +fn valid_sha(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn status_name(status: LocalCheckStatus) -> &'static str { + match status { + LocalCheckStatus::Passed => "passed", + LocalCheckStatus::Completed => "completed", + LocalCheckStatus::Ready => "ready", + LocalCheckStatus::NeedsAttention => "needs_attention", + LocalCheckStatus::Failed => "failed", + LocalCheckStatus::NoConfidence => "no_confidence", + } +} + +fn bounded_diagnostic(bytes: &[u8], limit: usize) -> String { + String::from_utf8_lossy(bytes) + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(limit) + .collect() +} + +fn elapsed_ms(started: Instant) -> u64 { + started.elapsed().as_millis().try_into().unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + fn finding(id: &str, title: &str, path: &str, line: i64) -> FixPacketFinding { + FixPacketFinding { + id: id.into(), + severity: "high".into(), + title: title.into(), + summary: "Fixture problem".into(), + suggestion: None, + file_path: path.into(), + line: Some(line), + confidence: Some(0.9), + } + } + + fn correctness(status: &str) -> FixAttemptCorrectness { + FixAttemptCorrectness { + status: status.into(), + target: Some("vitest · src/cart.test.ts".into()), + duration_ms: 10, + evidence: json!({}), + limitations: Vec::new(), + } + } + + #[test] + fn execution_requires_explicit_consent_and_bounded_inputs() { + let mut input = FixAttemptInput { + run_id: "local-check-7".into(), + finding_ids: vec!["finding-1".into()], + agent: "codex".into(), + confirmed: false, + timeout_ms: 30_000, + }; + assert!(validate_execute_input(&input) + .unwrap_err() + .contains("explicit confirmation")); + input.confirmed = true; + assert!(validate_execute_input(&input).is_ok()); + input.finding_ids.push("finding-1".into()); + assert!(validate_execute_input(&input) + .unwrap_err() + .contains("duplicate")); + } + + #[test] + fn finding_recheck_matches_nearby_lines_or_same_file_title_tokens() { + let original = finding( + "finding-1", + "Checkout total uses stale subtotal", + "src/cart.ts", + 42, + ); + assert!(finding_matches( + &original, + &json!({"filePath":"src/cart.ts","line":45,"title":"Different wording"}) + )); + assert!(finding_matches( + &original, + &json!({"file_path":"src/cart.ts","line":90,"title":"Checkout total uses stale value"}) + )); + assert!(!finding_matches( + &original, + &json!({"filePath":"src/other.ts","line":42,"title":"Checkout total uses stale subtotal"}) + )); + } + + #[test] + fn fixed_status_requires_both_executable_pass_and_completed_rereview() { + let originals = vec![finding("finding-1", "Stale subtotal", "src/cart.ts", 42)]; + let completed = FixAttemptReview { + status: "completed".into(), + review_id: Some("review-8".into()), + summary: None, + findings: Vec::new(), + limitation: None, + }; + let rechecks = classify_findings(&originals, &completed, &correctness("passed")); + assert_eq!(rechecks[0].status, "fixed"); + assert_eq!( + classify_attempt_state( + &FixAttemptGate { + status: "passed".into(), + detail: String::new() + }, + &correctness("passed"), + &completed, + &rechecks, + ), + "verified_fixed" + ); + + let unchecked = classify_findings(&originals, &completed, &correctness("no_confidence")); + assert_eq!(unchecked[0].status, "unchecked"); + assert_eq!( + classify_attempt_state( + &FixAttemptGate { + status: "passed".into(), + detail: String::new() + }, + &correctness("no_confidence"), + &completed, + &unchecked, + ), + "no_confidence" + ); + + let new_regression = FixAttemptReview { + findings: vec![json!({ + "filePath": "src/new-regression.ts", + "line": 8, + "title": "Fix introduced a new regression" + })], + ..completed.clone() + }; + let original_fixed = classify_findings(&originals, &new_regression, &correctness("passed")); + assert_eq!(original_fixed[0].status, "fixed"); + assert_eq!( + classify_attempt_state( + &FixAttemptGate { + status: "passed".into(), + detail: String::new() + }, + &correctness("passed"), + &new_regression, + &original_fixed, + ), + "needs_attention" + ); + } + + #[test] + fn coding_agents_use_noninteractive_edit_only_safety_modes() { + let claude = fix_agent_arguments("claude", "prompt"); + assert!(claude + .windows(2) + .any(|pair| pair == ["--permission-mode", "acceptEdits"])); + assert!(claude.contains(&"--strict-mcp-config".to_string())); + assert!(claude.contains(&"--no-session-persistence".to_string())); + + let gemini = fix_agent_arguments("gemini", "prompt"); + assert!(gemini.contains(&"--sandbox".to_string())); + assert!(gemini + .windows(2) + .any(|pair| pair == ["--approval-mode", "auto_edit"])); + assert!(gemini + .windows(2) + .any(|pair| pair == ["--extensions", "none"])); + + let codex = fix_agent_arguments("codex", "prompt"); + assert!(codex + .windows(2) + .any(|pair| pair == ["--sandbox", "workspace-write"])); + assert!(codex.contains(&"--ephemeral".to_string())); + } + + #[cfg(unix)] + #[tokio::test] + async fn fixture_agent_edits_only_the_detached_worktree_and_yields_a_bounded_diff() { + let root = tempfile::tempdir().expect("temporary fix fixture"); + let repository = root.path().join("repository"); + fs::create_dir(&repository).expect("repository directory"); + for arguments in [ + vec!["init"], + vec!["config", "user.email", "fixture@codevetter.test"], + vec!["config", "user.name", "CodeVetter Fixture"], + ] { + assert!(StdCommand::new("git") + .args(arguments) + .current_dir(&repository) + .status() + .expect("git setup") + .success()); + } + fs::write(repository.join("source.txt"), "original\n").expect("fixture source"); + assert!(StdCommand::new("git") + .args(["add", "source.txt"]) + .current_dir(&repository) + .status() + .expect("git add") + .success()); + assert!(StdCommand::new("git") + .args(["commit", "-m", "fixture"]) + .current_dir(&repository) + .status() + .expect("git commit") + .success()); + let head = git_output(&repository, &["rev-parse", "HEAD"], 1_024) + .map(String::from_utf8) + .expect("head bytes") + .expect("head UTF-8"); + let worktree = root.path().join("attempt/worktree"); + create_detached_worktree(&repository, &worktree, head.trim()).expect("detached worktree"); + + let fixture_agent = root.path().join("fixture-codex"); + fs::write( + &fixture_agent, + "#!/bin/sh\nprintf 'fixed by fixture agent\\n' > fixed.txt\n", + ) + .expect("fixture agent"); + fs::set_permissions(&fixture_agent, fs::Permissions::from_mode(0o755)) + .expect("fixture agent permissions"); + let execution = + run_fix_agent_at("codex", &fixture_agent, &worktree, "bounded fixture prompt").await; + assert!(execution.success, "{:?}", execution.diagnostic); + assert!(!repository.join("fixed.txt").exists()); + assert!(worktree.join("fixed.txt").is_file()); + + expose_untracked_diff(&worktree).expect("expose fixture diff"); + let change = collect_change(&worktree).expect("bounded fixture change"); + assert_eq!(change.changed_files, vec!["fixed.txt"]); + assert!(change.diff_bytes > 0); + assert_eq!(run_diff_check(&worktree).status, "passed"); + + assert!(StdCommand::new("git") + .args(["worktree", "remove", "--force"]) + .arg(&worktree) + .current_dir(&repository) + .status() + .expect("remove fixture worktree") + .success()); + } + + #[test] + fn attempt_identity_cannot_escape_the_app_data_root() { + let root = Path::new("/tmp/codevetter-fixture"); + assert_eq!( + attempt_root(root, "fix-attempt-abc123").unwrap(), + root.join("fix-attempts/fix-attempt-abc123") + ); + assert!(attempt_root(root, "../outside").is_err()); + assert!(attempt_root(root, "other-abc123").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/fix_packet.rs b/apps/desktop/src-tauri/src/commands/fix_packet.rs new file mode 100644 index 00000000..90cb8b9e --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/fix_packet.rs @@ -0,0 +1,507 @@ +//! Deterministic local agent handoff derived from one persisted local-check receipt. + +use rusqlite::{Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +use super::local_check::LocalCheckReceipt; + +const SCHEMA_VERSION: &str = "codevetter.agent-fix-packet/v1"; +const MAX_FINDINGS: usize = 100; +const MAX_EVIDENCE_REFS: usize = 24; +const MAX_MARKDOWN_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentFixPacketReceipt { + pub schema_version: String, + pub created_at: String, + pub run_id: String, + pub repo_path: String, + pub source: FixPacketSource, + pub agent: String, + pub task: FixPacketTask, + pub route_advice: String, + pub findings: Vec, + pub evidence: Vec, + pub limitations: Vec, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixPacketSource { + pub input: String, + pub base_sha: String, + pub head_sha: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixPacketTask { + pub goal: String, + pub acceptance_criteria: Vec, + pub non_goals: Vec, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixPacketFinding { + pub id: String, + pub severity: String, + pub title: String, + pub summary: String, + pub suggestion: Option, + pub file_path: String, + pub line: Option, + pub confidence: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixPacketEvidence { + pub kind: String, + pub status: String, + pub label: String, + pub artifact: Option, + pub qualification: String, +} + +pub fn build_agent_fix_packet( + connection: &Connection, + run_id: &str, + selected_finding_ids: &[String], +) -> Result { + validate_identity(run_id, "run id")?; + if selected_finding_ids.len() > MAX_FINDINGS { + return Err(format!( + "At most {MAX_FINDINGS} findings can enter one fix packet" + )); + } + let selected = selected_finding_ids + .iter() + .map(|id| { + validate_identity(id, "finding id")?; + Ok(id.clone()) + }) + .collect::, String>>()?; + if selected.len() != selected_finding_ids.len() { + return Err("Finding selection contains duplicate identities".into()); + } + + let receipt_json: Option = connection + .query_row( + "SELECT receipt_json FROM local_check_runs WHERE run_id = ?1", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("Read local-check receipt: {error}"))?; + let receipt_json = receipt_json.ok_or_else(|| "Local-check run was not found".to_string())?; + let receipt: Value = serde_json::from_str(&receipt_json) + .map_err(|error| format!("Decode local-check receipt: {error}"))?; + if string(&receipt, "schema_version") != Some("codevetter.local-check/v1") { + return Err("Only completed codevetter.local-check/v1 receipts support fix packets".into()); + } + + let repo_path = required_string(&receipt, "repo_path")?; + let source = receipt + .get("source") + .ok_or_else(|| "Local-check receipt has no source identity".to_string())?; + let source = FixPacketSource { + input: required_string(source, "input")?, + base_sha: required_string(source, "base_sha")?, + head_sha: required_string(source, "head_sha")?, + }; + let task_goal = required_string(&receipt, "task")?; + let review_evidence = receipt + .pointer("/stages/review/evidence") + .unwrap_or(&Value::Null); + let all_findings = review_evidence + .get("findings") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut matched = HashSet::new(); + let mut findings = Vec::new(); + for (index, finding) in all_findings.into_iter().take(MAX_FINDINGS).enumerate() { + let persisted_id = string(&finding, "id").map(ToOwned::to_owned); + if !selected.is_empty() + && !persisted_id + .as_ref() + .is_some_and(|id| selected.contains(id)) + { + continue; + } + let file_path = string(&finding, "filePath") + .or_else(|| string(&finding, "file_path")) + .unwrap_or_default() + .to_string(); + if file_path.is_empty() || std::path::Path::new(&file_path).is_absolute() { + continue; + } + let id = persisted_id.unwrap_or_else(|| format!("receipt-finding-{}", index + 1)); + matched.insert(id.clone()); + findings.push(FixPacketFinding { + id, + severity: string(&finding, "severity") + .unwrap_or("unknown") + .to_string(), + title: required_string(&finding, "title")?, + summary: required_string(&finding, "summary")?, + suggestion: string(&finding, "suggestion") + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned), + file_path, + line: finding.get("line").and_then(Value::as_i64), + confidence: finding.get("confidence").and_then(Value::as_f64), + }); + } + if !selected.is_empty() && !selected.is_subset(&matched) { + let missing = selected.difference(&matched).cloned().collect::>(); + return Err(format!( + "Selected findings are unavailable or not source-qualified: {}", + missing.join(", ") + )); + } + if findings.is_empty() { + return Err("The local-check receipt has no source-qualified findings to hand off".into()); + } + + let acceptance_criteria = receipt + .pointer("/spec_coverage/requirements") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|requirement| { + requirement + .get("supplied_to_review") + .and_then(Value::as_bool) + .unwrap_or(false) + || requirement + .get("selected_for_execution") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|requirement| { + let title = string(requirement, "title")?; + let text = string(requirement, "text").unwrap_or_default(); + Some(if text.is_empty() { + title.to_string() + } else { + format!("{title}: {text}") + }) + }) + .take(32) + .collect::>(); + + let agent = string(review_evidence, "agent") + .or_else(|| { + review_evidence + .pointer("/review_manifest/executor_id")? + .as_str() + }) + .unwrap_or("coding-agent") + .to_string(); + let evidence = collect_evidence(&receipt, review_evidence); + let high_risk = findings + .iter() + .any(|finding| matches!(finding.severity.as_str(), "critical" | "high")); + let route_advice = if high_risk { + "Use a full coding agent in an isolated worktree; require executable proof before merge." + } else if findings.len() <= 2 { + "Keep the patch tightly scoped, then rerun the exact verification receipt." + } else { + "Split this broad batch by file or behavior before starting the first fix attempt." + } + .to_string(); + let mut limitations = string_array(&receipt, "limitations"); + limitations.push( + "This packet is a deterministic handoff, not proof that a proposed fix is correct.".into(), + ); + if acceptance_criteria.is_empty() { + limitations.push( + "No explicit acceptance requirements were attached; the task goal is the only intent contract." + .into(), + ); + } + let mut packet = AgentFixPacketReceipt { + schema_version: SCHEMA_VERSION.into(), + created_at: chrono::Utc::now().to_rfc3339(), + run_id: run_id.to_string(), + repo_path, + source, + agent, + task: FixPacketTask { + goal: task_goal, + acceptance_criteria, + non_goals: Vec::new(), + source: "persisted_local_check_receipt".into(), + }, + route_advice, + findings, + evidence, + limitations, + markdown: String::new(), + }; + packet.markdown = render_markdown(&packet); + if packet.markdown.len() > MAX_MARKDOWN_BYTES { + return Err("Agent fix packet exceeds the bounded Markdown size".into()); + } + Ok(packet) +} + +pub fn load_local_check_receipt( + connection: &Connection, + run_id: &str, +) -> Result { + validate_identity(run_id, "run id")?; + let receipt_json: Option = connection + .query_row( + "SELECT receipt_json FROM local_check_runs WHERE run_id = ?1", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("Read local-check receipt: {error}"))?; + let receipt_json = receipt_json.ok_or_else(|| "Local-check run was not found".to_string())?; + let receipt: LocalCheckReceipt = serde_json::from_str(&receipt_json) + .map_err(|error| format!("Decode local-check receipt: {error}"))?; + if receipt.schema_version != "codevetter.local-check/v1" || receipt.run_id != run_id { + return Err( + "Only the exact completed codevetter.local-check/v1 receipt is supported".into(), + ); + } + Ok(receipt) +} + +fn collect_evidence(receipt: &Value, review_evidence: &Value) -> Vec { + let mut rows = Vec::new(); + for stage in ["correctness", "performance"] { + let Some(value) = receipt.pointer(&format!("/stages/{stage}")) else { + continue; + }; + let status = string(value, "status").unwrap_or("unavailable"); + let target = value.get("target"); + let label = target + .and_then(|target| { + let adapter = string(target, "adapter")?; + let path = string(target, "target")?; + Some(format!("{adapter} · {path}")) + }) + .unwrap_or_else(|| format!("{stage} stage")); + rows.push(FixPacketEvidence { + kind: stage.into(), + status: status.into(), + label, + artifact: None, + qualification: "versioned local-check stage".into(), + }); + } + for qa in review_evidence + .get("qa_evidence") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(5) + { + rows.push(FixPacketEvidence { + kind: "synthetic_qa".into(), + status: if qa.get("pass").and_then(Value::as_bool) == Some(true) { + "passed".into() + } else { + "failed".into() + }, + label: string(qa, "goal") + .or_else(|| string(qa, "route")) + .unwrap_or("Recorded QA journey") + .to_string(), + artifact: string(qa, "screenshot_path").map(ToOwned::to_owned), + qualification: "recorded runtime evidence".into(), + }); + } + for step in review_evidence + .get("evidence_procedure_steps") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(8) + { + rows.push(FixPacketEvidence { + kind: "procedure_gate".into(), + status: string(step, "status").unwrap_or("planned").to_string(), + label: string(step, "gate") + .or_else(|| string(step, "procedure")) + .unwrap_or("Evidence procedure") + .to_string(), + artifact: string(step, "artifact") + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + qualification: "deterministic procedure context".into(), + }); + } + rows.truncate(MAX_EVIDENCE_REFS); + rows +} + +fn render_markdown(packet: &AgentFixPacketReceipt) -> String { + let mut out = vec![ + "# Agent Fix Packet".to_string(), + String::new(), + format!("Run: {}", packet.run_id), + format!("Repo: {}", packet.repo_path), + format!("Diff: {}", packet.source.input), + format!("Head: {}", packet.source.head_sha), + format!("Agent: {}", packet.agent), + format!("Route advice: {}", packet.route_advice), + String::new(), + format!("Goal: {}", packet.task.goal), + ]; + if !packet.task.acceptance_criteria.is_empty() { + out.extend([String::new(), "Acceptance:".into()]); + out.extend( + packet + .task + .acceptance_criteria + .iter() + .map(|value| format!("- {value}")), + ); + } + out.extend([String::new(), "Findings:".into()]); + for (index, finding) in packet.findings.iter().enumerate() { + let line = finding + .line + .map(|line| format!(":{line}")) + .unwrap_or_default(); + out.push(format!( + "- {}. [{}] {} ({}{})", + index + 1, + finding.severity, + finding.title, + finding.file_path, + line + )); + out.push(format!(" Problem: {}", finding.summary)); + if let Some(suggestion) = &finding.suggestion { + out.push(format!(" Suggested fix: {suggestion}")); + } + } + if !packet.evidence.is_empty() { + out.extend([String::new(), "Evidence to preserve:".into()]); + for evidence in &packet.evidence { + let artifact = evidence + .artifact + .as_ref() + .map(|value| format!("; artifact={value}")) + .unwrap_or_default(); + out.push(format!( + "- [{} / {}] {} ({}){}", + evidence.kind, evidence.status, evidence.label, evidence.qualification, artifact + )); + } + } + out.extend([String::new(), "Limitations:".into()]); + out.extend(packet.limitations.iter().map(|value| format!("- {value}"))); + out.join("\n") +} + +fn validate_identity(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 160 + || value.trim() != value + || value.contains('\0') + || value.contains(['\r', '\n']) + { + return Err(format!("{label} must be a bounded single-line identity")); + } + Ok(()) +} + +fn required_string(value: &Value, key: &str) -> Result { + string(value, key) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("Local-check receipt is missing {key}")) +} + +fn string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn string_array(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + use serde_json::json; + + fn fixture() -> Connection { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let receipt = json!({ + "schema_version": "codevetter.local-check/v1", + "run_id": "local-check-7", + "ran_at": "2026-09-01T00:00:00Z", + "repo_path": "/fixture/repo", + "task": "Preserve checkout totals", + "source": { + "input": "main...HEAD", + "base_sha": "a".repeat(40), + "head_sha": "b".repeat(40), + "changed_paths": ["src/cart.ts"] + }, + "stages": { + "review": {"status":"needs_attention","evidence":{ + "review_manifest":{"executor_id":"claude"}, + "findings":[ + {"id":"finding-1","severity":"high","title":"Stale total","summary":"Uses stale subtotal.","suggestion":"Use discounted total.","filePath":"src/cart.ts","line":42,"confidence":0.94}, + {"id":"finding-2","severity":"low","title":"Copy","summary":"Label is unclear.","filePath":"src/cart.ts","line":9} + ], + "qa_evidence":[{"goal":"Verify checkout","pass":true,"screenshot_path":"artifacts/checkout.png"}], + "evidence_procedure_steps":[{"status":"satisfied","gate":"Exact checkout passes","artifact":"artifacts/receipt.json"}] + }}, + "correctness":{"status":"failed","target":{"adapter":"vitest","target":"src/cart.test.ts"}}, + "performance":{"status":"no_confidence","target":null} + }, + "spec_coverage":{"requirements":[{"title":"Discounted total","text":"Charge the post-discount amount.","supplied_to_review":true,"selected_for_execution":true}]}, + "limitations":["No performance workload matched."] + }); + connection.execute( + "INSERT INTO local_check_runs(run_id,schema_version,repo_path,base_sha,head_sha,verdict,task,receipt_json,ran_at) VALUES(?1,'codevetter.local-check/v1','/fixture/repo',?2,?3,'needs_attention','Preserve checkout totals',?4,'2026-09-01T00:00:00Z')", + params!["local-check-7", "a".repeat(40), "b".repeat(40), receipt.to_string()], + ).expect("insert receipt"); + connection + } + + #[test] + fn packet_preserves_selected_findings_acceptance_and_runtime_evidence() { + let packet = build_agent_fix_packet(&fixture(), "local-check-7", &["finding-1".into()]) + .expect("fix packet"); + assert_eq!(packet.schema_version, SCHEMA_VERSION); + assert_eq!(packet.findings.len(), 1); + assert_eq!(packet.findings[0].id, "finding-1"); + assert_eq!(packet.task.acceptance_criteria.len(), 1); + assert!(packet + .evidence + .iter() + .any(|row| row.kind == "synthetic_qa" && row.status == "passed")); + assert!(packet.markdown.contains("Use discounted total.")); + assert!(packet + .markdown + .contains("not proof that a proposed fix is correct")); + } + + #[test] + fn packet_rejects_unknown_or_unqualified_finding_selection() { + let error = build_agent_fix_packet(&fixture(), "local-check-7", &["missing".into()]) + .expect_err("unknown finding"); + assert!(error.contains("unavailable")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/history.rs b/apps/desktop/src-tauri/src/commands/history.rs index 004cf20b..5b0c10ab 100644 --- a/apps/desktop/src-tauri/src/commands/history.rs +++ b/apps/desktop/src-tauri/src/commands/history.rs @@ -1148,7 +1148,7 @@ fn estimate_cost_precise( /// cache-tier split — e.g. by-model aggregate rows that fall back to /// session-level totals without a per-model breakdown. Treats all /// cache-creation tokens as the default 5-minute tier. -fn estimate_cost( +pub(crate) fn estimate_cost( model: &str, total_input: i64, output_tokens: i64, @@ -1565,12 +1565,11 @@ fn upsert_adapter_summary_session( let archive_messages = summary.archive_messages.clone(); let parse_warnings = summary.parse_warnings.clone(); - for warning in &summary.parse_warnings { + if !summary.parse_warnings.is_empty() { log::warn!( - "{} session adapter warning for {}: {}", + "{} session adapter reported {} parse warning(s)", summary.adapter_id, - source_ref, - warning + summary.parse_warnings.len() ); } diff --git a/apps/desktop/src-tauri/src/commands/history_roots.rs b/apps/desktop/src-tauri/src/commands/history_roots.rs new file mode 100644 index 00000000..3a2437ab --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_roots.rs @@ -0,0 +1,250 @@ +use crate::db::queries; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const HISTORY_ROOTS_SCHEMA_VERSION: &str = "codevetter.history-roots/v1"; +const PREFERENCE_KEY: &str = "codex_usage_import_roots"; +const MAX_ROOTS: usize = 16; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryRootsOperation { + Read, + Add, + Remove, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryRoot { + pub path: String, + pub display_path: String, + pub exists: bool, + pub sessions_available: bool, + pub archived_sessions_available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryRootsReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: HistoryRootsOperation, + pub database_available: bool, + pub changed_root: Option, + pub roots: Vec, + pub limitations: Vec, +} + +pub fn run_history_roots( + connection: Option<&Connection>, + operation: HistoryRootsOperation, + requested_path: Option<&Path>, +) -> Result { + let mut roots = read_stored_roots(connection)?; + let changed_root = match operation { + HistoryRootsOperation::Read => { + if requested_path.is_some() { + return Err("read does not accept a history-root path".to_string()); + } + None + } + HistoryRootsOperation::Add => { + let connection = connection.ok_or("history-root add requires the local database")?; + let path = normalize_new_root( + requested_path.ok_or("history-root add requires an explicit directory")?, + )?; + let path_string = path.to_string_lossy().to_string(); + if !roots.contains(&path_string) { + if roots.len() >= MAX_ROOTS { + return Err(format!( + "at most {MAX_ROOTS} additional Codex roots are allowed" + )); + } + roots.push(path_string.clone()); + roots.sort(); + persist_roots(connection, &roots)?; + } + Some(path_string) + } + HistoryRootsOperation::Remove => { + let connection = connection.ok_or("history-root remove requires the local database")?; + let requested = requested_path + .ok_or("history-root remove requires an explicit stored path")? + .to_string_lossy() + .to_string(); + let previous_len = roots.len(); + roots.retain(|root| root != &requested); + if roots.len() == previous_len { + return Err("the requested history root is not configured".to_string()); + } + persist_roots(connection, &roots)?; + Some(requested) + } + }; + + Ok(HistoryRootsReceipt { + schema_version: HISTORY_ROOTS_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + operation, + database_available: connection.is_some(), + changed_root, + roots: roots.iter().map(|root| describe_root(root)).collect(), + limitations: vec![ + "The active CODEX_HOME remains automatic and is not duplicated here.".to_string(), + "Saving a root does not start reconciliation or read transcript content.".to_string(), + "Removing a root changes future discovery only; it does not delete provider transcripts." + .to_string(), + ], + }) +} + +fn read_stored_roots(connection: Option<&Connection>) -> Result, String> { + let Some(connection) = connection else { + return Ok(Vec::new()); + }; + let Some(raw) = queries::get_preference(connection, PREFERENCE_KEY) + .map_err(|error| format!("read additional Codex roots: {error}"))? + else { + return Ok(Vec::new()); + }; + let roots: Vec = serde_json::from_str(&raw) + .map_err(|error| format!("stored additional Codex roots are invalid: {error}"))?; + validate_stored_roots(roots) +} + +fn validate_stored_roots(roots: Vec) -> Result, String> { + if roots.len() > MAX_ROOTS { + return Err(format!( + "stored additional Codex roots exceed the {MAX_ROOTS}-root limit" + )); + } + let mut valid = Vec::with_capacity(roots.len()); + for root in roots { + if root.is_empty() || root.len() > 4_096 || root.contains(['\0', '\n', '\r']) { + return Err("stored additional Codex roots contain an invalid path".to_string()); + } + if !Path::new(&root).is_absolute() { + return Err("stored additional Codex roots must be absolute paths".to_string()); + } + if !valid.contains(&root) { + valid.push(root); + } + } + valid.sort(); + Ok(valid) +} + +fn normalize_new_root(path: &Path) -> Result { + let canonical = std::fs::canonicalize(path) + .map_err(|error| format!("open selected Codex history root: {error}"))?; + if !canonical.is_dir() { + return Err("the selected Codex history root is not a directory".to_string()); + } + let base = match canonical.file_name().and_then(|name| name.to_str()) { + Some("sessions" | "archived_sessions") => canonical + .parent() + .map(Path::to_path_buf) + .ok_or("the selected sessions directory has no parent")?, + _ => canonical, + }; + if !base.join("sessions").is_dir() && !base.join("archived_sessions").is_dir() { + return Err( + "select a Codex home, sessions directory, or archived_sessions directory".to_string(), + ); + } + Ok(base) +} + +fn persist_roots(connection: &Connection, roots: &[String]) -> Result<(), String> { + let serialized = serde_json::to_string(roots) + .map_err(|error| format!("serialize additional Codex roots: {error}"))?; + queries::set_preference(connection, PREFERENCE_KEY, &serialized) + .map_err(|error| format!("save additional Codex roots: {error}")) +} + +fn describe_root(path: &str) -> HistoryRoot { + let root = Path::new(path); + HistoryRoot { + path: path.to_string(), + display_path: display_path(root), + exists: root.is_dir(), + sessions_available: root.join("sessions").is_dir(), + archived_sessions_available: root.join("archived_sessions").is_dir(), + } +} + +fn display_path(path: &Path) -> String { + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + if let Ok(relative) = path.strip_prefix(home) { + return format!("~/{}", relative.to_string_lossy()); + } + } + path.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn history_roots_normalize_dedupe_and_remove_without_reading_transcripts() { + let fixture = tempfile::tempdir().expect("fixture"); + let codex_home = fixture.path().join("codex-home"); + std::fs::create_dir_all(codex_home.join("sessions")).expect("sessions"); + std::fs::write( + codex_home.join("sessions").join("secret.jsonl"), + "secret transcript", + ) + .expect("transcript"); + + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + let added = run_history_roots( + Some(&connection), + HistoryRootsOperation::Add, + Some(&codex_home.join("sessions")), + ) + .expect("add root"); + assert_eq!(added.roots.len(), 1); + let canonical_home = std::fs::canonicalize(&codex_home).expect("canonical Codex home"); + assert_eq!(added.roots[0].path, canonical_home.to_string_lossy()); + assert!(added.roots[0].sessions_available); + assert!(!serde_json::to_string(&added) + .expect("receipt") + .contains("secret transcript")); + + let duplicate = run_history_roots( + Some(&connection), + HistoryRootsOperation::Add, + Some(&codex_home), + ) + .expect("dedupe root"); + assert_eq!(duplicate.roots.len(), 1); + + let removed = run_history_roots( + Some(&connection), + HistoryRootsOperation::Remove, + Some(Path::new(&added.roots[0].path)), + ) + .expect("remove root"); + assert!(removed.roots.is_empty()); + assert!(codex_home.join("sessions").join("secret.jsonl").is_file()); + } + + #[test] + fn history_roots_reject_unrelated_or_relative_directories() { + let fixture = tempfile::tempdir().expect("fixture"); + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + assert!(run_history_roots( + Some(&connection), + HistoryRootsOperation::Add, + Some(fixture.path()), + ) + .is_err()); + queries::set_preference(&connection, PREFERENCE_KEY, "[\"relative/path\"]") + .expect("invalid stored root"); + assert!(run_history_roots(Some(&connection), HistoryRootsOperation::Read, None).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/local_check.rs b/apps/desktop/src-tauri/src/commands/local_check.rs index 50ab6408..2224abd8 100644 --- a/apps/desktop/src-tauri/src/commands/local_check.rs +++ b/apps/desktop/src-tauri/src/commands/local_check.rs @@ -7,12 +7,14 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::process::Command; use crate::{db, DbState}; +use super::cross_review; use super::evidence_scope::{ resolve_evidence_scope, EvidenceScopeCandidate, EvidenceScopeConsumer, EvidenceScopeInput, EvidenceScopeKind, EvidenceScopePlan, @@ -75,10 +77,14 @@ pub struct LocalCheckStages { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalCheckReceipt { pub schema_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, pub run_id: String, pub ran_at: String, pub repo_path: String, pub task: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub standards_pack: Option, pub source: TrexSourceReceipt, pub stages: LocalCheckStages, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +96,8 @@ pub struct LocalCheckReceipt { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalCheckPreflightReceipt { pub schema_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, pub ran_at: String, pub repo_path: String, pub task: String, @@ -113,6 +121,8 @@ pub struct LocalCheckInput { pub repo_path: PathBuf, pub change: String, pub task: String, + pub standards_pack: Option, + pub standards_context: Option, pub spec_paths: Vec, pub selected_requirement_ids: Vec, pub review_agent: String, @@ -156,10 +166,19 @@ where performance_selection_limitation, } = prepared; let diff_range = format!("{}...{}", source.base_sha, source.head_sha); - let review_task = compose_review_task(&input.task, spec_packet.as_ref()); + let mut review_task = compose_review_task(&input.task, spec_packet.as_ref()); + if let Some(context) = input + .standards_context + .as_deref() + .map(str::trim) + .filter(|context| !context.is_empty()) + { + review_task.push_str("\n\n"); + review_task.push_str(context); + } let app_data_dir = default_app_data_dir()?; - let connection = - db::init_db(app_data_dir).map_err(|error| format!("open CodeVetter database: {error}"))?; + let connection = db::init_db(app_data_dir.clone()) + .map_err(|error| format!("open CodeVetter database: {error}"))?; let db = DbState(std::sync::Arc::new(std::sync::Mutex::new(connection))); on_progress(LocalCheckProgress { @@ -223,6 +242,7 @@ where &review_task, &input.review_agent, review_runtime_context, + |stage, state| on_progress(LocalCheckProgress { stage, state }), ) .await; on_progress(progress_for_stage("review", &review)); @@ -273,18 +293,101 @@ where stage: "done", state: verdict_name(verdict), }); - Ok(LocalCheckReceipt { + let receipt = LocalCheckReceipt { schema_version: "codevetter.local-check/v1".into(), + request_id: None, run_id: format!("local-check-{}", uuid::Uuid::new_v4()), ran_at: chrono::Utc::now().to_rfc3339(), repo_path: repo_text, task: input.task, + standards_pack: input.standards_pack, source, stages, spec_coverage, verdict, limitations, + }; + let connection = db::init_db(app_data_dir) + .map_err(|error| format!("reopen CodeVetter database for run receipt: {error}"))?; + persist_local_check_receipt(&connection, &receipt)?; + Ok(receipt) +} + +pub fn persist_local_check_receipt( + connection: &rusqlite::Connection, + receipt: &LocalCheckReceipt, +) -> Result<(), String> { + let receipt_json = serde_json::to_string(receipt) + .map_err(|error| format!("serialize local check receipt for persistence: {error}"))?; + connection + .execute( + "INSERT OR REPLACE INTO local_check_runs( + run_id, schema_version, repo_path, base_sha, head_sha, + verdict, task, receipt_json, ran_at + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + receipt.run_id, + receipt.schema_version, + receipt.repo_path, + receipt.source.base_sha, + receipt.source.head_sha, + verdict_name(receipt.verdict), + receipt.task, + receipt_json, + receipt.ran_at, + ], + ) + .map_err(|error| format!("persist local check receipt: {error}"))?; + Ok(()) +} + +pub fn list_local_check_receipts( + connection: &rusqlite::Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let limit = limit.clamp(1, 100) as i64; + let sql = if repo_path.is_some() { + "SELECT receipt_json FROM local_check_runs + WHERE repo_path = ?1 ORDER BY ran_at DESC LIMIT ?2" + } else { + "SELECT receipt_json FROM local_check_runs + ORDER BY ran_at DESC LIMIT ?2" + }; + let mut statement = connection + .prepare(sql) + .map_err(|error| format!("prepare local check history: {error}"))?; + let decode = |row: &rusqlite::Row<'_>| -> rusqlite::Result { row.get(0) }; + let rows = match repo_path { + Some(repo_path) => statement.query_map(rusqlite::params![repo_path, limit], decode), + None => statement.query_map(rusqlite::params![rusqlite::types::Null, limit], decode), + } + .map_err(|error| format!("read local check history: {error}"))?; + rows.map(|row| { + let json = row.map_err(|error| format!("read local check receipt row: {error}"))?; + serde_json::from_str(&json) + .map_err(|error| format!("decode stored local check receipt: {error}")) }) + .collect() +} + +pub fn get_local_check_receipt( + connection: &rusqlite::Connection, + repo_path: &str, + run_id: &str, +) -> Result { + let receipt_json = connection + .query_row( + "SELECT receipt_json FROM local_check_runs + WHERE repo_path = ?1 AND run_id = ?2", + rusqlite::params![repo_path, run_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("read local check receipt: {error}"))? + .ok_or_else(|| "Local-check receipt was not found in this repository scope".to_string())?; + serde_json::from_str(&receipt_json) + .map_err(|error| format!("decode stored local check receipt: {error}")) } pub async fn preflight_local_check( @@ -313,6 +416,17 @@ pub async fn preflight_local_check( ) }); let mut limitations = Vec::new(); + let missing_review_executors = if input.review_agent == "cross" { + cross_review::missing_executors() + } else { + Vec::new() + }; + if !missing_review_executors.is_empty() { + limitations.push(format!( + "Cross-review requires both configured executors before either pass starts; missing: {}", + missing_review_executors.join(", ") + )); + } if correctness_target.is_none() { limitations.push( test_plan @@ -344,7 +458,8 @@ pub async fn preflight_local_check( ); } } - let status = if correctness_target.is_some() + let status = if missing_review_executors.is_empty() + && correctness_target.is_some() && spec_coverage.as_ref().is_none_or(|coverage| { coverage.summary.total_requirements > 0 && coverage.summary.selected_for_execution > 0 }) { @@ -355,6 +470,7 @@ pub async fn preflight_local_check( Ok(LocalCheckPreflightReceipt { schema_version: "codevetter.local-check-preflight/v1".into(), + request_id: None, ran_at: chrono::Utc::now().to_rfc3339(), repo_path: repo_text, task: input.task.clone(), @@ -441,8 +557,11 @@ fn validate_input(input: &LocalCheckInput) -> Result<(), String> { if input.task.trim().is_empty() || input.task.len() > 2_000 { return Err("Task must contain between 1 and 2,000 characters".into()); } - if !matches!(input.review_agent.as_str(), "claude" | "gemini" | "codex") { - return Err("Review agent must be `claude`, `gemini`, or `codex`".into()); + if !matches!( + input.review_agent.as_str(), + "claude" | "gemini" | "codex" | "cross" + ) { + return Err("Review agent must be `claude`, `gemini`, `codex`, or `cross`".into()); } if !(2..=10).contains(&input.samples) { return Err("Performance samples must be between 2 and 10".into()); @@ -578,15 +697,31 @@ fn git_text(repo: &Path, arguments: &[&str]) -> Result { .map_err(|_| "Git returned non-UTF-8 checkout evidence".into()) } -async fn run_review_stage( +async fn run_review_stage( db: DbState, repo_path: &str, diff_range: &str, task: &str, agent: &str, runtime_context: Vec, -) -> LocalCheckStage { + on_cross_progress: F, +) -> LocalCheckStage +where + F: FnMut(&'static str, &'static str), +{ let started = Instant::now(); + if agent == "cross" { + return run_cross_review_stage( + db, + repo_path, + diff_range, + task, + runtime_context, + started, + on_cross_progress, + ) + .await; + } match run_cli_review_core( db, repo_path.to_string(), @@ -599,61 +734,185 @@ async fn run_review_stage( ) .await { - Ok(evidence) => { - let readiness_complete = evidence - .get("review_readiness") - .and_then(|value| value.get("status")) - .and_then(Value::as_str) - == Some("ready") - && evidence.get("review_status").and_then(Value::as_str) == Some("completed"); - let actionable = evidence - .get("findings") - .and_then(Value::as_array) - .is_some_and(|findings| { - findings.iter().any(|finding| { - matches!( - finding.get("severity").and_then(Value::as_str), - Some("critical" | "high") - ) - }) - }); - let limitations = if readiness_complete { - Vec::new() - } else { - evidence - .get("review_readiness") - .and_then(|value| value.get("limitations")) - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(ToOwned::to_owned) - .collect::>() - }) - .filter(|values| !values.is_empty()) - .unwrap_or_else(|| { - vec!["Review context or execution coverage was incomplete".to_string()] - }) - }; - LocalCheckStage { - status: if !readiness_complete { - LocalCheckStatus::NoConfidence - } else if actionable { - LocalCheckStatus::NeedsAttention - } else { - LocalCheckStatus::Completed - }, - duration_ms: elapsed_ms(started), - target: None, - evidence, - limitations, - } - } + Ok(evidence) => review_stage_from_evidence(started, evidence), Err(error) => no_confidence_stage(started, None, error), } } +async fn run_cross_review_stage( + db: DbState, + repo_path: &str, + diff_range: &str, + task: &str, + runtime_context: Vec, + started: Instant, + mut on_progress: F, +) -> LocalCheckStage +where + F: FnMut(&'static str, &'static str), +{ + let missing = cross_review::missing_executors(); + if !missing.is_empty() { + let receipt = cross_review::incomplete_after_pass( + None, + "preflight", + &format!("missing configured executors: {}", missing.join(", ")), + ); + return review_stage_from_evidence(started, cross_review::project_stage_evidence(receipt)); + } + let policy_binding = match cross_review::coordinator_policy_binding( + repo_path, + diff_range, + task, + &runtime_context, + ) { + Ok(binding) => binding, + Err(error) => { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + None, + "policy_binding", + &error, + )), + ); + } + }; + let run_pass = |agent: &str, db: DbState, context: Vec| { + run_cli_review_core( + db, + repo_path.to_string(), + diff_range.to_string(), + "Local repository change".into(), + task.to_string(), + Some(agent.to_string()), + Some(context), + None, + ) + }; + on_progress("review_claude", "running"); + let mut claude = match run_pass("claude", db.clone(), runtime_context.clone()).await { + Ok(evidence) => evidence, + Err(error) => { + on_progress("review_claude", "no_confidence"); + let receipt = cross_review::incomplete_after_pass(None, "claude", &error); + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(receipt), + ); + } + }; + if let Err(error) = cross_review::attach_coordinator_binding(&mut claude, &policy_binding) { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + None, + "claude_binding", + &error, + )), + ); + } + on_progress("review_claude", "completed"); + on_progress("review_codex", "running"); + let mut codex = match run_pass("codex", db.clone(), runtime_context).await { + Ok(evidence) => evidence, + Err(error) => { + on_progress("review_codex", "no_confidence"); + let receipt = + cross_review::incomplete_after_pass(Some(("claude", claude)), "codex", &error); + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(receipt), + ); + } + }; + if let Err(error) = cross_review::attach_coordinator_binding(&mut codex, &policy_binding) { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + Some(("claude", claude)), + "codex_binding", + &error, + )), + ); + } + on_progress("review_codex", "completed"); + let receipt = match cross_review::reconcile_complete(claude, codex) { + Ok(receipt) => receipt, + Err(error) => { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + None, + "reconciliation", + &error, + )), + ); + } + }; + let mut evidence = cross_review::project_stage_evidence(receipt); + if let Err(error) = + cross_review::persist_composite_review(&db, repo_path, diff_range, None, &mut evidence) + { + return no_confidence_stage( + started, + None, + format!("Could not persist the cross-review composite: {error}"), + ); + } + review_stage_from_evidence(started, evidence) +} + +fn review_stage_from_evidence(started: Instant, evidence: Value) -> LocalCheckStage { + let readiness_complete = evidence + .pointer("/review_readiness/status") + .and_then(Value::as_str) + == Some("ready") + && evidence.get("review_status").and_then(Value::as_str) == Some("completed"); + let actionable = evidence + .get("findings") + .and_then(Value::as_array) + .is_some_and(|findings| { + findings.iter().any(|finding| { + matches!( + finding.get("severity").and_then(Value::as_str), + Some("critical" | "high") + ) + }) + }); + let limitations = if readiness_complete { + Vec::new() + } else { + evidence + .pointer("/review_readiness/limitations") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| { + vec!["Review context or execution coverage was incomplete".to_string()] + }) + }; + LocalCheckStage { + status: if !readiness_complete { + LocalCheckStatus::NoConfidence + } else if actionable { + LocalCheckStatus::NeedsAttention + } else { + LocalCheckStatus::Completed + }, + duration_ms: elapsed_ms(started), + target: None, + evidence, + limitations, + } +} + fn runtime_stage_review_context(kind: &str, stage: &LocalCheckStage) -> Value { let evidence_status = match stage.status { LocalCheckStatus::Passed => "pass", @@ -806,6 +1065,24 @@ async fn run_runtime_stage( } } +pub async fn rerun_fix_correctness_target( + repo_path: &Path, + target: Option, + timeout_ms: u64, +) -> LocalCheckStage { + run_runtime_stage( + repo_path, + "run", + target, + 2, + 0, + timeout_ms, + None, + Some("The source verification receipt has no correctness target to recheck".into()), + ) + .await +} + fn runtime_stage_status(operation: &str, evidence: &Value) -> LocalCheckStatus { if operation == "run" { return match evidence @@ -1433,4 +1710,61 @@ mod tests { LocalCheckStatus::NoConfidence ); } + + #[test] + fn persisted_local_checks_round_trip_in_reverse_chronological_order() { + let connection = rusqlite::Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let receipt = |run_id: &str, repo_path: &str, ran_at: &str| LocalCheckReceipt { + schema_version: "codevetter.local-check/v1".into(), + request_id: None, + run_id: run_id.into(), + ran_at: ran_at.into(), + repo_path: repo_path.into(), + task: format!("Verify {run_id}"), + standards_pack: None, + source: TrexSourceReceipt { + kind: super::super::trex_preview::TrexChangeKind::Range, + input: "main...HEAD".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/main.rs".into()], + }, + stages: LocalCheckStages { + review: stage(LocalCheckStatus::Completed), + correctness: stage(LocalCheckStatus::Passed), + performance: stage(LocalCheckStatus::NoConfidence), + optimization: stage(LocalCheckStatus::NoConfidence), + }, + spec_coverage: None, + verdict: LocalCheckVerdict::PassedWithLimits, + limitations: vec!["Fixture limitation".into()], + }; + persist_local_check_receipt( + &connection, + &receipt("run-old", "/tmp/repo", "2026-08-30T00:00:00Z"), + ) + .expect("old receipt"); + persist_local_check_receipt( + &connection, + &receipt("run-new", "/tmp/repo", "2026-08-31T00:00:00Z"), + ) + .expect("new receipt"); + persist_local_check_receipt( + &connection, + &receipt("run-other", "/tmp/other", "2026-09-01T00:00:00Z"), + ) + .expect("other receipt"); + + let rows = + list_local_check_receipts(&connection, Some("/tmp/repo"), 10).expect("stored history"); + assert_eq!( + rows.iter() + .map(|row| row.run_id.as_str()) + .collect::>(), + vec!["run-new", "run-old"] + ); + assert_eq!(rows[0].limitations, vec!["Fixture limitation"]); + } } diff --git a/apps/desktop/src-tauri/src/commands/local_usage.rs b/apps/desktop/src-tauri/src/commands/local_usage.rs index 938f0ca8..88815baf 100644 --- a/apps/desktop/src-tauri/src/commands/local_usage.rs +++ b/apps/desktop/src-tauri/src/commands/local_usage.rs @@ -1,6 +1,7 @@ use crate::db::queries; use crate::DbState; -use chrono::Utc; +use chrono::{Duration as ChronoDuration, Local, NaiveDate, Utc}; +use rusqlite::Connection; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -119,6 +120,41 @@ pub struct LocalUsageFailure { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DevinUsageModel { + pub model: String, + pub sessions: i64, + pub generated_tokens: i64, + pub cache_read_tokens: i64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DevinUsageWindow { + pub window: String, + pub since: Option, + pub sessions: i64, + pub generated_tokens: i64, + pub cache_read_tokens: i64, + pub cost_usd: f64, + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DevinUsageSummary { + pub status: String, + pub source: String, + pub sessions: i64, + pub generated_tokens: i64, + pub cache_read_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub models: Vec, + #[serde(default)] + pub windows: Vec, + pub limitations: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LocalUsageReport { pub status: String, @@ -130,6 +166,8 @@ pub struct LocalUsageReport { pub monthly: Vec, pub sessions: Vec, pub totals: LocalUsageTotals, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub devin: Option, } #[derive(Debug, Deserialize)] @@ -155,11 +193,11 @@ struct RawPeriod { #[serde(default)] agents: Vec, #[serde(flatten)] - totals: RawTotals, + _totals: RawTotals, #[serde(default)] metadata: RawMetadata, - #[serde(default)] - model_breakdowns: Vec, + #[serde(default, rename = "modelBreakdowns")] + _model_breakdowns: Vec, #[serde(default, rename = "modelsUsed")] _models_used: Vec, period: String, @@ -256,10 +294,178 @@ pub async fn get_local_usage_report( refresh: Option, timezone: Option, ) -> Result { - let roots = codex_roots(&db)?; - let timezone = normalize_timezone(timezone.as_deref()); + let roots = { + let connection = db.0.lock().map_err(|error| error.to_string())?; + codex_roots(Some(&connection))? + }; + let mut report = + get_local_usage_report_for_roots(roots, refresh.unwrap_or(false), timezone.as_deref()) + .await?; + let connection = db.0.lock().map_err(|error| error.to_string())?; + report.devin = Some(project_devin_usage(&connection)?); + Ok(report) +} + +/// Read the local usage report without requiring Tauri state. +/// +/// Transport adapters may provide an existing CodeVetter connection so imported +/// Codex roots remain consistent with the desktop app. Passing `None` keeps the +/// operation read-only and falls back to environment/default roots. +pub async fn get_headless_local_usage_report( + connection: Option<&Connection>, + refresh: bool, + timezone: Option<&str>, +) -> Result { + let roots = codex_roots(connection)?; + let mut report = get_local_usage_report_for_roots(roots, refresh, timezone).await?; + report.devin = connection.map(project_devin_usage).transpose()?; + Ok(report) +} + +fn project_devin_usage(connection: &Connection) -> Result { + project_devin_usage_at(connection, Local::now().date_naive()) +} + +fn project_devin_usage_at( + connection: &Connection, + today: NaiveDate, +) -> Result { + let row = queries::get_agent_usage_breakdown(connection) + .map_err(|error| error.to_string())? + .into_iter() + .find(|row| row.agent_type == "devin"); + let has_row = row.is_some(); + let excluded_agents = [ + "claude-code", + "codex", + "cursor", + "grok", + "google", + "openai", + "openrouter", + ] + .map(str::to_string); + let row = row.unwrap_or(queries::AgentUsageRow { + agent_type: "devin".into(), + sessions: 0, + real_input_tokens: 0, + cache_read_tokens: 0, + output_tokens: 0, + week_real_input_tokens: 0, + week_output_tokens: 0, + cost: 0.0, + }); + let mut windows = Vec::with_capacity(4); + for (window, days) in [ + ("1w", Some(7_i64)), + ("30d", Some(30_i64)), + ("90d", Some(90_i64)), + ("all", None), + ] { + let since = days.map(|days| { + (today - ChronoDuration::days(days - 1)) + .format("%Y-%m-%d") + .to_string() + }); + let models = project_devin_models(connection, since.as_deref(), &excluded_agents)?; + let (sessions, generated_tokens, cache_read_tokens, cost_usd) = match since.as_deref() { + Some(since) => { + let rows = queries::get_agent_usage_by_day_since(connection, since) + .map_err(|error| error.to_string())?; + let (generated, cache, cost) = rows + .into_iter() + .filter(|row| row.agent_type == "devin") + .fold((0_i64, 0_i64, 0.0_f64), |totals, row| { + ( + totals.0.saturating_add(row.generated), + totals.1.saturating_add(row.cache), + totals.2 + row.cost, + ) + }); + let sessions = + queries::get_agent_session_count_since(connection, "devin", Some(since)) + .map_err(|error| error.to_string())?; + (sessions, generated, cache, cost) + } + None => ( + row.sessions, + row.real_input_tokens.saturating_add(row.output_tokens), + row.cache_read_tokens, + row.cost, + ), + }; + windows.push(DevinUsageWindow { + window: window.into(), + since, + sessions, + generated_tokens, + cache_read_tokens, + cost_usd, + models, + }); + } + let models = windows + .iter() + .find(|window| window.window == "all") + .map(|window| window.models.clone()) + .unwrap_or_default(); + let status = if has_row || !models.is_empty() { + "ready" + } else { + "empty" + }; + Ok(DevinUsageSummary { + status: status.into(), + source: "CodeVetter SQLite · indexed Devin sessions.db".into(), + sessions: row.sessions, + generated_tokens: row.real_input_tokens.saturating_add(row.output_tokens), + cache_read_tokens: row.cache_read_tokens, + output_tokens: row.output_tokens, + cost_usd: row.cost, + models, + windows, + limitations: vec![ + "Devin remains separate from ccusage totals.".into(), + "This local history is not live quota telemetry.".into(), + ], + }) +} + +fn project_devin_models( + connection: &Connection, + since: Option<&str>, + excluded_agents: &[String], +) -> Result, String> { + queries::get_usage_by_model( + connection, + super::history::estimate_cost, + since, + None, + None, + excluded_agents, + ) + .map_err(|error| error.to_string()) + .map(|rows| { + rows.into_iter() + .map(|model| DevinUsageModel { + model: model.model, + sessions: model.sessions, + generated_tokens: model.generated, + cache_read_tokens: model.cache, + cost_usd: model.cost, + }) + .collect() + }) +} + +async fn get_local_usage_report_for_roots( + roots: Vec, + refresh: bool, + timezone: Option<&str>, +) -> Result { + let timezone = normalize_timezone(timezone); let mut cache = cache().lock().await; - if !refresh.unwrap_or(false) { + if !refresh { if let (Some(report), Some(cached_at)) = (&cache.report, cache.cached_at) { if cached_at.elapsed() < CACHE_TTL && report.provenance.timezone == timezone { return Ok(report.clone()); @@ -285,7 +491,7 @@ pub async fn get_local_usage_report( } } -fn codex_roots(db: &State<'_, DbState>) -> Result, String> { +fn codex_roots(connection: Option<&Connection>) -> Result, String> { let mut roots = BTreeSet::new(); if let Ok(value) = std::env::var("CODEX_HOME") { roots.extend( @@ -306,15 +512,16 @@ fn codex_roots(db: &State<'_, DbState>) -> Result, String> { ); } } - let conn = db.0.lock().map_err(|error| error.to_string())?; - if let Ok(Some(raw)) = queries::get_preference(&conn, "codex_usage_import_roots") { - if let Ok(imports) = serde_json::from_str::>(&raw) { - roots.extend( - imports - .into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty() && !value.contains(',')), - ); + if let Some(connection) = connection { + if let Ok(Some(raw)) = queries::get_preference(connection, "codex_usage_import_roots") { + if let Ok(imports) = serde_json::from_str::>(&raw) { + roots.extend( + imports + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && !value.contains(',')), + ); + } } } Ok(roots.into_iter().collect()) @@ -577,6 +784,7 @@ fn normalize_report( monthly, sessions, totals, + devin: None, }) } @@ -681,6 +889,7 @@ fn unavailable_report( monthly: Vec::new(), sessions: Vec::new(), totals: LocalUsageTotals::default(), + devin: None, } } @@ -803,6 +1012,68 @@ mod tests { assert_eq!(report.totals, LocalUsageTotals::default()); assert!(report.daily.is_empty()); assert!(report.provenance.pricing_complete); + assert!(report.devin.is_none()); + } + + #[test] + fn projects_devin_as_a_separate_local_source() { + let connection = Connection::open_in_memory().unwrap(); + crate::db::schema::run_migrations(&connection).unwrap(); + connection + .execute_batch( + "INSERT INTO cc_projects (id, display_name, dir_path, created_at) + VALUES ('devin-project', 'Devin', '/fixture/devin', '2026-09-01T00:00:00Z'); + INSERT INTO cc_sessions ( + id, project_id, agent_type, model_used, total_input_tokens, + total_output_tokens, cache_read_tokens, cache_creation_tokens, + estimated_cost_usd, last_message + ) VALUES ( + 'devin-session', 'devin-project', 'devin', 'glm-5.2', 1200, + 300, 200, 100, 0.0042, '2026-09-01T00:00:00Z' + ); + INSERT INTO cc_sessions ( + id, project_id, agent_type, model_used, total_input_tokens, + total_output_tokens, cache_read_tokens, cache_creation_tokens, + estimated_cost_usd, last_message + ) VALUES ( + 'devin-old', 'devin-project', 'devin', 'glm-5.2', 600, + 100, 50, 0, 0.0020, '2026-05-01T00:00:00Z' + ); + INSERT INTO cc_session_days (session_id, day, msg_count) VALUES + ('devin-session', '2026-09-01', 10), + ('devin-old', '2026-05-01', 5);", + ) + .unwrap(); + + let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(); + let summary = project_devin_usage_at(&connection, today).unwrap(); + + assert_eq!(summary.status, "ready"); + assert_eq!(summary.sessions, 2); + assert_eq!(summary.generated_tokens, 1850); + assert_eq!(summary.cache_read_tokens, 250); + assert_eq!(summary.output_tokens, 400); + assert_eq!(summary.models[0].model, "glm-5.2"); + let week = summary + .windows + .iter() + .find(|window| window.window == "1w") + .unwrap(); + assert_eq!(week.since.as_deref(), Some("2026-08-26")); + assert_eq!(week.sessions, 1); + assert_eq!(week.generated_tokens, 1200); + assert_eq!(week.cache_read_tokens, 200); + let all = summary + .windows + .iter() + .find(|window| window.window == "all") + .unwrap(); + assert_eq!(all.sessions, 2); + assert_eq!(all.generated_tokens, summary.generated_tokens); + assert!(summary + .limitations + .iter() + .any(|limitation| limitation.contains("not live quota"))); } #[test] diff --git a/apps/desktop/src-tauri/src/commands/mcp_access.rs b/apps/desktop/src-tauri/src/commands/mcp_access.rs index 2201d670..096ee329 100644 --- a/apps/desktop/src-tauri/src/commands/mcp_access.rs +++ b/apps/desktop/src-tauri/src/commands/mcp_access.rs @@ -46,6 +46,26 @@ pub struct McpRepositorySettings { pub recent_audit: Vec, } +pub const MCP_SETTINGS_SCHEMA_VERSION: &str = "codevetter.mcp-settings/v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum McpSettingsOperation { + Read, + Enable, + Disable, + ClearAudit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct McpSettingsReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: McpSettingsOperation, + pub cleared_audit_rows: usize, + pub settings: McpRepositorySettings, +} + pub fn canonical_repo_path(repo_path: &str) -> Result { Path::new(repo_path) .canonicalize() @@ -242,7 +262,7 @@ fn git_head(repo_path: &str) -> Option { .filter(|value| !value.is_empty()) } -fn load_mcp_repository_settings( +pub fn load_mcp_repository_settings( repo_path: String, db: &DbState, ) -> Result { @@ -355,7 +375,7 @@ pub async fn get_mcp_repository_settings( .map_err(|_| "MCP settings worker failed".to_string())? } -fn update_mcp_repository_enabled( +pub fn update_mcp_repository_enabled( repo_path: String, enabled: bool, db: &DbState, @@ -410,7 +430,7 @@ pub async fn set_mcp_repository_enabled( .map_err(|_| "MCP settings worker failed".to_string())? } -fn delete_mcp_access_audit(repo_path: String, db: &DbState) -> Result { +pub fn delete_mcp_access_audit(repo_path: String, db: &DbState) -> Result { let canonical = canonical_repo_path(&repo_path)?; let connection = db.0.lock() @@ -425,6 +445,29 @@ fn delete_mcp_access_audit(repo_path: String, db: &DbState) -> Result Result { + let (settings, cleared_audit_rows) = match operation { + McpSettingsOperation::Read => (load_mcp_repository_settings(repo_path, db)?, 0), + McpSettingsOperation::Enable => (update_mcp_repository_enabled(repo_path, true, db)?, 0), + McpSettingsOperation::Disable => (update_mcp_repository_enabled(repo_path, false, db)?, 0), + McpSettingsOperation::ClearAudit => { + let cleared = delete_mcp_access_audit(repo_path.clone(), db)?; + (load_mcp_repository_settings(repo_path, db)?, cleared) + } + }; + Ok(McpSettingsReceipt { + schema_version: MCP_SETTINGS_SCHEMA_VERSION.to_string(), + generated_at: Utc::now().to_rfc3339(), + operation, + cleared_audit_rows, + settings, + }) +} + #[tauri::command] pub async fn clear_mcp_access_audit( repo_path: String, diff --git a/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs b/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs index 26b09f2a..d3c38aec 100644 --- a/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs +++ b/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs @@ -134,3 +134,43 @@ fn settings_preview_creates_a_stable_disabled_scope() { assert_eq!(first.client_config, second.client_config); assert!(first.client_config.is_some()); } + +#[test] +fn shared_mcp_settings_receipt_preserves_scope_and_authority_transitions() { + let fixture = tempfile::tempdir().expect("fixture"); + let repo = fixture.path().join("repo"); + std::fs::create_dir(&repo).expect("repo"); + let repo_path = repo + .canonicalize() + .expect("canonical repo") + .to_string_lossy() + .to_string(); + let connection = Connection::open(fixture.path().join("codevetter.db")).expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, + created_at, updated_at + ) VALUES (?1, 'fixture', 'indexed-head', 'ready', ?2, ?2)", + params![repo_path, Utc::now().to_rfc3339()], + ) + .expect("history"); + let db = DbState(Arc::new(Mutex::new(connection))); + + let read = run_mcp_settings_operation(repo_path.clone(), McpSettingsOperation::Read, &db) + .expect("read"); + assert_eq!(read.schema_version, MCP_SETTINGS_SCHEMA_VERSION); + assert!(!read.settings.enabled); + assert!(read.settings.client_config.is_some()); + + let enabled = run_mcp_settings_operation(repo_path.clone(), McpSettingsOperation::Enable, &db) + .expect("enable"); + assert!(enabled.settings.enabled); + assert_eq!(enabled.settings.repo_id, read.settings.repo_id); + + let disabled = + run_mcp_settings_operation(repo_path, McpSettingsOperation::Disable, &db).expect("disable"); + assert!(!disabled.settings.enabled); + assert_eq!(disabled.settings.repo_id, read.settings.repo_id); +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 76a5152a..ee8e79dd 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -10,12 +10,15 @@ pub mod business_rule_archaeology; pub mod cli_install; pub mod cli_stream; pub mod codex_app_server; +pub mod cross_review; pub mod deterministic_review; pub mod differential_verification; pub mod dora; pub mod evidence_pattern; pub mod evidence_scope; pub mod files; +pub mod fix_attempt; +pub mod fix_packet; pub mod git; pub mod git_metadata; pub mod graph_trust; @@ -24,6 +27,7 @@ pub mod history_evidence; pub mod history_graph; pub mod history_query; pub mod history_read; +pub mod history_roots; pub mod history_summary_graph; pub mod intel; pub mod local_check; @@ -32,16 +36,24 @@ pub mod local_usage; pub mod managed_work; pub mod mcp_access; pub mod native_agent_island; +pub mod native_settings; pub mod observability; +pub mod onboarding; +pub mod ops_status; pub(crate) mod outcome_risk_calibration; #[cfg(test)] mod perf_bench; pub mod performance_bridge; pub mod preferences; pub mod procedure_events; +pub mod qa_workspace; +pub mod repo_query; pub mod repo_workspace; pub mod resources; pub mod review; +pub mod review_intent; +pub mod rubric_settings; +pub mod run_history; pub mod sandbox; pub mod scenario_compiler_bridge; pub(crate) mod secret_policy; @@ -53,6 +65,7 @@ pub mod spec_coverage; pub mod structural_graph; pub mod synthetic_qa; pub mod taste; +pub mod tool_collectors; pub mod trex_preview; pub mod trex_watcher; pub mod unpack; diff --git a/apps/desktop/src-tauri/src/commands/native_settings.rs b/apps/desktop/src-tauri/src/commands/native_settings.rs new file mode 100644 index 00000000..bc3bc217 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/native_settings.rs @@ -0,0 +1,535 @@ +use crate::db::queries; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +pub const NATIVE_SETTINGS_SCHEMA_VERSION: &str = "codevetter.native-settings/v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NativeSettingKind { + Toggle, + Choice, + Text, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeSettingOption { + pub value: String, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeSettingValue { + pub key: String, + pub section: String, + pub label: String, + pub description: String, + pub kind: NativeSettingKind, + pub value: String, + pub default_value: String, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeSettingsReceipt { + pub schema_version: String, + pub generated_at: String, + pub database_available: bool, + pub saved_key: Option, + pub settings: Vec, + pub excluded_sensitive_keys: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct NativeSettingDefinition { + key: &'static str, + section: &'static str, + label: &'static str, + description: &'static str, + kind: NativeSettingKind, + default_value: &'static str, + options: &'static [(&'static str, &'static str)], +} + +const EMPTY_OPTIONS: &[(&str, &str)] = &[]; +const REVIEW_TONES: &[(&str, &str)] = &[ + ("concise", "Concise"), + ("thorough", "Thorough"), + ("mentoring", "Mentoring"), + ("strict", "Strict"), +]; +const ADAPTERS: &[(&str, &str)] = &[("claude-code", "Claude Code"), ("codex", "Codex")]; +const ROLES: &[(&str, &str)] = &[ + ("coder", "Coder"), + ("reviewer", "Reviewer"), + ("planner", "Planner"), + ("debugger", "Debugger"), +]; +const CONCURRENCY: &[(&str, &str)] = + &[("1", "1"), ("2", "2"), ("3", "3"), ("5", "5"), ("10", "10")]; +const TRAY_CADENCE: &[(&str, &str)] = &[ + ("manual", "Manual only"), + ("60", "Every minute"), + ("120", "Every 2 minutes"), + ("300", "Every 5 minutes"), + ("900", "Every 15 minutes"), +]; +const ISLAND_VOLUME: &[(&str, &str)] = &[("0.5", "Quiet"), ("0.8", "Balanced"), ("1", "Full")]; +const ISLAND_PACE: &[(&str, &str)] = + &[("0.4", "Measured"), ("0.48", "Balanced"), ("0.56", "Quick")]; +const ISLAND_COOLDOWN: &[(&str, &str)] = &[ + ("15", "15 seconds"), + ("30", "30 seconds"), + ("60", "1 minute"), +]; +const ISLAND_QUIET_START: &[(&str, &str)] = &[ + ("", "Off"), + ("20", "8 PM"), + ("21", "9 PM"), + ("22", "10 PM"), + ("23", "11 PM"), +]; +const ISLAND_QUIET_END: &[(&str, &str)] = &[ + ("", "Off"), + ("6", "6 AM"), + ("7", "7 AM"), + ("8", "8 AM"), + ("9", "9 AM"), +]; + +const DEFINITIONS: &[NativeSettingDefinition] = &[ + definition( + "review_tone", + "general", + "Default Review Tone", + "Default tone for a new review.", + NativeSettingKind::Choice, + "thorough", + REVIEW_TONES, + ), + definition( + "compact_mode", + "appearance", + "Compact Mode", + "Use denser spacing on supported workbench surfaces.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "show_line_numbers", + "appearance", + "Show Line Numbers", + "Show line identities in source and finding references.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "show_costs", + "appearance", + "Show Costs", + "Show available local cost evidence without inferring cloud quota.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "default_adapter", + "agents", + "Default Adapter", + "Preferred coding-agent adapter for new work.", + NativeSettingKind::Choice, + "claude-code", + ADAPTERS, + ), + definition( + "default_role", + "agents", + "Default Role", + "Default role assigned to a new agent launch.", + NativeSettingKind::Choice, + "coder", + ROLES, + ), + definition( + "max_concurrent_agents", + "agents", + "Max Concurrent Agents", + "Maximum number of agent processes allowed by the current preference.", + NativeSettingKind::Choice, + "3", + CONCURRENCY, + ), + definition( + "claude_cli_path", + "agents", + "Claude Code CLI", + "Optional explicit path; empty keeps executable discovery enabled.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), + definition( + "codex_cli_path", + "agents", + "Codex CLI", + "Optional explicit path; empty keeps executable discovery enabled.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), + definition( + "notify_review_done", + "notifications", + "Review Completed", + "Notify when a code review finishes.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "notify_agent_error", + "notifications", + "Agent Error", + "Notify when an agent reports a terminal error.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "notify_task_complete", + "notifications", + "Task Completed", + "Notify when an agent finishes a task.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "notify_quota_thresholds", + "notifications", + "Provider Quota Thresholds", + "Notify only from observed provider-window telemetry.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "notify_session_usage_thresholds", + "notifications", + "Session Usage Thresholds", + "Notify from indexed session context estimates when enabled.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "notification_sound", + "notifications", + "Notification Sounds", + "Play the configured local notification tone.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "tray_refresh_cadence_secs", + "notifications", + "Menu Bar Refresh Cadence", + "Polling cadence for observed live-provider usage.", + NativeSettingKind::Choice, + "300", + TRAY_CADENCE, + ), + definition( + "native_agent_island_enabled", + "agent_island", + "Native Agent Island", + "Retain the opt-in preference for the supervised macOS agent-status surface.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speech_muted", + "agent_island", + "Mute Voice Callouts", + "Keep visual status available without speaking agent updates.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speak_completion", + "agent_island", + "Speak Completions", + "Announce the provider and project when a turn finishes.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speak_attention", + "agent_island", + "Speak Attention Requests", + "Announce confirmed questions and permission requests.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speak_failure", + "agent_island", + "Speak Failures", + "Announce when an owned agent session fails.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speech_volume", + "agent_island", + "Voice Volume", + "Set the local system voice volume for Agent Island callouts.", + NativeSettingKind::Choice, + "0.8", + ISLAND_VOLUME, + ), + definition( + "native_agent_island_speech_rate", + "agent_island", + "Voice Pace", + "Choose a calm local speech rate.", + NativeSettingKind::Choice, + "0.48", + ISLAND_PACE, + ), + definition( + "native_agent_island_speech_cooldown", + "agent_island", + "Repeat Cooldown", + "Coalesce repeated callouts for the same session and state.", + NativeSettingKind::Choice, + "30", + ISLAND_COOLDOWN, + ), + definition( + "native_agent_island_quiet_start", + "agent_island", + "Quiet Hours Start", + "Optional local hour when voice callouts pause.", + NativeSettingKind::Choice, + "", + ISLAND_QUIET_START, + ), + definition( + "native_agent_island_quiet_end", + "agent_island", + "Quiet Hours End", + "Optional local hour when voice callouts resume.", + NativeSettingKind::Choice, + "", + ISLAND_QUIET_END, + ), + definition( + "native_agent_island_codex_voice", + "agent_island", + "Codex Voice", + "Optional macOS voice identifier; empty preserves the distinct system default.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_claude_voice", + "agent_island", + "Claude Voice", + "Optional macOS voice identifier; empty preserves the distinct system default.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), +]; + +const fn definition( + key: &'static str, + section: &'static str, + label: &'static str, + description: &'static str, + kind: NativeSettingKind, + default_value: &'static str, + options: &'static [(&'static str, &'static str)], +) -> NativeSettingDefinition { + NativeSettingDefinition { + key, + section, + label, + description, + kind, + default_value, + options, + } +} + +pub fn list_native_settings( + connection: Option<&Connection>, +) -> Result { + settings_receipt(connection, None) +} + +pub fn set_native_setting( + connection: &Connection, + key: &str, + value: &str, +) -> Result { + let definition = DEFINITIONS + .iter() + .find(|definition| definition.key == key) + .ok_or_else(|| format!("setting `{key}` is not in the native non-secret allowlist"))?; + validate_value(definition, value)?; + queries::set_preference(connection, key, value) + .map_err(|error| format!("save native setting `{key}`: {error}"))?; + settings_receipt(Some(connection), Some(key.to_string())) +} + +fn settings_receipt( + connection: Option<&Connection>, + saved_key: Option, +) -> Result { + let mut settings = Vec::with_capacity(DEFINITIONS.len()); + for definition in DEFINITIONS { + let persisted = connection + .map(|connection| queries::get_preference(connection, definition.key)) + .transpose() + .map_err(|error| format!("read native setting `{}`: {error}", definition.key))? + .flatten(); + let value = persisted.unwrap_or_else(|| definition.default_value.to_string()); + validate_value(definition, &value).map_err(|error| { + format!( + "stored native setting `{}` is invalid and was not projected: {error}", + definition.key + ) + })?; + settings.push(NativeSettingValue { + key: definition.key.to_string(), + section: definition.section.to_string(), + label: definition.label.to_string(), + description: definition.description.to_string(), + kind: definition.kind, + value, + default_value: definition.default_value.to_string(), + options: definition + .options + .iter() + .map(|(value, label)| NativeSettingOption { + value: (*value).to_string(), + label: (*label).to_string(), + }) + .collect(), + }); + } + Ok(NativeSettingsReceipt { + schema_version: NATIVE_SETTINGS_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + database_available: connection.is_some(), + saved_key, + settings, + excluded_sensitive_keys: vec!["github_token".to_string()], + }) +} + +fn validate_value(definition: &NativeSettingDefinition, value: &str) -> Result<(), String> { + if value.contains('\0') || value.len() > 1_024 { + return Err("value must be at most 1024 characters and contain no NUL byte".to_string()); + } + if matches!( + definition.key, + "native_agent_island_codex_voice" | "native_agent_island_claude_voice" + ) && (value.chars().count() > 256 || value.chars().any(char::is_control)) + { + return Err( + "Agent Island voice identifiers must be at most 256 characters and contain no control characters" + .to_string(), + ); + } + match definition.kind { + NativeSettingKind::Toggle if value != "true" && value != "false" => { + Err("toggle value must be true or false".to_string()) + } + NativeSettingKind::Choice + if !definition + .options + .iter() + .any(|(candidate, _)| *candidate == value) => + { + Err("value is not one of the declared options".to_string()) + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn native_settings_project_only_allowlisted_non_secret_values() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + queries::set_preference(&connection, "github_token", "secret-value").expect("secret"); + let receipt = list_native_settings(Some(&connection)).expect("settings"); + + assert_eq!(receipt.schema_version, NATIVE_SETTINGS_SCHEMA_VERSION); + assert!(receipt + .settings + .iter() + .all(|setting| setting.key != "github_token")); + assert_eq!(receipt.excluded_sensitive_keys, vec!["github_token"]); + assert!(!serde_json::to_string(&receipt) + .expect("json") + .contains("secret-value")); + } + + #[test] + fn native_settings_validate_and_round_trip_declared_values() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + + let receipt = set_native_setting(&connection, "review_tone", "strict").expect("save"); + assert_eq!(receipt.saved_key.as_deref(), Some("review_tone")); + assert_eq!( + receipt + .settings + .iter() + .find(|setting| setting.key == "review_tone") + .map(|setting| setting.value.as_str()), + Some("strict") + ); + assert!(set_native_setting(&connection, "review_tone", "invented").is_err()); + assert!(set_native_setting(&connection, "github_token", "secret").is_err()); + + let island = set_native_setting(&connection, "native_agent_island_enabled", "true") + .expect("save island setting"); + let island_settings = island + .settings + .iter() + .filter(|setting| setting.section == "agent_island") + .collect::>(); + assert_eq!(island_settings.len(), 12); + assert_eq!( + island_settings + .iter() + .find(|setting| setting.key == "native_agent_island_enabled") + .map(|setting| setting.value.as_str()), + Some("true") + ); + assert!(set_native_setting( + &connection, + "native_agent_island_codex_voice", + &"a".repeat(257), + ) + .is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/observability.rs b/apps/desktop/src-tauri/src/commands/observability.rs index 54ae62d0..e3a9bd87 100644 --- a/apps/desktop/src-tauri/src/commands/observability.rs +++ b/apps/desktop/src-tauri/src/commands/observability.rs @@ -11,7 +11,7 @@ use std::time::Duration; -use rusqlite::params; +use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -91,10 +91,15 @@ pub struct SendNotificationInput { #[tauri::command] pub async fn get_billing_config(db: State<'_, DbState>) -> Result { - Ok(BillingConfig { - anthropic_configured: read_pref(&db, PREF_ANTHROPIC_ADMIN).is_some(), - openai_configured: read_pref(&db, PREF_OPENAI_ADMIN).is_some(), - }) + let conn = db.0.lock().map_err(|error| error.to_string())?; + Ok(billing_config_from_connection(&conn)) +} + +pub fn billing_config_from_connection(conn: &Connection) -> BillingConfig { + BillingConfig { + anthropic_configured: read_pref_from_connection(conn, PREF_ANTHROPIC_ADMIN).is_some(), + openai_configured: read_pref_from_connection(conn, PREF_OPENAI_ADMIN).is_some(), + } } #[tauri::command] @@ -361,8 +366,15 @@ pub async fn get_agent_observability( db: State<'_, DbState>, window_days: Option, ) -> Result { + let conn = db.0.lock().map_err(|error| error.to_string())?; + Ok(agent_observability_from_connection(&conn, window_days)) +} + +pub fn agent_observability_from_connection( + conn: &Connection, + window_days: Option, +) -> AgentObservability { let window = window_days.unwrap_or(30); - let conn = db.0.lock().map_err(|e| e.to_string())?; let mut rows: Vec = Vec::new(); // ── Reviews (status, duration from started_at→completed_at). @@ -504,26 +516,32 @@ pub async fn get_agent_observability( } } - Ok(AgentObservability { + AgentObservability { rows, window_days: window, - }) + } } // ─── Webhook notifications ────────────────────────────────────────────────── #[tauri::command] pub async fn get_webhook_config(db: State<'_, DbState>) -> Result { - let url = read_pref(&db, PREF_NOTIF_WEBHOOK); - let flavor = read_pref(&db, PREF_NOTIF_FLAVOR).unwrap_or_else(|| "slack".to_string()); - Ok(WebhookConfig { + let conn = db.0.lock().map_err(|error| error.to_string())?; + Ok(webhook_config_from_connection(&conn)) +} + +pub fn webhook_config_from_connection(conn: &Connection) -> WebhookConfig { + let url = read_pref_from_connection(conn, PREF_NOTIF_WEBHOOK); + let flavor = + read_pref_from_connection(conn, PREF_NOTIF_FLAVOR).unwrap_or_else(|| "slack".to_string()); + WebhookConfig { configured: url.is_some(), url_preview: url.as_ref().map(|u| { let head: String = u.chars().take(40).collect(); format!("{head}…") }), flavor, - }) + } } #[tauri::command] @@ -636,6 +654,10 @@ fn severity_color(sev: &str) -> i64 { fn read_pref(db: &State<'_, DbState>, key: &str) -> Option { let conn = db.0.lock().ok()?; + read_pref_from_connection(&conn, key) +} + +fn read_pref_from_connection(conn: &Connection, key: &str) -> Option { conn.query_row( "SELECT value FROM preferences WHERE key = ?1", params![key], diff --git a/apps/desktop/src-tauri/src/commands/onboarding.rs b/apps/desktop/src-tauri/src/commands/onboarding.rs new file mode 100644 index 00000000..1d11d3fc --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/onboarding.rs @@ -0,0 +1,185 @@ +use crate::commands::native_settings::{list_native_settings, set_native_setting}; +use crate::commands::review::resolve_cli_path; +use crate::db::queries; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +pub const ONBOARDING_SCHEMA_VERSION: &str = "codevetter.onboarding/v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OnboardingOperation { + Inspect, + Complete, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OnboardingToolStatus { + pub id: String, + pub label: String, + pub available: bool, + pub role: String, + pub authentication: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OnboardingReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: OnboardingOperation, + pub completed: bool, + pub completion_source: String, + pub default_adapter: String, + pub tools: Vec, + pub limitations: Vec, +} + +pub fn inspect_onboarding(connection: Option<&Connection>) -> Result { + onboarding_receipt(connection, OnboardingOperation::Inspect) +} + +pub fn complete_onboarding( + connection: &Connection, + default_adapter: &str, +) -> Result { + connection + .execute_batch("BEGIN IMMEDIATE TRANSACTION") + .map_err(|error| format!("begin onboarding update: {error}"))?; + let result = (|| { + set_native_setting(connection, "default_adapter", default_adapter)?; + queries::set_preference(connection, "onboarding_complete", "true") + .map_err(|error| format!("save onboarding completion: {error}"))?; + Ok::<(), String>(()) + })(); + match result { + Ok(()) => connection + .execute_batch("COMMIT") + .map_err(|error| format!("commit onboarding update: {error}"))?, + Err(error) => { + let _ = connection.execute_batch("ROLLBACK"); + return Err(error); + } + } + onboarding_receipt(Some(connection), OnboardingOperation::Complete) +} + +fn onboarding_receipt( + connection: Option<&Connection>, + operation: OnboardingOperation, +) -> Result { + let completed = connection + .map(|connection| queries::get_preference(connection, "onboarding_complete")) + .transpose() + .map_err(|error| format!("read onboarding completion: {error}"))? + .flatten() + .is_some_and(|value| value == "true"); + let default_adapter = list_native_settings(connection)? + .settings + .into_iter() + .find(|setting| setting.key == "default_adapter") + .map(|setting| setting.value) + .unwrap_or_else(|| "claude-code".to_string()); + let tools = [ + ( + "codex", + "Codex CLI", + "Runs configured Codex review and fix work", + ), + ( + "claude", + "Claude Code CLI", + "Runs configured Claude review and fix work", + ), + ( + "gh", + "GitHub CLI", + "Supplies optional repository and pull-request access", + ), + ] + .into_iter() + .map(|(id, label, role)| OnboardingToolStatus { + id: id.to_string(), + label: label.to_string(), + available: Path::new(&resolve_cli_path(id)).is_file(), + role: role.to_string(), + authentication: "not_inspected".to_string(), + }) + .collect::>(); + let selected_binary = if default_adapter == "codex" { + "codex" + } else { + "claude" + }; + let mut limitations = vec![ + "Tool readiness checks executable presence only; authentication and credentials are never inspected." + .to_string(), + "Completing onboarding changes only the shared completion flag and default agent adapter." + .to_string(), + ]; + if !tools + .iter() + .any(|tool| tool.id == selected_binary && tool.available) + { + limitations.push(format!( + "The selected {default_adapter} adapter is not currently discoverable; verification remains fail-closed until it is available." + )); + } + Ok(OnboardingReceipt { + schema_version: ONBOARDING_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + operation, + completed, + completion_source: "shared_tauri_native_preference".to_string(), + default_adapter, + tools, + limitations, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn onboarding_reuses_legacy_completion_without_inspecting_credentials() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + queries::set_preference(&connection, "onboarding_complete", "true").expect("completion"); + queries::set_preference(&connection, "github_token", "fixture-secret").expect("secret"); + + let receipt = inspect_onboarding(Some(&connection)).expect("receipt"); + + assert!(receipt.completed); + assert_eq!(receipt.completion_source, "shared_tauri_native_preference"); + assert!(receipt + .tools + .iter() + .all(|tool| tool.authentication == "not_inspected")); + assert!(!serde_json::to_string(&receipt) + .expect("json") + .contains("fixture-secret")); + } + + #[test] + fn onboarding_completion_updates_only_declared_non_secret_preferences() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + + let receipt = complete_onboarding(&connection, "codex").expect("complete"); + + assert!(receipt.completed); + assert_eq!(receipt.operation, OnboardingOperation::Complete); + assert_eq!(receipt.default_adapter, "codex"); + assert_eq!( + queries::get_preference(&connection, "onboarding_complete").expect("completion"), + Some("true".to_string()) + ); + assert_eq!( + queries::get_preference(&connection, "default_adapter").expect("adapter"), + Some("codex".to_string()) + ); + assert!(complete_onboarding(&connection, "unknown").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/ops_status.rs b/apps/desktop/src-tauri/src/commands/ops_status.rs new file mode 100644 index 00000000..1f542903 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/ops_status.rs @@ -0,0 +1,160 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use super::observability::{ + agent_observability_from_connection, billing_config_from_connection, + webhook_config_from_connection, TaskTypeStats, +}; + +pub const OPS_STATUS_SCHEMA_VERSION: &str = "codevetter.ops-status/v1"; +pub const OPS_WINDOWS: &[u32] = &[7, 30, 90]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpsStatusReceipt { + pub schema_version: String, + pub generated_at: String, + pub database_available: bool, + pub window_days: u32, + pub billing: OpsBillingStatus, + pub webhook: OpsWebhookStatus, + pub observability: Vec, + pub excluded_sensitive_keys: Vec, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpsBillingStatus { + pub anthropic_configured: bool, + pub openai_configured: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpsWebhookStatus { + pub configured: bool, + pub flavor: String, +} + +pub fn inspect_ops_status( + connection: Option<&Connection>, + window_days: u32, +) -> Result { + if !OPS_WINDOWS.contains(&window_days) { + return Err("Ops window must be one of 7, 30, or 90 days".to_string()); + } + + let (billing, webhook, observability) = if let Some(connection) = connection { + let billing = billing_config_from_connection(connection); + let webhook = webhook_config_from_connection(connection); + let flavor = match webhook.flavor.as_str() { + "slack" | "discord" | "generic" => webhook.flavor, + _ => "unknown".to_string(), + }; + let observability = agent_observability_from_connection(connection, Some(window_days)); + ( + OpsBillingStatus { + anthropic_configured: billing.anthropic_configured, + openai_configured: billing.openai_configured, + }, + OpsWebhookStatus { + configured: webhook.configured, + flavor, + }, + observability.rows, + ) + } else { + ( + OpsBillingStatus { + anthropic_configured: false, + openai_configured: false, + }, + OpsWebhookStatus { + configured: false, + flavor: "slack".to_string(), + }, + Vec::new(), + ) + }; + + Ok(OpsStatusReceipt { + schema_version: OPS_STATUS_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + database_available: connection.is_some(), + window_days, + billing, + webhook, + observability, + excluded_sensitive_keys: vec![ + "anthropic_admin_key".to_string(), + "openai_admin_key".to_string(), + "notif_webhook_url".to_string(), + ], + limitations: vec![ + "This read-only receipt never returns credentials or webhook URLs.".to_string(), + "It reads stored aggregate evidence only and never contacts a provider or webhook." + .to_string(), + "Credential writes, live billing refresh, and webhook tests remain incumbent authority." + .to_string(), + "Indexed sessions have no explicit failure signal and remain labelled as an aggregate proxy." + .to_string(), + ], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use rusqlite::params; + + #[test] + fn ops_status_is_aggregate_only_and_excludes_sensitive_values() { + let directory = tempfile::tempdir().expect("temporary app data"); + let connection = db::init_db(directory.path().to_path_buf()).expect("database"); + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["anthropic_admin_key", "secret-anthropic-value"], + ) + .expect("admin preference"); + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["notif_webhook_url", "https://hooks.example.invalid/private"], + ) + .expect("webhook preference"); + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["notif_webhook_flavor", "discord"], + ) + .expect("webhook flavor"); + + let receipt = inspect_ops_status(Some(&connection), 30).expect("Ops receipt"); + let encoded = serde_json::to_string(&receipt).expect("serialize receipt"); + + assert_eq!(receipt.schema_version, OPS_STATUS_SCHEMA_VERSION); + assert!(receipt.billing.anthropic_configured); + assert!(!receipt.billing.openai_configured); + assert!(receipt.webhook.configured); + assert_eq!(receipt.webhook.flavor, "discord"); + assert!(!encoded.contains("secret-anthropic-value")); + assert!(!encoded.contains("hooks.example.invalid")); + assert_eq!(receipt.excluded_sensitive_keys.len(), 3); + } + + #[test] + fn ops_status_rejects_unbounded_windows_and_unknown_flavors() { + let directory = tempfile::tempdir().expect("temporary app data"); + let connection = db::init_db(directory.path().to_path_buf()).expect("database"); + assert!(inspect_ops_status(Some(&connection), 365).is_err()); + + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["notif_webhook_flavor", "custom-private-flavor"], + ) + .expect("webhook flavor"); + let receipt = inspect_ops_status(Some(&connection), 7).expect("Ops receipt"); + assert_eq!(receipt.webhook.flavor, "unknown"); + } +} diff --git a/apps/desktop/src-tauri/src/commands/performance_bridge.rs b/apps/desktop/src-tauri/src/commands/performance_bridge.rs index e2e44511..765105ad 100644 --- a/apps/desktop/src-tauri/src/commands/performance_bridge.rs +++ b/apps/desktop/src-tauri/src/commands/performance_bridge.rs @@ -4,7 +4,7 @@ //! argument arrays. The Node runtime remains the single source of truth for //! planning, profiling, diagnosis, and paired verification contracts. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -13,6 +13,7 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sysinfo::{Pid, ProcessesToUpdate, System}; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::AsyncReadExt; use tokio::process::Command; @@ -88,6 +89,16 @@ pub struct PerformanceCleanupReceipt { pub temporary_profiles_retained: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PerformanceResourceReceipt { + pub sampler: Option, + pub sample_interval_ms: u64, + pub samples: u32, + pub peak_rss_bytes: Option, + pub peak_processes: Option, + pub limitations: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformanceRunReceipt { pub schema_version: u32, @@ -99,6 +110,87 @@ pub struct PerformanceRunReceipt { pub result: Value, pub stderr_summary: Option, pub cleanup: PerformanceCleanupReceipt, + #[serde(default)] + pub resources: PerformanceResourceReceipt, +} + +struct PerformanceResourceSampler { + root_pid: Option, + system: System, + samples: u32, + peak_rss_bytes: u64, + peak_processes: u32, +} + +impl PerformanceResourceSampler { + fn new(root_pid: Option) -> Self { + Self { + root_pid: root_pid.map(Pid::from_u32), + system: System::new(), + samples: 0, + peak_rss_bytes: 0, + peak_processes: 0, + } + } + + fn sample(&mut self) { + let Some(root_pid) = self.root_pid else { + return; + }; + self.system.refresh_processes(ProcessesToUpdate::All, true); + let process_ids = owned_process_tree(&self.system, root_pid); + if process_ids.is_empty() { + return; + } + let rss_bytes = process_ids + .iter() + .filter_map(|pid| self.system.process(*pid)) + .map(|process| process.memory()) + .sum(); + self.samples = self.samples.saturating_add(1); + self.peak_rss_bytes = self.peak_rss_bytes.max(rss_bytes); + self.peak_processes = self + .peak_processes + .max(process_ids.len().try_into().unwrap_or(u32::MAX)); + } + + fn receipt(self) -> PerformanceResourceReceipt { + let sampled = self.samples > 0; + PerformanceResourceReceipt { + sampler: sampled.then(|| "sysinfo_owned_process_tree".to_string()), + sample_interval_ms: 75, + samples: self.samples, + peak_rss_bytes: sampled.then_some(self.peak_rss_bytes), + peak_processes: sampled.then_some(self.peak_processes), + limitations: vec![if sampled { + "RSS and process counts are periodic owned-process-tree samples; short-lived peaks between samples may be missed." + .to_string() + } else { + "Owned-process resource sampling was unavailable for this run.".to_string() + }], + } + } +} + +fn owned_process_tree(system: &System, root_pid: Pid) -> HashSet { + if system.process(root_pid).is_none() { + return HashSet::new(); + } + let mut process_ids = HashSet::from([root_pid]); + loop { + let before = process_ids.len(); + for (pid, process) in system.processes() { + if process + .parent() + .is_some_and(|parent| process_ids.contains(&parent)) + { + process_ids.insert(*pid); + } + } + if process_ids.len() == before { + return process_ids; + } + } } #[derive(Debug, Clone, Serialize)] @@ -131,8 +223,9 @@ pub async fn run_local_performance( registry: registry.inner(), request_id: validated.request_id.clone(), }; + let cli_path = resolve_cli_path(&app)?; emit_progress(&app, &validated, "started"); - let receipt = execute(&app, &validated, cancellation).await; + let receipt = execute(&validated, cancellation, cli_path).await; emit_progress( &app, &validated, @@ -148,6 +241,14 @@ pub async fn run_local_performance( receipt } +pub async fn run_headless_performance( + input: PerformanceRunInput, +) -> Result { + let validated = validate_input(input)?; + let cli_path = resolve_headless_cli_path()?; + execute(&validated, Arc::new(AtomicBool::new(false)), cli_path).await +} + #[tauri::command] pub fn cancel_local_performance( registry: State<'_, PerformanceRunRegistry>, @@ -190,12 +291,11 @@ fn emit_progress(app: &AppHandle, input: &PerformanceRunInput, stage: &'static s } async fn execute( - app: &AppHandle, input: &PerformanceRunInput, cancellation: Arc, + cli_path: PathBuf, ) -> Result { let started = Instant::now(); - let cli_path = resolve_cli_path(app)?; let args = build_arguments(input)?; ensure_node_available().await?; @@ -212,6 +312,7 @@ async fn execute( let mut child = command .spawn() .map_err(|error| format!("Could not start the local performance runtime: {error}"))?; + let mut resource_sampler = PerformanceResourceSampler::new(child.id()); let stdout = child .stdout @@ -227,6 +328,7 @@ async fn execute( let deadline = tokio::time::Instant::now() + overall_timeout; let mut cancelled = false; let status = loop { + resource_sampler.sample(); if cancellation.load(Ordering::SeqCst) { cancelled = true; child @@ -247,6 +349,7 @@ async fn execute( })?; let stdout_bytes = stdout_task.await.map_err(join_error)??; let stderr_bytes = stderr_task.await.map_err(join_error)??; + let resources = resource_sampler.receipt(); return Ok(no_confidence_receipt( input, started, @@ -254,6 +357,7 @@ async fn execute( "The bounded desktop performance operation timed out.", &stderr_bytes, Some(&stdout_bytes), + resources, )); } if let Some(status) = child @@ -267,6 +371,7 @@ async fn execute( let stdout_bytes = stdout_task.await.map_err(join_error)??; let stderr_bytes = stderr_task.await.map_err(join_error)??; + let resources = resource_sampler.receipt(); if cancelled { return Ok(PerformanceRunReceipt { schema_version: 1, @@ -282,6 +387,7 @@ async fn execute( }), stderr_summary: sanitize_summary(&stderr_bytes, &input.repo_path), cleanup: cleanup_receipt(), + resources, }); } @@ -291,6 +397,7 @@ async fn execute( elapsed_ms(started), &stdout_bytes, &stderr_bytes, + resources, ) } @@ -300,6 +407,7 @@ fn receipt_from_output( duration_ms: u64, stdout: &[u8], stderr: &[u8], + resources: PerformanceResourceReceipt, ) -> Result { let mut result: Value = serde_json::from_slice(stdout).map_err(|_| { "The local performance runtime returned malformed or excessive output".to_string() @@ -320,6 +428,7 @@ fn receipt_from_output( result, stderr_summary: sanitize_summary(stderr, &input.repo_path), cleanup: cleanup_receipt(), + resources, }) } @@ -525,12 +634,8 @@ fn build_arguments(input: &PerformanceRunInput) -> Result, String> { } fn resolve_cli_path(app: &AppHandle) -> Result { - let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../scripts/runtime-failure-capsule/cli.mjs"); - if source.is_file() { - return source - .canonicalize() - .map_err(|error| format!("Could not resolve the performance runtime: {error}")); + if let Ok(source) = resolve_source_cli_path() { + return Ok(source); } let bundled = app .path() @@ -543,6 +648,34 @@ fn resolve_cli_path(app: &AppHandle) -> Result { Ok(bundled) } +fn resolve_headless_cli_path() -> Result { + if let Ok(source) = resolve_source_cli_path() { + return Ok(source); + } + let executable = std::env::current_exe() + .map_err(|error| format!("Could not resolve the CodeVetter executable: {error}"))?; + let bundled = executable + .parent() + .and_then(Path::parent) + .map(|contents| contents.join("Resources/runtime-failure-capsule/cli.mjs")) + .ok_or_else(|| "The packaged local performance runtime is unavailable".to_string())?; + if !bundled.is_file() { + return Err("The packaged local performance runtime is unavailable".to_string()); + } + Ok(bundled) +} + +fn resolve_source_cli_path() -> Result { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../scripts/runtime-failure-capsule/cli.mjs"); + if !source.is_file() { + return Err("The source performance runtime is unavailable".to_string()); + } + source + .canonicalize() + .map_err(|error| format!("Could not resolve the performance runtime: {error}")) +} + async fn ensure_node_available() -> Result<(), String> { let status = Command::new("node") .arg("--version") @@ -575,6 +708,7 @@ fn no_confidence_receipt( message: &str, stderr: &[u8], stdout: Option<&[u8]>, + resources: PerformanceResourceReceipt, ) -> PerformanceRunReceipt { PerformanceRunReceipt { schema_version: 1, @@ -591,6 +725,7 @@ fn no_confidence_receipt( }), stderr_summary: sanitize_summary(stderr, &input.repo_path), cleanup: cleanup_receipt(), + resources, } } @@ -814,15 +949,40 @@ mod tests { "limitations": ["Exact fixture scope only."] }); let stdout = serde_json::to_vec(&runtime_result).unwrap(); - let receipt = receipt_from_output(&validated, Some(0), 17, &stdout, b"").unwrap(); + let resources = PerformanceResourceReceipt { + sampler: Some("fixture".into()), + sample_interval_ms: 75, + samples: 2, + peak_rss_bytes: Some(1_048_576), + peak_processes: Some(2), + limitations: vec!["Fixture sample.".into()], + }; + let receipt = + receipt_from_output(&validated, Some(0), 17, &stdout, b"", resources.clone()).unwrap(); assert_eq!(receipt.result, runtime_result); assert_eq!(receipt.operation, PerformanceOperation::Plan); assert_eq!(receipt.state, "succeeded"); assert!(receipt.cleanup.owned_process_reaped); - assert!( - receipt_from_output(&validated, Some(0), 0, b"not-json", b"") - .unwrap_err() - .contains("malformed") - ); + assert_eq!(receipt.resources.peak_rss_bytes, Some(1_048_576)); + assert!(receipt_from_output( + &validated, + Some(0), + 0, + b"not-json", + b"", + PerformanceResourceReceipt::default(), + ) + .unwrap_err() + .contains("malformed")); + } + + #[test] + fn resource_sampler_records_the_owned_process_tree() { + let mut sampler = PerformanceResourceSampler::new(Some(std::process::id())); + sampler.sample(); + let receipt = sampler.receipt(); + assert!(receipt.samples >= 1); + assert!(receipt.peak_rss_bytes.is_some_and(|bytes| bytes > 0)); + assert!(receipt.peak_processes.is_some_and(|count| count >= 1)); } } diff --git a/apps/desktop/src-tauri/src/commands/qa_workspace.rs b/apps/desktop/src-tauri/src/commands/qa_workspace.rs new file mode 100644 index 00000000..715dc0bd --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/qa_workspace.rs @@ -0,0 +1,712 @@ +use crate::db::queries; +use chrono::{DateTime, Utc}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const QA_WORKSPACE_SCHEMA: &str = "codevetter.qa-workspace/v1"; +const NATIVE_WORKFLOW_PREFIX: &str = "native_testing_qa_workflows_v1"; +const LEGACY_WORKFLOW_PREFIX: &str = "quick_review_qa_workflows"; +const LEGACY_PRESET_PREFIX: &str = "quick_review_qa_preset"; +const MAX_WORKFLOWS: usize = 12; +const MAX_TARGETS: usize = 16; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaTargetPreset { + pub id: String, + pub name: String, + pub route: String, + pub goal: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StoredQaWorkflow { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub loop_id: String, + #[serde(default = "default_runner")] + pub runner_type: String, + #[serde(default)] + pub goal: String, + #[serde(default)] + pub repo_spec_path: String, + #[serde(default = "default_trace_mode")] + pub repo_trace_mode: String, + #[serde(default)] + pub target_route: String, + #[serde(default)] + pub allow_remote_target: bool, + #[serde(default)] + pub targets: Vec, + #[serde(default)] + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaWorkflow { + pub id: String, + pub name: String, + pub base_url: String, + pub loop_id: String, + pub runner_type: String, + pub goal: String, + pub repo_spec_path: String, + pub repo_trace_mode: String, + pub target_route: String, + pub allow_remote_target: bool, + pub targets: Vec, + pub updated_at: String, + pub editable: bool, + pub limitation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaSpecCandidate { + pub path: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaRerunRun { + pub id: String, + pub created_at: String, + pub runner_type: String, + pub base_url: String, + pub loop_id: String, + pub route: String, + pub goal: String, + pub pass: bool, + pub duration_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaPostFixPreparation { + pub status: String, + pub summary: String, + pub before: QaRerunRun, + pub after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaWorkspaceReceipt { + pub schema_version: String, + pub repo_path: String, + pub preference_key: String, + pub source: String, + pub workflows: Vec, + pub specs: Vec, + pub post_fix: Option, + pub limitations: Vec, +} + +#[derive(Debug, Clone)] +pub enum QaWorkspaceMutation { + Inspect, + SaveWorkflow(StoredQaWorkflow), + DeleteWorkflow { + workflow_id: String, + }, + SaveTarget { + workflow_id: String, + target: QaTargetPreset, + }, + DeleteTarget { + workflow_id: String, + target_id: String, + }, +} + +fn default_runner() -> String { + "playwright_builtin".to_string() +} + +fn default_trace_mode() -> String { + "retain-on-failure".to_string() +} + +fn stable_preference_suffix(value: &str) -> String { + let mut hash: u32 = 2_166_136_261; + for unit in value.encode_utf16() { + hash ^= u32::from(unit); + hash = hash.wrapping_mul(16_777_619); + } + to_base36(hash) +} + +fn to_base36(mut value: u32) -> String { + if value == 0 { + return "0".to_string(); + } + let mut out = Vec::new(); + while value > 0 { + let digit = value % 36; + out.push(if digit < 10 { + (b'0' + digit as u8) as char + } else { + (b'a' + (digit - 10) as u8) as char + }); + value /= 36; + } + out.iter().rev().collect() +} + +fn scoped_key(prefix: &str, repo_path: &str) -> String { + format!( + "{prefix}_repo_{}", + stable_preference_suffix(repo_path.trim()) + ) +} + +fn required_text(value: &str, field: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{field} is required")); + } + if value.chars().count() > max { + return Err(format!("{field} must be at most {max} characters")); + } + Ok(value.to_string()) +} + +fn normalize_route(value: &str) -> Result { + let value = required_text(value, "route", 240)?; + if !value.starts_with('/') || value.starts_with("//") { + return Err("route must be a repository-relative browser path beginning with /".into()); + } + Ok(value) +} + +fn normalize_spec_path(repo: &Path, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + let relative = Path::new(value); + if relative.is_absolute() + || relative + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err("repo_spec_path must remain repository-relative".into()); + } + let candidate = repo.join(relative); + if !candidate.is_file() { + return Err(format!("repo_spec_path does not exist: {value}")); + } + Ok(value.replace('\\', "/")) +} + +fn normalize_target(target: QaTargetPreset) -> Result { + Ok(QaTargetPreset { + id: required_text(&target.id, "target id", 100)?, + name: required_text(&target.name, "target name", 100)?, + route: normalize_route(&target.route)?, + goal: required_text(&target.goal, "target goal", 500)?, + }) +} + +fn normalize_workflow(repo: &Path, workflow: StoredQaWorkflow) -> Result { + let runner_type = required_text(&workflow.runner_type, "runner_type", 40)?; + if !matches!( + runner_type.as_str(), + "playwright_builtin" | "repo_playwright" + ) { + return Err("native QA workflows support playwright_builtin or repo_playwright; arbitrary external commands remain legacy-only".into()); + } + let repo_trace_mode = required_text(&workflow.repo_trace_mode, "repo_trace_mode", 40)?; + if !matches!(repo_trace_mode.as_str(), "off" | "retain-on-failure" | "on") { + return Err("repo_trace_mode must be off, retain-on-failure, or on".into()); + } + let mut targets = workflow + .targets + .into_iter() + .map(normalize_target) + .collect::, _>>()?; + targets.truncate(MAX_TARGETS); + let base_url = workflow.base_url.trim().trim_end_matches('/').to_string(); + if !base_url.is_empty() { + let url = reqwest::Url::parse(&base_url) + .map_err(|_| "base_url must be a valid HTTP(S) URL".to_string())?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err("base_url must be a valid HTTP(S) URL".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("base_url must not contain embedded credentials".into()); + } + } + Ok(StoredQaWorkflow { + id: required_text(&workflow.id, "workflow id", 100)?, + name: required_text(&workflow.name, "workflow name", 100)?, + base_url, + loop_id: required_text(&workflow.loop_id, "loop_id", 100)?, + runner_type, + goal: required_text(&workflow.goal, "goal", 500)?, + repo_spec_path: normalize_spec_path(repo, &workflow.repo_spec_path)?, + repo_trace_mode, + target_route: normalize_route(&workflow.target_route)?, + allow_remote_target: workflow.allow_remote_target, + targets, + updated_at: Utc::now().to_rfc3339(), + }) +} + +fn project_workflow(workflow: StoredQaWorkflow) -> QaWorkflow { + let editable = matches!( + workflow.runner_type.as_str(), + "playwright_builtin" | "repo_playwright" + ); + let mut limitations = Vec::new(); + if !editable { + limitations.push( + "This legacy workflow uses an arbitrary external command. Native Testing will not expose or execute it." + .to_string(), + ); + } + let base_url = match reqwest::Url::parse(workflow.base_url.trim()) { + Ok(url) + if matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() => + { + workflow.base_url + } + _ if workflow.base_url.trim().is_empty() => String::new(), + _ => { + limitations.push( + "The legacy preview URL was omitted because it was invalid or credential-bearing." + .to_string(), + ); + String::new() + } + }; + QaWorkflow { + id: workflow.id, + name: workflow.name, + base_url, + loop_id: workflow.loop_id, + runner_type: workflow.runner_type, + goal: workflow.goal, + repo_spec_path: workflow.repo_spec_path, + repo_trace_mode: workflow.repo_trace_mode, + target_route: workflow.target_route, + allow_remote_target: workflow.allow_remote_target, + targets: workflow.targets.into_iter().take(MAX_TARGETS).collect(), + updated_at: workflow.updated_at, + editable, + limitation: (!limitations.is_empty()).then(|| limitations.join(" ")), + } +} + +fn parse_workflows(raw: Option) -> Vec { + raw.and_then(|value| serde_json::from_str::>(&value).ok()) + .unwrap_or_default() + .into_iter() + .take(MAX_WORKFLOWS) + .collect() +} + +fn legacy_preset(raw: Option) -> Vec { + raw.and_then(|value| serde_json::from_str::(&value).ok()) + .map(|mut workflow| { + workflow.id = "legacy-preset".into(); + if workflow.name.trim().is_empty() { + workflow.name = "Legacy QA preset".into(); + } + vec![workflow] + }) + .unwrap_or_default() +} + +fn load_workflows( + connection: &Connection, + repo_path: &str, +) -> Result<(String, Vec), String> { + let native_key = scoped_key(NATIVE_WORKFLOW_PREFIX, repo_path); + let native_raw = queries::get_preference(connection, &native_key).map_err(|e| e.to_string())?; + if native_raw.is_some() { + return Ok(("native".into(), parse_workflows(native_raw))); + } + let scoped_legacy_key = scoped_key(LEGACY_WORKFLOW_PREFIX, repo_path); + let scoped_legacy = parse_workflows( + queries::get_preference(connection, &scoped_legacy_key).map_err(|e| e.to_string())?, + ); + if !scoped_legacy.is_empty() { + return Ok(("legacy_projected".into(), scoped_legacy)); + } + let global_legacy = parse_workflows( + queries::get_preference(connection, LEGACY_WORKFLOW_PREFIX).map_err(|e| e.to_string())?, + ); + if !global_legacy.is_empty() { + return Ok(("legacy_global_projected".into(), global_legacy)); + } + let scoped_preset_key = scoped_key(LEGACY_PRESET_PREFIX, repo_path); + let scoped_preset = legacy_preset( + queries::get_preference(connection, &scoped_preset_key).map_err(|e| e.to_string())?, + ); + if !scoped_preset.is_empty() { + return Ok(("legacy_preset_projected".into(), scoped_preset)); + } + let global_preset = legacy_preset( + queries::get_preference(connection, LEGACY_PRESET_PREFIX).map_err(|e| e.to_string())?, + ); + Ok(( + if global_preset.is_empty() { + "empty" + } else { + "legacy_global_preset_projected" + } + .into(), + global_preset, + )) +} + +fn save_workflows( + connection: &Connection, + repo_path: &str, + workflows: &[StoredQaWorkflow], +) -> Result<(), String> { + let value = + serde_json::to_string(workflows).map_err(|e| format!("serialize QA workflows: {e}"))?; + queries::set_preference( + connection, + &scoped_key(NATIVE_WORKFLOW_PREFIX, repo_path), + &value, + ) + .map_err(|e| e.to_string()) +} + +fn flow_key(run: &queries::SyntheticQaRunRow) -> String { + format!( + "{}\0{}\0{}\0{}\0{}", + run.runner_type, + run.base_url.as_deref().unwrap_or_default(), + run.loop_id, + run.route.as_deref().unwrap_or_default(), + run.goal.as_deref().unwrap_or_default() + ) +} + +fn rerun_projection(run: &queries::SyntheticQaRunRow) -> QaRerunRun { + QaRerunRun { + id: run.id.clone(), + created_at: run.created_at.clone(), + runner_type: run.runner_type.clone(), + base_url: run.base_url.clone().unwrap_or_default(), + loop_id: run.loop_id.clone(), + route: run.route.clone().unwrap_or_else(|| "/".into()), + goal: run.goal.clone().unwrap_or_else(|| run.loop_id.clone()), + pass: run.pass, + duration_ms: run.duration_ms, + } +} + +fn post_fix_preparation( + connection: &Connection, + repo_path: &str, + fix_completed_at: Option<&str>, +) -> Result, String> { + let Some(fix_completed_at) = fix_completed_at else { + return Ok(None); + }; + let fix_time = DateTime::parse_from_rfc3339(fix_completed_at) + .map_err(|_| "fix_completed_at must be an RFC3339 timestamp".to_string())? + .with_timezone(&Utc); + let runs = queries::list_synthetic_qa_runs_for_repo(connection, repo_path, 50) + .map_err(|e| e.to_string())?; + let before = runs.iter().find(|run| { + DateTime::parse_from_rfc3339(&run.created_at) + .map(|time| time.with_timezone(&Utc) <= fix_time) + .unwrap_or(false) + }); + let Some(before) = before else { + return Ok(None); + }; + let key = flow_key(before); + let after = runs.iter().find(|run| { + DateTime::parse_from_rfc3339(&run.created_at) + .map(|time| time.with_timezone(&Utc) > fix_time) + .unwrap_or(false) + && flow_key(run) == key + }); + let (status, summary) = match after { + None => ( + "needs_rerun", + format!( + "Fix is ready for QA comparison: rerun {} with the same {} flow.", + before.route.as_deref().unwrap_or(&before.loop_id), + before.runner_type + ), + ), + Some(after) if !before.pass && after.pass => ( + "fixed", + "Post-fix QA passed; the prior matching flow failed and the rerun passed.".into(), + ), + Some(after) if !before.pass && !after.pass => ( + "still_broken", + "Post-fix QA still fails; both matching runs failed.".into(), + ), + Some(after) if before.pass && !after.pass => ( + "regressed", + "Post-fix QA regressed; the prior matching flow passed and the rerun failed.".into(), + ), + Some(_) => ( + "still_passing", + "Post-fix QA still passes for the matching flow.".into(), + ), + }; + Ok(Some(QaPostFixPreparation { + status: status.into(), + summary, + before: rerun_projection(before), + after: after.map(rerun_projection), + })) +} + +pub fn run_qa_workspace_headless( + connection: &Connection, + repo_path: PathBuf, + mutation: QaWorkspaceMutation, + fix_completed_at: Option<&str>, +) -> Result { + let repo_path = std::fs::canonicalize(&repo_path) + .map_err(|e| format!("repository {} is unavailable: {e}", repo_path.display()))?; + if !repo_path.is_dir() { + return Err("repo must be an existing directory".into()); + } + let repo = repo_path.to_string_lossy().into_owned(); + let (mut source, mut workflows) = load_workflows(connection, &repo)?; + match mutation { + QaWorkspaceMutation::Inspect => {} + QaWorkspaceMutation::SaveWorkflow(workflow) => { + let mut workflow = normalize_workflow(&repo_path, workflow)?; + if workflow.targets.is_empty() { + if let Some(existing) = workflows + .iter() + .find(|candidate| candidate.id == workflow.id) + { + workflow.targets = existing.targets.clone(); + } + } + workflows.retain(|candidate| candidate.id != workflow.id); + workflows.insert(0, workflow); + workflows.truncate(MAX_WORKFLOWS); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + QaWorkspaceMutation::DeleteWorkflow { workflow_id } => { + let workflow_id = required_text(&workflow_id, "workflow id", 100)?; + workflows.retain(|candidate| candidate.id != workflow_id); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + QaWorkspaceMutation::SaveTarget { + workflow_id, + target, + } => { + let workflow_id = required_text(&workflow_id, "workflow id", 100)?; + let target = normalize_target(target)?; + let workflow = workflows + .iter_mut() + .find(|candidate| candidate.id == workflow_id) + .ok_or_else(|| format!("workflow not found: {workflow_id}"))?; + if !matches!( + workflow.runner_type.as_str(), + "playwright_builtin" | "repo_playwright" + ) { + return Err( + "legacy external-command workflows are read-only in native Testing".into(), + ); + } + workflow + .targets + .retain(|candidate| candidate.id != target.id); + workflow.targets.insert(0, target); + workflow.targets.truncate(MAX_TARGETS); + workflow.updated_at = Utc::now().to_rfc3339(); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + QaWorkspaceMutation::DeleteTarget { + workflow_id, + target_id, + } => { + let workflow_id = required_text(&workflow_id, "workflow id", 100)?; + let target_id = required_text(&target_id, "target id", 100)?; + let workflow = workflows + .iter_mut() + .find(|candidate| candidate.id == workflow_id) + .ok_or_else(|| format!("workflow not found: {workflow_id}"))?; + workflow + .targets + .retain(|candidate| candidate.id != target_id); + workflow.updated_at = Utc::now().to_rfc3339(); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + } + + let specs = super::synthetic_qa::discover_playwright_specs_headless(&repo_path) + .into_iter() + .map(|candidate| QaSpecCandidate { + path: candidate.path, + reason: candidate.reason, + }) + .collect(); + let projected = workflows + .into_iter() + .map(project_workflow) + .collect::>(); + let mut limitations = vec![ + "Credential-bearing storage-state paths are never projected into this receipt.".into(), + "Arbitrary external-command workflows remain legacy-only and cannot execute from native Testing.".into(), + "Preparing a post-fix flow does not grant preview network consent or start browser execution.".into(), + ]; + if projected.iter().any(|workflow| !workflow.editable) { + limitations.push("At least one projected legacy workflow is read-only.".into()); + } + Ok(QaWorkspaceReceipt { + schema_version: QA_WORKSPACE_SCHEMA.into(), + repo_path: repo.clone(), + preference_key: scoped_key(NATIVE_WORKFLOW_PREFIX, &repo), + source, + workflows: projected, + specs, + post_fix: post_fix_preparation(connection, &repo, fix_completed_at)?, + limitations, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workflow(repo_spec_path: &str) -> StoredQaWorkflow { + StoredQaWorkflow { + id: "checkout".into(), + name: "Checkout".into(), + base_url: "http://localhost:1420/".into(), + loop_id: "checkout".into(), + runner_type: "repo_playwright".into(), + goal: "Complete checkout".into(), + repo_spec_path: repo_spec_path.into(), + repo_trace_mode: "retain-on-failure".into(), + target_route: "/checkout".into(), + allow_remote_target: false, + targets: vec![QaTargetPreset { + id: "primary".into(), + name: "Primary checkout".into(), + route: "/checkout".into(), + goal: "Complete checkout".into(), + }], + updated_at: String::new(), + } + } + + #[test] + fn scoped_key_matches_the_frontend_fnv_contract() { + assert_eq!( + scoped_key("quick_review_qa_workflows", "/fixture/repo"), + "quick_review_qa_workflows_repo_sfx8og" + ); + } + + #[test] + fn saves_safe_workflow_without_touching_legacy_preference() { + let repo = tempfile::tempdir().expect("repo"); + std::fs::create_dir_all(repo.path().join("tests")).expect("tests"); + std::fs::write( + repo.path().join("tests/checkout.spec.ts"), + "import { test } from '@playwright/test';", + ) + .expect("spec"); + let connection = Connection::open_in_memory().expect("db"); + connection + .execute_batch("CREATE TABLE preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL);") + .expect("schema"); + queries::set_preference(&connection, LEGACY_WORKFLOW_PREFIX, "legacy-value") + .expect("legacy"); + + let receipt = run_qa_workspace_headless( + &connection, + repo.path().to_path_buf(), + QaWorkspaceMutation::SaveWorkflow(workflow("tests/checkout.spec.ts")), + None, + ) + .expect("receipt"); + + assert_eq!(receipt.schema_version, QA_WORKSPACE_SCHEMA); + assert_eq!(receipt.source, "native"); + assert_eq!(receipt.workflows.len(), 1); + assert_eq!(receipt.specs[0].path, "tests/checkout.spec.ts"); + assert_eq!( + queries::get_preference(&connection, LEGACY_WORKFLOW_PREFIX).expect("legacy read"), + Some("legacy-value".into()) + ); + let persisted = queries::get_preference(&connection, &receipt.preference_key) + .expect("native read") + .expect("native value"); + assert!(!persisted.contains("storageStatePath")); + assert!(!persisted.contains("externalCommand")); + } + + #[test] + fn rejects_external_command_runner_and_parent_spec_paths() { + let repo = tempfile::tempdir().expect("repo"); + let mut candidate = workflow("../secret.json"); + candidate.runner_type = "external_skill".into(); + let error = normalize_workflow(repo.path(), candidate).expect_err("external rejected"); + assert!(error.contains("external commands")); + + let error = normalize_workflow(repo.path(), workflow("../secret.json")) + .expect_err("parent path rejected"); + assert!(error.contains("repository-relative")); + } + + #[test] + fn native_empty_state_does_not_resurface_legacy_and_legacy_urls_are_scrubbed() { + let repo = tempfile::tempdir().expect("repo"); + let connection = Connection::open_in_memory().expect("db"); + connection + .execute_batch("CREATE TABLE preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL);") + .expect("schema"); + let repo_path = std::fs::canonicalize(repo.path()).expect("canonical repo"); + let repo_text = repo_path.to_string_lossy(); + queries::set_preference( + &connection, + LEGACY_WORKFLOW_PREFIX, + r#"[{"id":"legacy","name":"Legacy","baseUrl":"https://user:password@example.test","loopId":"legacy","runnerType":"playwright_builtin","goal":"Smoke","targetRoute":"/"}]"#, + ) + .expect("legacy"); + let projected = run_qa_workspace_headless( + &connection, + repo_path.clone(), + QaWorkspaceMutation::Inspect, + None, + ) + .expect("projected"); + assert_eq!(projected.workflows[0].base_url, ""); + assert!(projected.workflows[0] + .limitation + .as_deref() + .is_some_and(|value| value.contains("credential-bearing"))); + + queries::set_preference( + &connection, + &scoped_key(NATIVE_WORKFLOW_PREFIX, &repo_text), + "[]", + ) + .expect("native empty"); + let empty = + run_qa_workspace_headless(&connection, repo_path, QaWorkspaceMutation::Inspect, None) + .expect("native empty receipt"); + assert_eq!(empty.source, "native"); + assert!(empty.workflows.is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/repo_query.rs b/apps/desktop/src-tauri/src/commands/repo_query.rs new file mode 100644 index 00000000..ef5a336b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/repo_query.rs @@ -0,0 +1,1094 @@ +//! Bounded, read-only repository query projection shared by the native viewer and CLI. +//! +//! Query semantics stay in the canonical structural graph and history services. This +//! module only validates the native/CLI boundary and packages freshness alongside results. + +use crate::commands::{ + history_graph::HistoryGraphStatus, + history_query::{HistoryCausalSelector, HistoryCausalTrace}, + history_read::{HistoryReadService, HistoryUnifiedSearch}, + structural_graph::{ + query::{ + self, GraphDirection, GraphExplanation, GraphImpactResult, GraphPathResult, + GraphQueryFilter, GraphSearchResult, + }, + service::{StructuralGraphReadService, StructuralGraphReadStatus}, + types::StructuralGraphSnapshot, + }, +}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + io::{BufRead, Write}, + path::Path, + sync::Arc, +}; + +pub const REPO_QUERY_SCHEMA: &str = "codevetter.repo-query/v2"; +pub const REPO_QUERY_PREPARATION_SCHEMA: &str = "codevetter.repo-query-preparation/v1"; +pub const REPO_QUERY_WORKER_REQUEST_SCHEMA: &str = "codevetter.repo-query-worker-request/v2"; +pub const REPO_QUERY_WORKER_RESPONSE_SCHEMA: &str = "codevetter.repo-query-worker-response/v1"; +const MAX_QUERY_BYTES: usize = 4_096; +const MAX_QUERY_RESULTS: usize = 100; +const MAX_WORKER_LINE_BYTES: usize = 16 * 1024; +const MAX_REQUEST_ID_BYTES: usize = 128; +const MAX_WORKER_GRAPH_SNAPSHOTS: usize = 1; + +#[derive(Default)] +struct RepositoryQueryWorkerCache { + graph_snapshots: HashMap, +} + +struct CachedGraphSnapshot { + snapshot: Arc, + traversal_ready: bool, +} + +impl RepositoryQueryWorkerCache { + fn graph_snapshot( + &mut self, + graph: &StructuralGraphReadService<'_>, + status: &StructuralGraphReadStatus, + traversal_required: bool, + ) -> Result, String> { + let snapshot_id = status.snapshot_id.as_deref().ok_or_else(|| { + "Canonical structural graph snapshot identity is unavailable".to_string() + })?; + if let Some(cached) = self.graph_snapshots.get_mut(snapshot_id) { + if traversal_required && !cached.traversal_ready { + let edges = graph.traversal_edges_by_snapshot_id(snapshot_id)?; + Arc::get_mut(&mut cached.snapshot) + .ok_or_else(|| "Canonical graph cache is unexpectedly shared".to_string())? + .edges = edges; + cached.traversal_ready = true; + } + return Ok(Arc::clone(&cached.snapshot)); + } + let mut snapshot = graph.search_snapshot_by_id(snapshot_id)?; + if snapshot.id != snapshot_id { + return Err( + "Canonical structural graph changed while the query was prepared".to_string(), + ); + } + if self.graph_snapshots.len() >= MAX_WORKER_GRAPH_SNAPSHOTS { + self.graph_snapshots.clear(); + } + if traversal_required { + snapshot.edges = graph.traversal_edges_by_snapshot_id(snapshot_id)?; + } + let snapshot = Arc::new(snapshot); + self.graph_snapshots.insert( + snapshot.id.clone(), + CachedGraphSnapshot { + snapshot: Arc::clone(&snapshot), + traversal_ready: traversal_required, + }, + ); + Ok(snapshot) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryQueryDomain { + Graph, + History, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryQueryMode { + #[default] + Search, + Explain, + Impact, + Path, + Trace, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryHistorySelectorKind { + Event, + Entity, + Revision, + Release, + Episode, +} + +#[derive(Debug, Clone)] +pub struct RepositoryQueryInput { + pub domain: RepositoryQueryDomain, + pub mode: RepositoryQueryMode, + pub query: String, + pub target: Option, + pub direction: Option, + pub depth: Option, + pub history_selector: Option, + pub limit: usize, +} + +impl RepositoryQueryInput { + pub fn search(domain: RepositoryQueryDomain, query: impl Into, limit: usize) -> Self { + Self { + domain, + mode: RepositoryQueryMode::Search, + query: query.into(), + target: None, + direction: None, + depth: None, + history_selector: None, + limit, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryQueryReceipt { + pub schema_version: &'static str, + pub authority: &'static str, + pub repo_path: String, + pub query: String, + pub domain: RepositoryQueryDomain, + pub mode: RepositoryQueryMode, + pub target: Option, + pub direction: Option, + pub depth: Option, + pub history_selector: Option, + pub limit: usize, + pub status: &'static str, + pub issue: Option, + pub graph_status: StructuralGraphReadStatus, + pub history_status: HistoryGraphStatus, + pub graph_result: Option, + pub graph_explanation: Option, + pub graph_impact: Option, + pub graph_path: Option, + pub history_result: Option, + pub history_trace: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryQueryWorkerOperation { + Prepare, + Query, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryQueryWorkerRequest { + pub schema_version: String, + pub request_id: String, + pub operation: RepositoryQueryWorkerOperation, + pub repo_path: String, + pub domain: RepositoryQueryDomain, + #[serde(default)] + pub mode: RepositoryQueryMode, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub direction: Option, + #[serde(default)] + pub depth: Option, + #[serde(default)] + pub history_selector: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RepositoryQueryPreparation { + pub schema_version: &'static str, + pub authority: &'static str, + pub repo_path: String, + pub domain: RepositoryQueryDomain, + pub status: &'static str, + pub issue: Option, + pub graph_status: StructuralGraphReadStatus, + pub history_status: HistoryGraphStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RepositoryQueryWorkerResponse { + pub schema_version: &'static str, + pub request_id: String, + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub receipt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preparation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn query_repository_evidence( + connection: &Connection, + repo_path: &Path, + domain: RepositoryQueryDomain, + query_text: &str, + limit: usize, +) -> Result { + query_repository_evidence_with_input( + connection, + repo_path, + RepositoryQueryInput::search(domain, query_text, limit), + ) +} + +pub fn query_repository_evidence_with_input( + connection: &Connection, + repo_path: &Path, + input: RepositoryQueryInput, +) -> Result { + query_repository_evidence_internal(connection, None, repo_path, input) +} + +pub fn prepare_repository_query( + connection: &Connection, + repo_path: &Path, + domain: RepositoryQueryDomain, +) -> Result { + prepare_repository_query_with_cache(connection, repo_path, domain, None) +} + +fn prepare_repository_query_with_cache( + connection: &Connection, + repo_path: &Path, + domain: RepositoryQueryDomain, + cache: Option<&mut RepositoryQueryWorkerCache>, +) -> Result { + let canonical = canonical_repository(repo_path)?; + let repo_path = canonical.to_string_lossy().into_owned(); + let graph = StructuralGraphReadService::new(connection, repo_path.clone()); + let history = HistoryReadService::new(connection, &repo_path)?; + let graph_status = graph.status()?; + let history_status = history.status()?; + let (status, issue) = match domain { + RepositoryQueryDomain::Graph if graph_status.indexed => { + if let Some(cache) = cache { + let snapshot = cache.graph_snapshot(&graph, &graph_status, false)?; + query::prepare_search_index(&snapshot); + } else { + graph.prepare_search_index()?; + } + ("ready", None) + } + RepositoryQueryDomain::Graph => ( + "unavailable", + Some( + "The canonical structural graph has not been indexed for this repository." + .to_string(), + ), + ), + RepositoryQueryDomain::History if history_status.indexed => ("ready", None), + RepositoryQueryDomain::History => ( + "unavailable", + Some( + "Temporal history has not been indexed for this repository; no query was run." + .to_string(), + ), + ), + }; + Ok(RepositoryQueryPreparation { + schema_version: REPO_QUERY_PREPARATION_SCHEMA, + authority: "read_only_projection", + repo_path, + domain, + status, + issue, + graph_status, + history_status, + }) +} + +/// Serve bounded read-only repository requests until stdin closes. +/// +/// One SQLite connection and the canonical process-local graph index cache are +/// retained for the worker lifetime. Every request receives exactly one line +/// of JSON, including malformed requests, so callers cannot lose framing. +pub fn run_repository_query_worker( + connection: &Connection, + reader: impl BufRead, + mut writer: impl Write, +) -> Result<(), String> { + let mut cache = RepositoryQueryWorkerCache::default(); + for line in reader.lines() { + let line = + line.map_err(|error| format!("read repository query worker request: {error}"))?; + let response = if line.len() > MAX_WORKER_LINE_BYTES { + RepositoryQueryWorkerResponse::error( + "invalid", + format!( + "Repository query worker requests must not exceed {MAX_WORKER_LINE_BYTES} bytes" + ), + ) + } else { + handle_worker_line(connection, &mut cache, &line) + }; + serde_json::to_writer(&mut writer, &response) + .map_err(|error| format!("serialize repository query worker response: {error}"))?; + writer + .write_all(b"\n") + .map_err(|error| format!("write repository query worker response: {error}"))?; + writer + .flush() + .map_err(|error| format!("flush repository query worker response: {error}"))?; + } + Ok(()) +} + +fn handle_worker_line( + connection: &Connection, + cache: &mut RepositoryQueryWorkerCache, + line: &str, +) -> RepositoryQueryWorkerResponse { + let request = match serde_json::from_str::(line) { + Ok(request) => request, + Err(error) => { + return RepositoryQueryWorkerResponse::error( + "invalid", + format!("Decode repository query worker request: {error}"), + ) + } + }; + if let Err(error) = validate_worker_request(&request) { + return RepositoryQueryWorkerResponse::error(&request.request_id, error); + } + let outcome = match request.operation { + RepositoryQueryWorkerOperation::Prepare => prepare_repository_query_with_cache( + connection, + Path::new(&request.repo_path), + request.domain, + Some(cache), + ) + .map(|preparation| (None, Some(preparation))), + RepositoryQueryWorkerOperation::Query => query_repository_evidence_cached( + connection, + cache, + Path::new(&request.repo_path), + RepositoryQueryInput { + domain: request.domain, + mode: request.mode, + query: request.query.unwrap_or_default(), + target: request.target, + direction: request.direction, + depth: request.depth, + history_selector: request.history_selector, + limit: request.limit.unwrap_or(40), + }, + ) + .map(|receipt| (Some(receipt), None)), + }; + match outcome { + Ok((receipt, preparation)) => RepositoryQueryWorkerResponse { + schema_version: REPO_QUERY_WORKER_RESPONSE_SCHEMA, + request_id: request.request_id, + status: "ok", + receipt, + preparation, + error: None, + }, + Err(error) => RepositoryQueryWorkerResponse::error(&request.request_id, error), + } +} + +fn query_repository_evidence_cached( + connection: &Connection, + cache: &mut RepositoryQueryWorkerCache, + repo_path: &Path, + input: RepositoryQueryInput, +) -> Result { + query_repository_evidence_internal(connection, Some(cache), repo_path, input) +} + +fn query_repository_evidence_internal( + connection: &Connection, + cache: Option<&mut RepositoryQueryWorkerCache>, + repo_path: &Path, + mut input: RepositoryQueryInput, +) -> Result { + normalize_and_validate_input(&mut input)?; + let canonical = canonical_repository(repo_path)?; + let repo_path = canonical.to_string_lossy().into_owned(); + let graph = StructuralGraphReadService::new(connection, repo_path.clone()); + let history = HistoryReadService::new(connection, &repo_path)?; + let graph_status = graph.status()?; + let history_status = history.status()?; + + let mut graph_result = None; + let mut graph_explanation = None; + let mut graph_impact = None; + let mut graph_path = None; + let mut history_result = None; + let mut history_trace = None; + let (status, issue) = match input.domain { + RepositoryQueryDomain::Graph if !graph_status.indexed => ( + "unavailable", + Some( + "The canonical structural graph has not been indexed for this repository." + .to_string(), + ), + ), + RepositoryQueryDomain::Graph => { + let snapshot = match cache { + Some(cache) => cache.graph_snapshot( + &graph, + &graph_status, + input.mode != RepositoryQueryMode::Search, + )?, + None => { + let snapshot_id = graph_status.snapshot_id.as_deref().ok_or_else(|| { + "Canonical structural graph snapshot identity is unavailable".to_string() + })?; + let mut snapshot = graph.search_snapshot_by_id(snapshot_id)?; + if input.mode != RepositoryQueryMode::Search { + snapshot.edges = graph.traversal_edges_by_snapshot_id(snapshot_id)?; + } + Arc::new(snapshot) + } + }; + let current_head = graph.current_head(); + match input.mode { + RepositoryQueryMode::Search => { + let mut result = query::search( + &snapshot, + &input.query, + &GraphQueryFilter::default(), + Some(input.limit), + ); + result.context.observe_current_head(current_head); + graph_result = Some(result); + } + RepositoryQueryMode::Explain => { + let mut result = query::explain(&snapshot, &input.query)?; + result.context.observe_current_head(current_head); + graph_explanation = Some(result); + } + RepositoryQueryMode::Impact => { + let mut result = query::impact( + &snapshot, + &input.query, + input.direction.clone().unwrap_or(GraphDirection::Outgoing), + input.depth, + &GraphQueryFilter::default(), + Some(input.limit), + )?; + result.context.observe_current_head(current_head); + hydrate_result_edges(&graph, &result.context.snapshot_id, &mut result.edges)?; + graph_impact = Some(result); + } + RepositoryQueryMode::Path => { + let mut result = query::shortest_path( + &snapshot, + &input.query, + input.target.as_deref().unwrap_or_default(), + &GraphQueryFilter::default(), + )?; + result.context.observe_current_head(current_head); + hydrate_result_edges(&graph, &result.context.snapshot_id, &mut result.edges)?; + graph_path = Some(result); + } + RepositoryQueryMode::Trace => unreachable!("validated graph query mode"), + } + ("ready", None) + } + RepositoryQueryDomain::History if !history_status.indexed => ( + "unavailable", + Some( + "Temporal history has not been indexed for this repository; no query was run." + .to_string(), + ), + ), + RepositoryQueryDomain::History => { + match input.mode { + RepositoryQueryMode::Search => { + history_result = Some(history.search(&input.query, input.limit, 0)?); + } + RepositoryQueryMode::Trace => { + history_trace = Some(history.trace( + history_selector( + input.history_selector.expect("validated history selector"), + &input.query, + ), + input.limit, + None, + )?); + } + _ => unreachable!("validated history query mode"), + } + ("ready", None) + } + }; + + Ok(RepositoryQueryReceipt { + schema_version: REPO_QUERY_SCHEMA, + authority: "read_only_projection", + repo_path, + query: input.query, + domain: input.domain, + mode: input.mode, + target: input.target, + direction: input.direction, + depth: input.depth, + history_selector: input.history_selector, + limit: input.limit, + status, + issue, + graph_status, + history_status, + graph_result, + graph_explanation, + graph_impact, + graph_path, + history_result, + history_trace, + }) +} + +fn validate_worker_request(request: &RepositoryQueryWorkerRequest) -> Result<(), String> { + if request.schema_version != REPO_QUERY_WORKER_REQUEST_SCHEMA { + return Err(format!( + "Unsupported repository query worker request schema {}", + request.schema_version + )); + } + if request.request_id.is_empty() + || request.request_id.len() > MAX_REQUEST_ID_BYTES + || request.request_id.chars().any(char::is_control) + { + return Err("Repository query worker request ids must be one bounded line".to_string()); + } + if request.repo_path.is_empty() + || request.repo_path.len() > MAX_QUERY_BYTES + || request.repo_path.chars().any(char::is_control) + { + return Err("Repository query worker paths must be one bounded line".to_string()); + } + match request.operation { + RepositoryQueryWorkerOperation::Prepare + if request.mode != RepositoryQueryMode::Search + || request.query.is_some() + || request.target.is_some() + || request.direction.is_some() + || request.depth.is_some() + || request.history_selector.is_some() + || request.limit.is_some() => + { + Err("Repository query prepare does not accept query operation fields".to_string()) + } + RepositoryQueryWorkerOperation::Query if request.query.is_none() => { + Err("Repository query requests require a query field".to_string()) + } + RepositoryQueryWorkerOperation::Query => { + let mut input = RepositoryQueryInput { + domain: request.domain, + mode: request.mode, + query: request.query.clone().unwrap_or_default(), + target: request.target.clone(), + direction: request.direction.clone(), + depth: request.depth, + history_selector: request.history_selector, + limit: request.limit.unwrap_or(40), + }; + normalize_and_validate_input(&mut input) + } + RepositoryQueryWorkerOperation::Prepare => Ok(()), + } +} + +impl RepositoryQueryWorkerResponse { + fn error(request_id: &str, error: String) -> Self { + Self { + schema_version: REPO_QUERY_WORKER_RESPONSE_SCHEMA, + request_id: request_id.to_string(), + status: "error", + receipt: None, + preparation: None, + error: Some(error), + } + } +} + +fn canonical_repository(repo_path: &Path) -> Result { + let canonical = std::fs::canonicalize(repo_path) + .map_err(|error| format!("repository {} is unavailable: {error}", repo_path.display()))?; + if !canonical.is_dir() { + return Err(format!( + "repository {} is not a directory", + canonical.display() + )); + } + Ok(canonical) +} + +fn validate_query(query: &str) -> Result { + let query = query.trim(); + if query.is_empty() { + return Err("A non-empty repository query is required".to_string()); + } + if query.len() > MAX_QUERY_BYTES || query.chars().any(char::is_control) { + return Err(format!( + "Repository queries must be one bounded line of at most {MAX_QUERY_BYTES} bytes" + )); + } + Ok(query.to_string()) +} + +fn validate_limit(limit: usize) -> Result { + if !(1..=MAX_QUERY_RESULTS).contains(&limit) { + return Err(format!( + "Repository query limit must be between 1 and {MAX_QUERY_RESULTS}" + )); + } + Ok(limit) +} + +fn normalize_and_validate_input(input: &mut RepositoryQueryInput) -> Result<(), String> { + input.query = validate_query(&input.query)?; + input.limit = validate_limit(input.limit)?; + if let Some(target) = input.target.as_mut() { + *target = validate_query(target)?; + } + if let Some(depth) = input.depth { + if !(1..=12).contains(&depth) { + return Err("Repository impact depth must be between 1 and 12".to_string()); + } + } + match (input.domain, input.mode) { + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Search) + | (RepositoryQueryDomain::Graph, RepositoryQueryMode::Explain) + if input.target.is_none() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_none() => + { + Ok(()) + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Impact) + if input.target.is_none() && input.history_selector.is_none() => + { + input.direction.get_or_insert(GraphDirection::Outgoing); + input.depth.get_or_insert(3); + Ok(()) + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Path) + if input.target.is_some() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_none() => + { + Ok(()) + } + (RepositoryQueryDomain::History, RepositoryQueryMode::Search) + if input.target.is_none() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_none() => + { + Ok(()) + } + (RepositoryQueryDomain::History, RepositoryQueryMode::Trace) + if input.target.is_none() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_some() => + { + Ok(()) + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Trace) => { + Err("Graph queries do not support causal trace mode".to_string()) + } + (RepositoryQueryDomain::History, _) => { + Err("History queries support only search and causal trace modes".to_string()) + } + _ => Err("Repository query fields are inconsistent with the selected mode".to_string()), + } +} + +fn history_selector(kind: RepositoryHistorySelectorKind, value: &str) -> HistoryCausalSelector { + match kind { + RepositoryHistorySelectorKind::Event => HistoryCausalSelector::Event { + event_id: value.to_string(), + }, + RepositoryHistorySelectorKind::Entity => HistoryCausalSelector::Entity { + entity_id: value.to_string(), + }, + RepositoryHistorySelectorKind::Revision => HistoryCausalSelector::Revision { + revision: value.to_string(), + }, + RepositoryHistorySelectorKind::Release => HistoryCausalSelector::Release { + tag: value.to_string(), + }, + RepositoryHistorySelectorKind::Episode => HistoryCausalSelector::EpisodeKey { + key: value.to_string(), + }, + } +} + +fn hydrate_result_edges( + graph: &StructuralGraphReadService<'_>, + snapshot_id: &str, + edges: &mut [crate::commands::structural_graph::types::StructuralGraphEdge], +) -> Result<(), String> { + let ids = edges.iter().map(|edge| edge.id.clone()).collect::>(); + let mut hydrated = graph + .edges_by_ids(snapshot_id, &ids)? + .into_iter() + .map(|edge| (edge.id.clone(), edge)) + .collect::>(); + for edge in edges { + *edge = hydrated + .remove(&edge.id) + .ok_or_else(|| "A canonical traversal edge could not be hydrated".to_string())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::structural_graph::{ + storage::persist_snapshot, + types::{ + GraphOrigin, GraphTrust, StructuralGraphCoverage, StructuralGraphEdge, + StructuralGraphEngineInfo, StructuralGraphNode, StructuralGraphSnapshot, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + }, + }; + use std::{fs, io::Cursor, process::Command}; + + #[test] + fn query_boundary_rejects_empty_multiline_and_unbounded_inputs() { + assert!(validate_query(" ").is_err()); + assert!(validate_query("one\ntwo").is_err()); + assert!(validate_query(&"x".repeat(MAX_QUERY_BYTES + 1)).is_err()); + assert!(validate_limit(0).is_err()); + assert!(validate_limit(MAX_QUERY_RESULTS + 1).is_err()); + assert_eq!( + validate_query(" review pipeline ").unwrap(), + "review pipeline" + ); + assert_eq!(validate_limit(25).unwrap(), 25); + } + + #[test] + fn history_query_fails_closed_when_temporal_coverage_is_not_indexed() { + let root = + std::env::temp_dir().join(format!("codevetter-repo-query-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture root"); + for arguments in [ + vec!["init", "-q"], + vec!["add", "README.md"], + vec![ + "-c", + "user.name=CodeVetter", + "-c", + "user.email=codevetter@example.invalid", + "commit", + "-qm", + "Fix verification regression", + ], + ] { + if arguments[0] == "add" { + fs::write(root.join("README.md"), "fixture").expect("fixture source"); + } + assert!(Command::new("git") + .args(arguments) + .current_dir(&root) + .status() + .expect("git command") + .success()); + } + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + + let receipt = query_repository_evidence( + &connection, + &root, + RepositoryQueryDomain::History, + "regression", + 10, + ) + .expect("history query"); + + assert_eq!(receipt.schema_version, REPO_QUERY_SCHEMA); + assert_eq!(receipt.authority, "read_only_projection"); + assert_eq!(receipt.status, "unavailable"); + assert!(!receipt.graph_status.indexed); + assert!(!receipt.history_status.indexed); + assert!(receipt.issue.is_some()); + assert!(receipt.history_result.is_none()); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + #[test] + fn worker_keeps_framing_and_fails_closed_for_unindexed_evidence() { + let root = + std::env::temp_dir().join(format!("codevetter-repo-worker-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture root"); + assert!(Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .status() + .expect("git init") + .success()); + fs::write(root.join("README.md"), "fixture").expect("fixture source"); + assert!(Command::new("git") + .args(["add", "README.md"]) + .current_dir(&root) + .status() + .expect("git add") + .success()); + assert!(Command::new("git") + .args([ + "-c", + "user.name=CodeVetter", + "-c", + "user.email=codevetter@example.invalid", + "commit", + "-qm", + "Seed worker fixture", + ]) + .current_dir(&root) + .status() + .expect("git commit") + .success()); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let repo_path = root.to_string_lossy(); + let input = format!( + "{{\"schema_version\":\"{REPO_QUERY_WORKER_REQUEST_SCHEMA}\",\"request_id\":\"prepare-1\",\"operation\":\"prepare\",\"repo_path\":{},\"domain\":\"graph\"}}\n{{\"schema_version\":\"{REPO_QUERY_WORKER_REQUEST_SCHEMA}\",\"request_id\":\"query-1\",\"operation\":\"query\",\"repo_path\":{},\"domain\":\"history\",\"query\":\"seed\",\"limit\":10}}\n", + serde_json::to_string(repo_path.as_ref()).expect("path json"), + serde_json::to_string(repo_path.as_ref()).expect("path json") + ); + let mut output = Vec::new(); + run_repository_query_worker(&connection, Cursor::new(input), &mut output) + .expect("worker run"); + let encoded = String::from_utf8(output).expect("worker utf8"); + let responses = encoded + .lines() + .map(|line| serde_json::from_str::(line).expect("worker response")) + .collect::>(); + + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["request_id"], "prepare-1"); + assert_eq!(responses[0]["status"], "ok"); + assert_eq!(responses[0]["preparation"]["status"], "unavailable"); + assert_eq!(responses[1]["request_id"], "query-1"); + assert_eq!(responses[1]["status"], "ok"); + assert_eq!(responses[1]["receipt"]["status"], "unavailable"); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + #[test] + fn worker_cache_reloads_when_the_latest_canonical_snapshot_changes() { + let root = + std::env::temp_dir().join(format!("codevetter-repo-cache-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture root"); + assert!(Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .status() + .expect("git init") + .success()); + fs::write(root.join("README.md"), "fixture").expect("fixture source"); + assert!(Command::new("git") + .args(["add", "README.md"]) + .current_dir(&root) + .status() + .expect("git add") + .success()); + assert!(Command::new("git") + .args([ + "-c", + "user.name=CodeVetter", + "-c", + "user.email=codevetter@example.invalid", + "commit", + "-qm", + "Seed cache fixture", + ]) + .current_dir(&root) + .status() + .expect("git commit") + .success()); + let head = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&root) + .output() + .expect("git head") + .stdout, + ) + .expect("head utf8") + .trim() + .to_string(); + let repo_path = fs::canonicalize(&root) + .expect("canonical fixture root") + .to_string_lossy() + .into_owned(); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + persist_snapshot( + &connection, + &search_snapshot( + "snapshot-1", + "alpha_verifier", + "2026-09-01T00:00:01Z", + &repo_path, + &head, + ), + ) + .expect("first snapshot"); + let mut cache = RepositoryQueryWorkerCache::default(); + let preparation = prepare_repository_query_with_cache( + &connection, + &root, + RepositoryQueryDomain::Graph, + Some(&mut cache), + ) + .expect("prepare first snapshot"); + assert_eq!(preparation.status, "ready"); + assert!(cache.graph_snapshots.contains_key("snapshot-1")); + let first = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput::search(RepositoryQueryDomain::Graph, "alpha", 10), + ) + .expect("query first snapshot"); + assert_eq!( + first.graph_result.unwrap().hits[0].node.label, + "alpha_verifier" + ); + let explained = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput { + domain: RepositoryQueryDomain::Graph, + mode: RepositoryQueryMode::Explain, + query: "node:alpha_verifier".to_string(), + target: None, + direction: None, + depth: None, + history_selector: None, + limit: 10, + }, + ) + .expect("explain first snapshot"); + assert_eq!( + explained.graph_explanation.unwrap().node.label, + "alpha_verifier" + ); + let impacted = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput { + domain: RepositoryQueryDomain::Graph, + mode: RepositoryQueryMode::Impact, + query: "node:alpha_verifier".to_string(), + target: None, + direction: Some(GraphDirection::Both), + depth: Some(2), + history_selector: None, + limit: 10, + }, + ) + .expect("impact first snapshot"); + assert_eq!( + impacted.graph_impact.unwrap().edges[0].evidence, + "canonical fixture edge" + ); + + persist_snapshot( + &connection, + &search_snapshot( + "snapshot-2", + "beta_verifier", + "2026-09-01T00:00:02Z", + &repo_path, + &head, + ), + ) + .expect("second snapshot"); + let second = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput::search(RepositoryQueryDomain::Graph, "beta", 10), + ) + .expect("query second snapshot"); + assert_eq!( + second.graph_result.unwrap().hits[0].node.label, + "beta_verifier" + ); + assert_eq!(cache.graph_snapshots.len(), 1); + assert!(cache.graph_snapshots.contains_key("snapshot-2")); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + fn search_snapshot( + id: &str, + label: &str, + created_at: &str, + repo_path: &str, + head: &str, + ) -> StructuralGraphSnapshot { + StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: id.to_string(), + repo_path: repo_path.to_string(), + repo_head: Some(head.to_string()), + created_at: created_at.to_string(), + engine: StructuralGraphEngineInfo { + id: "fixture".to_string(), + version: "1".to_string(), + bundled: true, + syntax_aware: true, + supported_languages: vec!["rust".to_string()], + }, + cursor: None, + ignore_fingerprint: None, + coverage: StructuralGraphCoverage { + discovered_files: 1, + indexed_files: 1, + ..StructuralGraphCoverage::default() + }, + diagnostics: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + nodes: vec![StructuralGraphNode { + id: format!("node:{label}"), + kind: "function".to_string(), + label: label.to_string(), + qualified_name: Some(format!("fixture::{label}")), + path: Some("src/lib.rs".to_string()), + detail: Some("Canonical worker cache fixture".to_string()), + language: Some("rust".to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + }], + edges: vec![StructuralGraphEdge { + id: format!("edge:{label}"), + from: format!("node:{label}"), + to: format!("node:{label}"), + kind: "references".to_string(), + evidence: "canonical fixture edge".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Resolution, + sources: Vec::new(), + candidates: Vec::new(), + }], + metrics: Vec::new(), + clone_groups: Vec::new(), + truncated: false, + } + } +} diff --git a/apps/desktop/src-tauri/src/commands/repo_workspace.rs b/apps/desktop/src-tauri/src/commands/repo_workspace.rs index efbc48de..bfe917a1 100644 --- a/apps/desktop/src-tauri/src/commands/repo_workspace.rs +++ b/apps/desktop/src-tauri/src/commands/repo_workspace.rs @@ -4,7 +4,7 @@ use crate::commands::dora; use crate::commands::intel; use crate::commands::unpack; use crate::commands::unpack_scan::{emit_unpack_scan_progress, ScanProgress, ScanProgressCallback}; -use crate::commands::unpack_scan_profile::{emit_unpack_scan_profile, UnpackScanProfiler}; +use crate::commands::unpack_scan_profile::emit_unpack_scan_profile; use crate::DbState; use serde::{Deserialize, Serialize}; use std::process::Command as StdCommand; @@ -76,15 +76,6 @@ fn display_name_from_path(repo_path: &str) -> String { .to_string() } -fn touch_unpack_at(conn: &rusqlite::Connection, repo_path: &str, at: &str) -> Result<(), String> { - conn.execute( - "UPDATE repo_projects SET last_unpack_at = ?2 WHERE repo_path = ?1", - rusqlite::params![repo_path, at], - ) - .map_err(|e| e.to_string())?; - Ok(()) -} - fn touch_intel_at(conn: &rusqlite::Connection, repo_path: &str, at: &str) -> Result<(), String> { conn.execute( "UPDATE repo_projects SET last_intel_at = ?2 WHERE repo_path = ?1", @@ -312,57 +303,37 @@ pub async fn save_unpack_scan_snapshot( .await .map_err(|e| format!("inventory scan task join error: {e}"))??; - let inventory = build.inventory; - emit_unpack_scan_profile(&app, &report_id, &inventory.repo_path, &build.profile); - - let mut persist_profiler = UnpackScanProfiler::new("local_scan_persist"); + let repo_path = build.inventory.repo_path.clone(); + let files_scanned = build.inventory.files_scanned; + let needs_enrichment = unpack::inventory_needs_enrichment(&build.inventory); + emit_unpack_scan_profile(&app, &report_id, &repo_path, &build.profile); emit_unpack_scan_progress( &app, &report_id, - &inventory.repo_path, - &format!("Saved snapshot · {} files scanned", inventory.files_scanned), - inventory.files_scanned, + &repo_path, + &format!("Saving snapshot · {files_scanned} files scanned"), + files_scanned, ); - let inventory_json = serde_json::to_string(&inventory).map_err(|e| e.to_string())?; - persist_profiler.step("serialize", "JSON serialize (inventory → SQLite)"); - let now = chrono::Utc::now().to_rfc3339(); - let conn = conn_lock(&db)?; + let receipt = + unpack::persist_unpack_scan_snapshot_from_connection(&conn, report_id.clone(), build)?; + drop(conn); + for profile in &receipt.profiles { + if profile.stage == "local_scan_persist" { + emit_unpack_scan_profile(&app, &report_id, &repo_path, profile); + } + } + emit_unpack_scan_progress( + &app, + &report_id, + &repo_path, + &format!("Saved snapshot · {files_scanned} files scanned"), + files_scanned, + ); - crate::db::with_busy_retry( - || { - conn.execute( - "INSERT INTO repo_unpacked_reports - (id, repo_path, repo_name, commit_sha, status, inventory_json, - files_scanned, files_skipped, bytes_scanned, started_at, completed_at, created_at) - VALUES (?1, ?2, ?3, ?4, 'scan_only', ?5, ?6, ?7, ?8, ?9, ?9, ?9)", - rusqlite::params![ - report_id, - inventory.repo_path, - inventory.repo_name, - inventory.commit_sha, - inventory_json, - inventory.files_scanned as i64, - inventory.files_skipped as i64, - inventory.bytes_scanned as i64, - now, - ], - ) - }, - 15, - ) - .map_err(|e| e.to_string())?; - persist_profiler.step("db_insert", "SQLite insert"); - - touch_unpack_at(&conn, &inventory.repo_path, &now)?; - persist_profiler.step("touch_project", "Update repo project metadata"); - - let persist_profile = persist_profiler.finish(); - emit_unpack_scan_profile(&app, &report_id, &inventory.repo_path, &persist_profile); - - if unpack::inventory_needs_enrichment(&inventory) { + if needs_enrichment { let db_arc = db.0.clone(); let app_bg = app.clone(); let report_id_bg = report_id.clone(); @@ -375,13 +346,7 @@ pub async fn save_unpack_scan_snapshot( }); } - Ok(serde_json::json!({ - "report_id": report_id, - "status": "scan_only", - "inventory": unpack::trim_inventory_for_client(inventory), - "created_at": now, - "profiles": [build.profile, persist_profile], - })) + serde_json::to_value(receipt).map_err(|error| error.to_string()) } fn truncate_scan_path(path: &str) -> String { diff --git a/apps/desktop/src-tauri/src/commands/review.rs b/apps/desktop/src-tauri/src/commands/review.rs index e4feff91..0f6c2da8 100644 --- a/apps/desktop/src-tauri/src/commands/review.rs +++ b/apps/desktop/src-tauri/src/commands/review.rs @@ -89,7 +89,7 @@ pub async fn cancel_cli_review(repo_path: String) -> Result { /// usual user-install locations (asdf shims, bun, pnpm, npm global, homebrew, /// `~/.local/bin`) and returns the first match. Falls back to the bare name /// so the existing PATH lookup still runs if none match. -fn resolve_cli_path(name: &str) -> String { +pub(crate) fn resolve_cli_path(name: &str) -> String { // First, honor PATH if it works if let Ok(path_var) = std::env::var("PATH") { for dir in std::env::split_paths(&path_var) { @@ -2580,6 +2580,15 @@ pub async fn run_cli_review_core( context_delivery: "internal".to_string(), limitations: readiness_limitations, }; + let intent_diagnostic = crate::commands::review_intent::build_review_intent_diagnostic( + &change_description, + &changed_files, + &findings_val, + &qa_runs, + review_manifest.complete_coverage, + ); + let intent_diagnostic_json = + serde_json::to_value(&intent_diagnostic).unwrap_or_else(|_| json!({})); let summary = parsed .get("summary") @@ -2750,6 +2759,7 @@ pub async fn run_cli_review_core( "evidence_procedure_steps": evidence_procedure_steps_json.clone(), "coordinator_failed": coordinator_failed, "review_readiness": review_readiness, + "intent_diagnostic": intent_diagnostic_json.clone(), "review_manifest": review_manifest.clone(), }) .to_string(), @@ -2796,6 +2806,7 @@ pub async fn run_cli_review_core( "coordinator_used": plan.uses_coordinator, "review_status": review_status, "review_readiness": review_readiness, + "intent_diagnostic": intent_diagnostic_json, "review_memory_graph": review_memory_graph_json, "trusted_graph_context": trusted_graph_context_json, "qa_evidence": qa_evidence_json, @@ -3495,9 +3506,9 @@ mod tests { use super::*; /// Generate CodeVetter's public-benchmark comparator outputs by running - /// every `benchmark/cases//` through the REAL production review + /// every `benchmarks/public-catch-rate/cases//` through the REAL production review /// pipeline (risk tiers, specialists, coordinator, dedup) headlessly. - /// Raw pipeline output lands in `benchmark/reviews-raw/.codevetter.raw.json`; + /// Raw pipeline output lands in `benchmarks/public-catch-rate/reviews-raw/.codevetter.raw.json`; /// ground-truth mapping is a separate, human-checked step. Requires the /// `claude` CLI on PATH and burns real quota — hence ignored. #[test] @@ -4173,6 +4184,10 @@ mod tests { #[tokio::test] async fn review_executor_is_bounded_and_rejects_malformed_output() { let temp = tempfile::tempdir().expect("temp"); + // This test validates parsing and byte bounds, not the timeout path. + // Leave enough process-start budget when the full filesystem-heavy + // suite runs concurrently on CI; timeout behavior has a separate test. + let fixture_timeout = Duration::from_secs(5); let valid = executable_script( &temp, "valid", @@ -4183,7 +4198,7 @@ mod tests { "claude", temp.path().to_string_lossy().into_owned(), "review".into(), - Duration::from_secs(1), + fixture_timeout, 1024, ) .await @@ -4196,7 +4211,7 @@ mod tests { "claude", temp.path().to_string_lossy().into_owned(), "review".into(), - Duration::from_secs(1), + fixture_timeout, 1024, ) .await @@ -4209,7 +4224,7 @@ mod tests { "claude", temp.path().to_string_lossy().into_owned(), "review".into(), - Duration::from_secs(1), + fixture_timeout, 64, ) .await diff --git a/apps/desktop/src-tauri/src/commands/review_intent.rs b/apps/desktop/src-tauri/src/commands/review_intent.rs new file mode 100644 index 00000000..072c71ec --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/review_intent.rs @@ -0,0 +1,342 @@ +//! Deterministic intent diagnostics for completed reviews. +//! +//! This projection explains what evidence exists around the operator's stated +//! goal. It never closes intent automatically: only an explicit human +//! disposition may make that product claim. + +use std::collections::BTreeSet; + +use serde::Serialize; +use serde_json::Value; + +pub const REVIEW_INTENT_DIAGNOSTIC_SCHEMA: &str = "codevetter.review-intent-diagnostic/v1"; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ReviewIntentDiagnostic { + pub schema_version: String, + pub intent: IntentCapture, + pub changed_surfaces: Vec, + pub signals: IntentSignals, + pub gaps: Vec, + pub timeline: Vec, + pub closure: IntentClosure, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentCapture { + pub summary: String, + pub status: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentSignals { + pub changed_paths: usize, + pub findings: usize, + pub high_risk_findings: usize, + pub qa_runs: usize, + pub passed_qa_runs: usize, + pub failed_qa_runs: usize, + pub qa_artifacts: usize, + pub complete_review_coverage: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentTimelineItem { + pub id: String, + pub label: String, + pub detail: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentClosure { + pub status: String, + pub reason: String, + pub requires_human_disposition: bool, +} + +pub fn build_review_intent_diagnostic( + change_description: &str, + changed_files: &[String], + findings: &[Value], + qa_runs: &[Value], + complete_review_coverage: bool, +) -> ReviewIntentDiagnostic { + let intent_summary = change_description.trim(); + let intent_captured = !intent_summary.is_empty(); + let high_risk_findings = findings + .iter() + .filter(|finding| { + matches!( + finding.get("severity").and_then(Value::as_str), + Some("critical" | "high") + ) + }) + .count(); + let passed_qa_runs = qa_runs + .iter() + .filter(|run| run.get("pass").and_then(Value::as_bool) == Some(true)) + .count(); + let failed_qa_runs = qa_runs.len().saturating_sub(passed_qa_runs); + let qa_artifacts = qa_runs + .iter() + .map(|run| { + let artifacts = run + .get("artifacts") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let screenshot = usize::from( + run.get("screenshot_path") + .and_then(Value::as_str) + .is_some_and(|path| !path.trim().is_empty()), + ); + artifacts + screenshot + }) + .sum(); + + let mut gaps = Vec::new(); + if !intent_captured { + gaps.push("Original task intent was not captured.".to_string()); + } + if !complete_review_coverage { + gaps.push("Deterministic source-review coverage is incomplete.".to_string()); + } + if high_risk_findings > 0 { + gaps.push(format!( + "{high_risk_findings} high-risk finding{} require disposition and executable re-check.", + if high_risk_findings == 1 { "" } else { "s" } + )); + } + if qa_runs.is_empty() { + gaps.push("No synthetic user-flow evidence was recorded.".to_string()); + } else if failed_qa_runs > 0 { + gaps.push(format!( + "{failed_qa_runs} recorded synthetic QA run{} did not pass.", + if failed_qa_runs == 1 { "" } else { "s" } + )); + } + + let (closure_status, closure_reason) = if !intent_captured { + ( + "missing_intent", + "Capture the original goal before judging whether the change satisfies it.", + ) + } else if !complete_review_coverage || high_risk_findings > 0 || failed_qa_runs > 0 { + ( + "evidence_conflict", + "Recorded review or runtime evidence still conflicts with the stated intent.", + ) + } else if qa_runs.is_empty() { + ( + "insufficient_evidence", + "Source review is complete, but no recorded user-flow evidence supports intent closure.", + ) + } else { + ( + "ready_for_human_disposition", + "Recorded evidence is ready for an explicit human intent disposition.", + ) + }; + + ReviewIntentDiagnostic { + schema_version: REVIEW_INTENT_DIAGNOSTIC_SCHEMA.into(), + intent: IntentCapture { + summary: if intent_captured { + intent_summary.to_string() + } else { + "No explicit task intent captured".into() + }, + status: if intent_captured { + "captured" + } else { + "missing" + } + .into(), + source: "operator_task".into(), + }, + changed_surfaces: classify_changed_surfaces(changed_files), + signals: IntentSignals { + changed_paths: changed_files.len(), + findings: findings.len(), + high_risk_findings, + qa_runs: qa_runs.len(), + passed_qa_runs, + failed_qa_runs, + qa_artifacts, + complete_review_coverage, + }, + gaps, + timeline: vec![ + IntentTimelineItem { + id: "intent".into(), + label: "Intent captured".into(), + detail: if intent_captured { + intent_summary.to_string() + } else { + "No explicit task goal was supplied.".into() + }, + status: if intent_captured { "done" } else { "missing" }.into(), + }, + IntentTimelineItem { + id: "review".into(), + label: "Source review".into(), + detail: format!( + "{} findings across {} changed paths; {} high risk.", + findings.len(), + changed_files.len(), + high_risk_findings + ), + status: if complete_review_coverage && high_risk_findings == 0 { + "done" + } else { + "warning" + } + .into(), + }, + IntentTimelineItem { + id: "synthetic_qa".into(), + label: "Synthetic QA".into(), + detail: if qa_runs.is_empty() { + "No recorded user-flow run.".into() + } else { + format!( + "{} passed, {} failed, {} retained artifact references.", + passed_qa_runs, failed_qa_runs, qa_artifacts + ) + }, + status: if qa_runs.is_empty() { + "missing" + } else if failed_qa_runs > 0 { + "warning" + } else { + "done" + } + .into(), + }, + IntentTimelineItem { + id: "human_disposition".into(), + label: "Intent disposition".into(), + detail: "Requires an explicit human decision; CodeVetter does not infer closure." + .into(), + status: "pending".into(), + }, + ], + closure: IntentClosure { + status: closure_status.into(), + reason: closure_reason.into(), + requires_human_disposition: true, + }, + limitations: vec![ + "Intent closure is never inferred from review or test output.".into(), + "Legacy synthetic QA is recorded evidence and is not assumed revision-exact.".into(), + ], + } +} + +fn classify_changed_surfaces(changed_files: &[String]) -> Vec { + let mut surfaces = BTreeSet::new(); + for path in changed_files { + let path = path.to_ascii_lowercase(); + if path.contains("test") || path.contains("spec.") { + surfaces.insert("tests".to_string()); + } + if path.starts_with("docs/") || path.ends_with(".md") { + surfaces.insert("documentation".to_string()); + } + if path.ends_with(".tsx") + || path.ends_with(".jsx") + || path.ends_with(".css") + || path.ends_with(".swift") + { + surfaces.insert("user_interface".to_string()); + } + if path.ends_with(".rs") + || path.ends_with(".ts") + || path.ends_with(".js") + || path.contains("src-tauri") + || path.contains("commands/") + || path.contains("/api/") + || path.contains("server") + { + surfaces.insert("runtime".to_string()); + } + if path.starts_with("scripts/") || path.starts_with(".github/") { + surfaces.insert("automation".to_string()); + } + if path.ends_with(".sql") || path.contains("migration") { + surfaces.insert("data".to_string()); + } + } + if surfaces.is_empty() && !changed_files.is_empty() { + surfaces.insert("other".to_string()); + } + surfaces.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn failed_runtime_and_high_risk_review_block_intent_disposition() { + let diagnostic = build_review_intent_diagnostic( + "Preserve checkout totals", + &["src/cart.ts".into(), "src/cart.test.ts".into()], + &[json!({"severity": "high"})], + &[json!({ + "pass": false, + "artifacts": ["artifacts/trace.zip"], + "screenshot_path": "artifacts/failure.png" + })], + true, + ); + + assert_eq!(diagnostic.schema_version, REVIEW_INTENT_DIAGNOSTIC_SCHEMA); + assert_eq!(diagnostic.closure.status, "evidence_conflict"); + assert!(diagnostic.closure.requires_human_disposition); + assert_eq!(diagnostic.signals.high_risk_findings, 1); + assert_eq!(diagnostic.signals.failed_qa_runs, 1); + assert_eq!(diagnostic.signals.qa_artifacts, 2); + assert_eq!(diagnostic.changed_surfaces, vec!["runtime", "tests"]); + } + + #[test] + fn source_only_review_stays_insufficient_for_intent_closure() { + let diagnostic = build_review_intent_diagnostic( + "Keep settings readable", + &["src/SettingsView.swift".into()], + &[], + &[], + true, + ); + + assert_eq!(diagnostic.closure.status, "insufficient_evidence"); + assert_eq!(diagnostic.changed_surfaces, vec!["user_interface"]); + assert!(diagnostic + .gaps + .iter() + .any(|gap| gap.contains("No synthetic user-flow"))); + } + + #[test] + fn passing_recorded_qa_only_makes_evidence_ready_for_human_disposition() { + let diagnostic = build_review_intent_diagnostic( + "Keep the checkout flow working", + &["src/Checkout.tsx".into()], + &[], + &[json!({"pass": true, "artifacts": ["artifacts/checkout.png"]})], + true, + ); + + assert_eq!(diagnostic.closure.status, "ready_for_human_disposition"); + assert!(diagnostic.closure.requires_human_disposition); + assert!(diagnostic + .limitations + .iter() + .any(|limitation| limitation.contains("not assumed revision-exact"))); + } +} diff --git a/apps/desktop/src-tauri/src/commands/rubric_settings.rs b/apps/desktop/src-tauri/src/commands/rubric_settings.rs new file mode 100644 index 00000000..a66f28f3 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/rubric_settings.rs @@ -0,0 +1,536 @@ +use crate::db::queries; +use chrono::Utc; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use tauri::State; + +use crate::DbState; + +pub const RUBRIC_SETTINGS_SCHEMA_VERSION: &str = "codevetter.rubric-settings/v1"; +const RUBRIC_PREFERENCE_KEY: &str = "review_rubric_config_v1"; +const MAX_CUSTOM_PACKS: usize = 50; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RubricSettingsOperation { + Read, + Select, + Upsert, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RubricPackInput { + pub id: String, + pub name: String, + pub focus: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LegacyRubricConfig { + pub custom_rules: Option>, + pub active_standards_pack: Option, + pub standards_packs: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +struct StoredRubricConfig { + active_pack_id: Option, + custom_rules: Vec, + custom_packs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct RubricPackReceipt { + pub id: String, + pub name: String, + pub focus: String, + pub checks: Vec, + pub built_in: bool, + pub active: bool, + pub review_count: i64, + pub total_findings: i64, + pub prompt_preview: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct RubricSettingsReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: RubricSettingsOperation, + pub active_pack_id: Option, + pub custom_rules: Vec, + pub packs: Vec, + pub saved_pack_id: Option, + pub migrated_legacy_config: bool, +} + +#[tauri::command] +pub async fn get_rubric_settings( + db: State<'_, DbState>, + legacy_config: Option, +) -> Result { + let connection = db.0.lock().map_err(|error| error.to_string())?; + read_rubric_settings(&connection, legacy_config) +} + +#[tauri::command] +pub async fn set_active_rubric_pack( + db: State<'_, DbState>, + pack_id: String, +) -> Result { + let connection = db.0.lock().map_err(|error| error.to_string())?; + select_rubric_pack(&connection, &pack_id) +} + +#[tauri::command] +pub async fn save_rubric_pack( + db: State<'_, DbState>, + pack: RubricPackInput, +) -> Result { + let connection = db.0.lock().map_err(|error| error.to_string())?; + upsert_rubric_pack(&connection, pack) +} + +pub fn read_rubric_settings( + connection: &Connection, + legacy: Option, +) -> Result { + let (config, migrated) = load_or_migrate_config(connection, legacy)?; + build_receipt( + connection, + RubricSettingsOperation::Read, + config, + None, + migrated, + ) +} + +pub fn active_rubric_prompt(connection: &Connection) -> Result<(Option, String), String> { + let receipt = read_rubric_settings(connection, None)?; + let selected = receipt + .active_pack_id + .as_deref() + .and_then(|id| receipt.packs.iter().find(|pack| pack.id == id)) + .or_else(|| receipt.packs.first()) + .ok_or_else(|| "No review rubric packs are available".to_string())?; + Ok((receipt.active_pack_id, selected.prompt_preview.clone())) +} + +pub fn select_rubric_pack( + connection: &Connection, + pack_id: &str, +) -> Result { + let mut config = load_config(connection)?.unwrap_or_default(); + let pack_id = validate_id(pack_id)?; + if !all_pack_inputs(&config) + .iter() + .any(|pack| pack.id == pack_id) + { + return Err("Rubric pack not found".to_string()); + } + config.active_pack_id = Some(pack_id.clone()); + save_config(connection, &config)?; + build_receipt( + connection, + RubricSettingsOperation::Select, + config, + Some(pack_id), + false, + ) +} + +pub fn upsert_rubric_pack( + connection: &Connection, + pack: RubricPackInput, +) -> Result { + let mut config = load_config(connection)?.unwrap_or_default(); + let pack = validate_pack(pack)?; + if built_in_packs() + .iter() + .any(|built_in| built_in.id == pack.id) + { + return Err("Built-in rubric packs cannot be overwritten".to_string()); + } + if let Some(index) = config + .custom_packs + .iter() + .position(|existing| existing.id == pack.id) + { + config.custom_packs[index] = pack.clone(); + } else { + if config.custom_packs.len() >= MAX_CUSTOM_PACKS { + return Err(format!( + "At most {MAX_CUSTOM_PACKS} custom rubric packs are supported" + )); + } + config.custom_packs.push(pack.clone()); + } + config.active_pack_id = Some(pack.id.clone()); + validate_stored_config(&config)?; + save_config(connection, &config)?; + build_receipt( + connection, + RubricSettingsOperation::Upsert, + config, + Some(pack.id), + false, + ) +} + +fn load_or_migrate_config( + connection: &Connection, + legacy: Option, +) -> Result<(StoredRubricConfig, bool), String> { + if let Some(config) = load_config(connection)? { + return Ok((config, false)); + } + let Some(legacy) = legacy else { + return Ok((StoredRubricConfig::default(), false)); + }; + let config = StoredRubricConfig { + active_pack_id: legacy.active_standards_pack, + custom_rules: legacy.custom_rules.unwrap_or_default(), + custom_packs: legacy.standards_packs.unwrap_or_default(), + }; + let config = validate_stored_config(&config)?; + save_config(connection, &config)?; + Ok((config, true)) +} + +fn load_config(connection: &Connection) -> Result, String> { + let Some(raw) = queries::get_preference(connection, RUBRIC_PREFERENCE_KEY) + .map_err(|error| error.to_string())? + else { + return Ok(None); + }; + let config: StoredRubricConfig = serde_json::from_str(&raw) + .map_err(|_| "Stored rubric configuration is invalid".to_string())?; + Ok(Some(validate_stored_config(&config)?)) +} + +fn save_config(connection: &Connection, config: &StoredRubricConfig) -> Result<(), String> { + let value = serde_json::to_string(config).map_err(|error| error.to_string())?; + queries::set_preference(connection, RUBRIC_PREFERENCE_KEY, &value) + .map_err(|error| error.to_string()) +} + +fn validate_stored_config(config: &StoredRubricConfig) -> Result { + if config.custom_packs.len() > MAX_CUSTOM_PACKS { + return Err(format!( + "At most {MAX_CUSTOM_PACKS} custom rubric packs are supported" + )); + } + let custom_rules = config + .custom_rules + .iter() + .map(|rule| bounded_text(rule, "custom rule", 500)) + .collect::, _>>()?; + if custom_rules.len() > 100 { + return Err("At most 100 custom rubric rules are supported".to_string()); + } + let custom_packs = config + .custom_packs + .iter() + .cloned() + .map(validate_pack) + .collect::, _>>()?; + let mut ids = HashSet::new(); + for pack in built_in_packs().into_iter().chain(custom_packs.clone()) { + if !ids.insert(pack.id.clone()) { + return Err(format!("Duplicate rubric pack id `{}`", pack.id)); + } + } + let active_pack_id = config + .active_pack_id + .as_deref() + .map(validate_id) + .transpose()?; + if active_pack_id.as_ref().is_some_and(|id| !ids.contains(id)) { + return Err("Active rubric pack does not exist".to_string()); + } + Ok(StoredRubricConfig { + active_pack_id, + custom_rules, + custom_packs, + }) +} + +fn validate_pack(pack: RubricPackInput) -> Result { + let id = validate_id(&pack.id)?; + let name = bounded_text(&pack.name, "pack name", 80)?; + let focus = bounded_text(&pack.focus, "pack focus", 500)?; + if pack.checks.is_empty() || pack.checks.len() > 32 { + return Err("A rubric pack requires between 1 and 32 checks".to_string()); + } + let checks = pack + .checks + .iter() + .map(|check| bounded_text(check, "pack check", 500)) + .collect::, _>>()?; + Ok(RubricPackInput { + id, + name, + focus, + checks, + }) +} + +fn validate_id(value: &str) -> Result { + let id = value.trim(); + if id.is_empty() + || id.len() > 64 + || !id.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + { + return Err("Rubric pack ids use 1-64 lowercase letters, digits, or hyphens".to_string()); + } + Ok(id.to_string()) +} + +fn bounded_text(value: &str, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > max { + return Err(format!( + "A {label} between 1 and {max} characters is required" + )); + } + Ok(value.to_string()) +} + +fn build_receipt( + connection: &Connection, + operation: RubricSettingsOperation, + config: StoredRubricConfig, + saved_pack_id: Option, + migrated_legacy_config: bool, +) -> Result { + let usage = queries::get_standards_pack_usage(connection) + .map_err(|error| error.to_string())? + .into_iter() + .map(|row| (row.standards_pack, (row.review_count, row.total_findings))) + .collect::>(); + let built_in_ids = built_in_packs() + .into_iter() + .map(|pack| pack.id) + .collect::>(); + let active_id = config.active_pack_id.clone(); + let packs = all_pack_inputs(&config) + .into_iter() + .map(|pack| { + let (review_count, total_findings) = usage.get(&pack.id).copied().unwrap_or((0, 0)); + RubricPackReceipt { + prompt_preview: build_prompt_preview(&pack, &config.custom_rules), + built_in: built_in_ids.contains(&pack.id), + active: active_id.as_deref() == Some(pack.id.as_str()), + review_count, + total_findings, + id: pack.id, + name: pack.name, + focus: pack.focus, + checks: pack.checks, + } + }) + .collect(); + Ok(RubricSettingsReceipt { + schema_version: RUBRIC_SETTINGS_SCHEMA_VERSION.to_string(), + generated_at: Utc::now().to_rfc3339(), + operation, + active_pack_id: config.active_pack_id, + custom_rules: config.custom_rules, + packs, + saved_pack_id, + migrated_legacy_config, + }) +} + +fn all_pack_inputs(config: &StoredRubricConfig) -> Vec { + built_in_packs() + .into_iter() + .chain(config.custom_packs.clone()) + .collect() +} + +fn build_prompt_preview(pack: &RubricPackInput, custom_rules: &[String]) -> String { + let mut lines = vec![ + "CodeVetter review standards pack:".to_string(), + format!("- Pack: {}", pack.name), + format!("- Focus: {}", pack.focus), + ]; + lines.extend(pack.checks.iter().map(|check| format!("- Check: {check}"))); + lines.extend( + custom_rules + .iter() + .map(|rule| format!("- Custom rule: {rule}")), + ); + lines.join("\n") +} + +fn built_in_packs() -> Vec { + vec![ + RubricPackInput { + id: "product-safety".to_string(), + name: "Product Safety".to_string(), + focus: "User-facing regressions, broken flows, data loss, and confusing states." + .to_string(), + checks: vec![ + "Flag behavior changes that can break an existing user workflow.".to_string(), + "Check loading, empty, error, and permission states for user-facing screens." + .to_string(), + "Prioritize concrete reproduction steps over style commentary.".to_string(), + ], + }, + RubricPackInput { + id: "security-boundary".to_string(), + name: "Security Boundary".to_string(), + focus: "Auth, authorization, secret handling, trust boundaries, and injection risk." + .to_string(), + checks: vec![ + "Verify server-side authorization, not just hidden client controls.".to_string(), + "Flag secrets, tokens, PII, or prompts that can leak into logs or analytics." + .to_string(), + "Check untrusted input before database, shell, network, or model calls." + .to_string(), + ], + }, + RubricPackInput { + id: "agent-handoff".to_string(), + name: "Agent Handoff".to_string(), + focus: "Review quality for multi-agent workflows and future task continuity." + .to_string(), + checks: vec![ + "Call out missing tests or verification commands the next agent must run." + .to_string(), + "Prefer findings with file paths, line numbers, and a bounded fix.".to_string(), + "Separate real blockers from optional cleanup so agents do not waste context." + .to_string(), + ], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::schema; + + fn fixture() -> Connection { + let connection = Connection::open_in_memory().expect("database"); + schema::run_migrations(&connection).expect("schema"); + connection + } + + #[test] + fn legacy_config_migrates_once_and_prompt_preview_matches_review_format() { + let connection = fixture(); + let legacy = LegacyRubricConfig { + active_standards_pack: Some("custom-payments".to_string()), + custom_rules: Some(vec!["Preserve ledger auditability.".to_string()]), + standards_packs: Some(vec![RubricPackInput { + id: "custom-payments".to_string(), + name: "Payments".to_string(), + focus: "Money movement".to_string(), + checks: vec!["Check retry idempotency.".to_string()], + }]), + }; + let migrated = read_rubric_settings(&connection, Some(legacy.clone())).expect("migrate"); + assert!(migrated.migrated_legacy_config); + let pack = migrated + .packs + .iter() + .find(|pack| pack.id == "custom-payments") + .expect("custom pack"); + assert!(pack.active); + assert!(pack + .prompt_preview + .contains("- Check: Check retry idempotency.")); + assert!(pack + .prompt_preview + .contains("- Custom rule: Preserve ledger auditability.")); + + let reread = + read_rubric_settings(&connection, Some(LegacyRubricConfig::default())).expect("reread"); + assert!(!reread.migrated_legacy_config); + assert_eq!(reread.active_pack_id.as_deref(), Some("custom-payments")); + } + + #[test] + fn existing_canonical_config_wins_over_conflicting_legacy_state() { + let connection = fixture(); + let canonical = RubricPackInput { + id: "canonical-pack".to_string(), + name: "Canonical".to_string(), + focus: "Persisted Rust authority".to_string(), + checks: vec!["Keep the canonical pack.".to_string()], + }; + upsert_rubric_pack(&connection, canonical).expect("canonical pack"); + + let legacy = LegacyRubricConfig { + active_standards_pack: Some("legacy-pack".to_string()), + custom_rules: Some(vec!["Do not overwrite Rust.".to_string()]), + standards_packs: Some(vec![RubricPackInput { + id: "legacy-pack".to_string(), + name: "Legacy".to_string(), + focus: "WebView state".to_string(), + checks: vec!["Legacy check.".to_string()], + }]), + }; + let receipt = read_rubric_settings(&connection, Some(legacy)).expect("read"); + + assert!(!receipt.migrated_legacy_config); + assert_eq!(receipt.active_pack_id.as_deref(), Some("canonical-pack")); + assert!(receipt.packs.iter().any(|pack| pack.id == "canonical-pack")); + assert!(!receipt.packs.iter().any(|pack| pack.id == "legacy-pack")); + } + + #[test] + fn invalid_legacy_state_fails_without_creating_a_canonical_preference() { + let connection = fixture(); + let invalid = LegacyRubricConfig { + active_standards_pack: Some("missing-pack".to_string()), + custom_rules: None, + standards_packs: Some(vec![]), + }; + + assert!(read_rubric_settings(&connection, Some(invalid)).is_err()); + assert!(queries::get_preference(&connection, RUBRIC_PREFERENCE_KEY) + .expect("preference lookup") + .is_none()); + } + + #[test] + fn select_and_upsert_reject_unknown_or_built_in_overwrites() { + let connection = fixture(); + assert!(select_rubric_pack(&connection, "missing").is_err()); + assert!(upsert_rubric_pack( + &connection, + RubricPackInput { + id: "product-safety".to_string(), + name: "Overwrite".to_string(), + focus: "No".to_string(), + checks: vec!["No".to_string()], + } + ) + .is_err()); + + let receipt = upsert_rubric_pack( + &connection, + RubricPackInput { + id: "performance-proof".to_string(), + name: "Performance Proof".to_string(), + focus: "Measured regressions".to_string(), + checks: vec!["Require a reproducible baseline.".to_string()], + }, + ) + .expect("upsert"); + assert_eq!(receipt.saved_pack_id.as_deref(), Some("performance-proof")); + assert_eq!(receipt.active_pack_id.as_deref(), Some("performance-proof")); + let (active_id, prompt) = active_rubric_prompt(&connection).expect("active prompt"); + assert_eq!(active_id.as_deref(), Some("performance-proof")); + assert!(prompt.contains("- Check: Require a reproducible baseline.")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/run_history.rs b/apps/desktop/src-tauri/src/commands/run_history.rs new file mode 100644 index 00000000..902b6044 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/run_history.rs @@ -0,0 +1,841 @@ +//! One bounded, Rust-owned projection of persisted verification history. +//! +//! The originating receipt remains untouched in `receipt`. Metadata here only +//! gives CLI and native clients a stable way to render unlike receipt families +//! without reinterpreting their verdicts or opening SQLite themselves. + +use std::collections::HashMap; + +use rusqlite::{params, params_from_iter, Connection}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +const MAX_RUN_HISTORY_LIMIT: usize = 100; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunKind { + LocalCheck, + Preview, + TrexPr, + SyntheticQa, + WarmVerification, + DifferentialVerification, + AudienceValidation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunHistoryRecord { + pub schema_version: String, + pub id: String, + pub kind: RunKind, + pub repo_path: Option, + pub recorded_at: String, + pub title: String, + pub outcome: String, + pub receipt_schema: String, + pub source_label: Option, + pub limitations: Vec, + pub receipt: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunHistoryReceipt { + pub schema_version: String, + pub generated_at: String, + pub repo_path: Option, + pub limit: usize, + pub returned: usize, + pub runs: Vec, +} + +pub fn list_run_history( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result { + let limit = limit.clamp(1, MAX_RUN_HISTORY_LIMIT); + let mut runs = Vec::new(); + runs.extend(list_local_checks(connection, repo_path, limit)?); + runs.extend(list_preview_runs(connection, repo_path, limit)?); + runs.extend(list_trex_pr_runs(connection, repo_path, limit)?); + runs.extend(list_synthetic_qa_runs(connection, repo_path, limit)?); + runs.extend(list_warm_runs(connection, repo_path, limit)?); + runs.extend(list_differential_runs(connection, repo_path, limit)?); + runs.extend(list_audience_runs(connection, repo_path, limit)?); + runs.sort_by(|left, right| { + right + .recorded_at + .cmp(&left.recorded_at) + .then_with(|| right.id.cmp(&left.id)) + }); + runs.truncate(limit); + + Ok(RunHistoryReceipt { + schema_version: "codevetter.run-history/v1".into(), + generated_at: chrono::Utc::now().to_rfc3339(), + repo_path: repo_path.map(ToOwned::to_owned), + limit, + returned: runs.len(), + runs, + }) +} + +fn list_trex_pr_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, ran_at, pr_number, head_sha, verdict, confidence, + summary, status_state, status_error, duration_ms + FROM trex_pr_runs", + repo_path, + "ran_at", + "id", + ); + query_rows(connection, &sql, repo_path, limit, "T-Rex PR", |row| { + let id: String = row.get(0)?; + let repo_path: String = row.get(1)?; + let recorded_at: String = row.get(2)?; + let pr_number: i64 = row.get(3)?; + let head_sha: String = row.get(4)?; + let verdict: String = row.get(5)?; + let confidence: f64 = row.get(6)?; + let summary: String = row.get(7)?; + let status_state: Option = row.get(8)?; + let status_error: Option = row.get(9)?; + let duration_ms: i64 = row.get(10)?; + let limitations = status_error + .iter() + .map(|error| format!("PR status could not be resolved: {error}")) + .collect(); + let receipt = json!({ + "schema_version": "codevetter.trex-pr-run/v1", + "id": id, + "repo_path": repo_path, + "pr_number": pr_number, + "head_sha": head_sha, + "verdict": verdict, + "confidence": confidence, + "summary": summary, + "status_state": status_state, + "status_error": status_error, + "duration_ms": duration_ms, + "ran_at": recorded_at, + }); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id, + kind: RunKind::TrexPr, + repo_path: Some(repo_path), + recorded_at, + title: format!("PR #{pr_number}: {summary}"), + outcome: verdict, + receipt_schema: "codevetter.trex-pr-run/v1".into(), + source_label: Some(short_identity(&head_sha)), + limitations, + receipt, + }) + }) +} + +fn list_synthetic_qa_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, goal, route, runner_type, review_id, + loop_id, base_url, pass, duration_ms, notes, screenshot_path, + artifacts, console_errors, error, trace_json + FROM synthetic_qa_runs", + repo_path, + "created_at", + "id", + ); + query_rows(connection, &sql, repo_path, limit, "synthetic QA", |row| { + let id: String = row.get(0)?; + let repo_path: Option = row.get(1)?; + let recorded_at: String = row.get(2)?; + let goal: Option = row.get(3)?; + let route: Option = row.get(4)?; + let runner_type: String = row.get(5)?; + let review_id: Option = row.get(6)?; + let loop_id: String = row.get(7)?; + let base_url: Option = row.get(8)?; + let passed = row.get::<_, i64>(9)? != 0; + let duration_ms: i64 = row.get(10)?; + let notes: Option = row.get(11)?; + let screenshot_path: Option = row.get(12)?; + let artifacts_json: Option = row.get(13)?; + let console_errors: i64 = row.get(14)?; + let error: Option = row.get(15)?; + let trace_json: Option = row.get(16)?; + let title = non_empty(goal.as_deref()) + .or_else(|| non_empty(route.as_deref())) + .unwrap_or("Synthetic QA") + .to_owned(); + let mut limitations = Vec::new(); + if !passed { + limitations.push( + error + .clone() + .unwrap_or_else(|| "Synthetic QA did not pass".into()), + ); + } + if console_errors > 0 { + limitations.push(format!("{console_errors} console error(s) recorded")); + } + let receipt = json!({ + "schema_version": "codevetter.synthetic-qa-run/v1", + "id": id, + "review_id": review_id, + "repo_path": repo_path, + "loop_id": loop_id, + "runner_type": runner_type, + "base_url": base_url, + "route": route, + "goal": goal, + "pass": passed, + "duration_ms": duration_ms, + "notes": notes, + "screenshot_path": screenshot_path, + "artifacts": decode_json_or_raw(artifacts_json), + "console_errors": console_errors, + "error": error, + "trace": decode_json_or_raw(trace_json.clone()), + "trace_json": trace_json, + "created_at": recorded_at, + }); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id, + kind: RunKind::SyntheticQa, + repo_path, + recorded_at, + title, + outcome: if passed { "passed" } else { "failed" }.into(), + receipt_schema: "codevetter.synthetic-qa-run/v1".into(), + source_label: Some(runner_type), + limitations, + receipt, + }) + }) +} + +fn list_local_checks( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT run_id, repo_path, ran_at, task, verdict, head_sha, receipt_json + FROM local_check_runs", + repo_path, + "ran_at", + "run_id", + ); + query_rows(connection, &sql, repo_path, limit, "local check", |row| { + let receipt: Value = decode_json(row.get(6)?, "local check")?; + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::LocalCheck, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: row.get(3)?, + outcome: row.get(4)?, + receipt_schema: string_field(&receipt, "schema_version") + .unwrap_or("codevetter.local-check/v1") + .into(), + source_label: Some(short_identity(&row.get::<_, String>(5)?)), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }) +} + +fn list_preview_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, ran_at, summary, verdict, head_sha, receipt_json + FROM trex_preview_runs", + repo_path, + "ran_at", + "id", + ); + query_rows(connection, &sql, repo_path, limit, "preview", |row| { + let receipt: Value = decode_json(row.get(6)?, "preview")?; + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::Preview, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: row.get(3)?, + outcome: row.get(4)?, + receipt_schema: "codevetter.trex-preview/v1".into(), + source_label: Some(short_identity(&row.get::<_, String>(5)?)), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }) +} + +fn list_warm_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, outcome, target_sha, result_json + FROM warm_verification_runs", + repo_path, + "created_at", + "id", + ); + query_rows( + connection, + &sql, + repo_path, + limit, + "warm verification", + |row| { + let receipt: Value = decode_json(row.get(5)?, "warm verification")?; + let warm = receipt + .get("warm") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::WarmVerification, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: if warm { + "Warm browser verification".into() + } else { + "Browser verification".into() + }, + outcome: row.get(3)?, + receipt_schema: "codevetter.warm-verification/v1".into(), + source_label: Some(short_identity(&row.get::<_, String>(4)?)), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }, + ) +} + +fn list_differential_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, classification, reference_sha, summary_json + FROM differential_verification_runs", + repo_path, + "created_at", + "id", + ); + query_rows( + connection, + &sql, + repo_path, + limit, + "differential verification", + |row| { + let receipt: Value = decode_json(row.get(5)?, "differential verification")?; + let source: Option = row.get(4)?; + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::DifferentialVerification, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: "Differential verification".into(), + outcome: row.get(3)?, + receipt_schema: "codevetter.differential-verification/v1".into(), + source_label: source.as_deref().map(short_identity), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }, + ) +} + +fn list_audience_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, task, status, audience, review_id, + candidate_a, candidate_b, criteria_json, min_responses, required, + waived_reason, updated_at + FROM audience_validation_runs", + repo_path, + "created_at", + "id", + ); + let mut runs = query_rows( + connection, + &sql, + repo_path, + limit, + "audience validation", + |row| { + let criteria_json: String = row.get(9)?; + let criteria: Value = decode_json(criteria_json, "audience criteria")?; + let required = row.get::<_, i64>(11)? != 0; + let status: String = row.get(4)?; + let waived_reason: Option = row.get(12)?; + let mut limitations = Vec::new(); + if status != "complete" && status != "waived" { + limitations.push(format!("Audience validation is {status}")); + } + if let Some(reason) = waived_reason.as_ref() { + limitations.push(format!("Audience validation was waived: {reason}")); + } + let id: String = row.get(0)?; + let repo_path: Option = row.get(1)?; + let recorded_at: String = row.get(2)?; + let title: String = row.get(3)?; + let audience: String = row.get(5)?; + let review_id: String = row.get(6)?; + let candidate_a: String = row.get(7)?; + let candidate_b: Option = row.get(8)?; + let min_responses: i64 = row.get(10)?; + let updated_at: String = row.get(13)?; + let receipt = json!({ + "schema_version": "codevetter.audience-validation-run/v1", + "id": id, + "review_id": review_id, + "repo_path": repo_path, + "audience": audience, + "task": title, + "candidate_a": candidate_a, + "candidate_b": candidate_b, + "criteria": criteria, + "min_responses": min_responses, + "required": required, + "status": status, + "waived_reason": waived_reason, + "created_at": recorded_at, + "updated_at": updated_at, + }); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id, + kind: RunKind::AudienceValidation, + repo_path, + recorded_at, + title, + outcome: status, + receipt_schema: "codevetter.audience-validation-run/v1".into(), + source_label: Some(audience), + limitations, + receipt, + }) + }, + )?; + let run_ids = runs.iter().map(|run| run.id.clone()).collect::>(); + let mut responses_by_run = audience_responses(connection, &run_ids)?; + for run in &mut runs { + let responses = responses_by_run.remove(&run.id).unwrap_or_default(); + if let Some(receipt) = run.receipt.as_object_mut() { + receipt.insert("response_count".into(), json!(responses.len())); + receipt.insert("responses".into(), Value::Array(responses)); + } + } + Ok(runs) +} + +fn audience_responses( + connection: &Connection, + run_ids: &[String], +) -> Result>, String> { + if run_ids.is_empty() { + return Ok(HashMap::new()); + } + let placeholders = (1..=run_ids.len()) + .map(|index| format!("?{index}")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT run_id, id, participant_id, provenance, criterion, candidate_a, candidate_b, + preferred_candidate, reverse_preferred_candidate, confidence, task_passed, + feedback, evidence_ref, elapsed_ms, created_at + FROM audience_validation_responses + WHERE run_id IN ({placeholders}) + ORDER BY run_id ASC, created_at ASC, id ASC" + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("prepare audience response history: {error}"))?; + let rows = statement + .query_map(params_from_iter(run_ids), |row| { + let run_id: String = row.get(0)?; + let task_passed: Option = row.get(10)?; + Ok(( + run_id.clone(), + json!({ + "id": row.get::<_, String>(1)?, + "run_id": run_id, + "participant_id": row.get::<_, String>(2)?, + "provenance": row.get::<_, String>(3)?, + "criterion": row.get::<_, String>(4)?, + "candidate_a": row.get::<_, String>(5)?, + "candidate_b": row.get::<_, Option>(6)?, + "preferred_candidate": row.get::<_, Option>(7)?, + "reverse_preferred_candidate": row.get::<_, Option>(8)?, + "confidence": row.get::<_, f64>(9)?, + "task_passed": task_passed.map(|value| value != 0), + "feedback": row.get::<_, Option>(11)?, + "evidence_ref": row.get::<_, Option>(12)?, + "elapsed_ms": row.get::<_, Option>(13)?, + "created_at": row.get::<_, String>(14)?, + }), + )) + }) + .map_err(|error| format!("read audience response history: {error}"))?; + let mut responses = HashMap::>::new(); + for row in rows { + let (run_id, response) = + row.map_err(|error| format!("decode audience response history: {error}"))?; + responses.entry(run_id).or_default().push(response); + } + Ok(responses) +} + +fn filtered_sql( + base: &str, + repo_path: Option<&str>, + order_column: &str, + identity_column: &str, +) -> String { + let filter = if repo_path.is_some() { + " WHERE repo_path = ?1" + } else { + "" + }; + let limit_parameter = if repo_path.is_some() { "?2" } else { "?1" }; + format!( + "{base}{filter} ORDER BY {order_column} DESC, {identity_column} DESC LIMIT {limit_parameter}" + ) +} + +fn query_rows( + connection: &Connection, + sql: &str, + repo_path: Option<&str>, + limit: usize, + label: &str, + map: F, +) -> Result, String> +where + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let mut statement = connection + .prepare(sql) + .map_err(|error| format!("prepare {label} history: {error}"))?; + let rows = match repo_path { + Some(repo_path) => statement.query_map(params![repo_path, limit as i64], map), + None => statement.query_map(params![limit as i64], map), + } + .map_err(|error| format!("read {label} history: {error}"))?; + rows.collect::>>() + .map_err(|error| format!("decode {label} history: {error}")) +} + +fn decode_json(text: String, label: &str) -> rusqlite::Result { + serde_json::from_str(&text).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + format!("invalid stored {label} JSON: {error}").into(), + ) + }) +} + +fn decode_json_or_raw(text: Option) -> Value { + match text { + Some(text) => serde_json::from_str(&text).unwrap_or(Value::String(text)), + None => Value::Null, + } +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn string_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value.get(field).and_then(Value::as_str) +} + +fn string_array(value: &Value, field: &str) -> Vec { + value + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() +} + +fn short_identity(identity: &str) -> String { + identity.chars().take(12).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + #[test] + fn projects_all_retained_run_families_in_one_bounded_order() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + seed_run_families(&connection); + + let history = list_run_history(&connection, Some("/repo"), 7).expect("history"); + + assert_eq!(history.schema_version, "codevetter.run-history/v1"); + assert_eq!(history.returned, 7); + assert_eq!(history.runs[0].kind, RunKind::TrexPr); + assert_eq!(history.runs[1].kind, RunKind::SyntheticQa); + assert_eq!(history.runs[2].kind, RunKind::AudienceValidation); + assert_eq!(history.runs[3].kind, RunKind::DifferentialVerification); + assert_eq!(history.runs[4].kind, RunKind::WarmVerification); + assert_eq!(history.runs[5].kind, RunKind::Preview); + assert_eq!(history.runs[6].kind, RunKind::LocalCheck); + assert_eq!(history.runs[2].receipt["response_count"], 1); + assert_eq!( + history.runs[2].receipt["responses"][0]["participant_id"], + "participant-1" + ); + assert!(history + .runs + .iter() + .all(|run| run.repo_path.as_deref() == Some("/repo"))); + } + + #[test] + fn global_history_includes_null_repo_audience_rows_without_weakening_filtering() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + seed_run_families(&connection); + connection + .execute( + "UPDATE audience_validation_runs SET repo_path = NULL WHERE id = 'audience-1'", + [], + ) + .expect("null repo audience"); + + let global = list_run_history(&connection, None, 100).expect("global history"); + let filtered = list_run_history(&connection, Some("/repo"), 100).expect("filtered history"); + + assert!(global.runs.iter().any(|run| run.id == "audience-1")); + assert!(!filtered.runs.iter().any(|run| run.id == "audience-1")); + } + + #[test] + #[ignore = "release-mode performance evidence; run explicitly with --nocapture"] + fn benchmark_seven_family_projection_over_seven_hundred_rows() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + seed_large_ledger(&connection); + + for _ in 0..20 { + assert_eq!( + list_run_history(&connection, Some("/repo"), 100) + .expect("warm projection") + .returned, + 100 + ); + } + + let mut samples_us = Vec::with_capacity(250); + for _ in 0..250 { + let started = Instant::now(); + let history = + list_run_history(&connection, Some("/repo"), 100).expect("measured projection"); + samples_us.push(started.elapsed().as_micros() as u64); + assert_eq!(history.returned, 100); + } + samples_us.sort_unstable(); + let median_us = samples_us[samples_us.len() / 2]; + let p95_us = samples_us[(samples_us.len() * 95 / 100).min(samples_us.len() - 1)]; + println!( + "RUN_HISTORY_BENCHMARK_JSON {}", + json!({ + "schema_version": "codevetter.native-run-history-benchmark/v1", + "stored_run_rows": 700, + "stored_audience_response_rows": 100, + "returned_rows": 100, + "families": 7, + "warmups": 20, + "samples": 250, + "median_us": median_us, + "p95_us": p95_us, + }) + ); + } + + fn seed_run_families(connection: &Connection) { + connection + .execute_batch( + r#" + INSERT INTO local_reviews(id, review_type, source_label, repo_path, repo_full_name, + pr_number, status, created_at) + VALUES('review-1', 'pull_request', 'PR #1', '/repo', 'fleet/codevetter', 1, 'complete', + '2026-08-31T00:00:00Z'); + INSERT INTO local_check_runs(run_id, schema_version, repo_path, base_sha, head_sha, + verdict, task, receipt_json, ran_at) + VALUES('local-1', 'codevetter.local-check/v1', '/repo', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'passed_with_limits', 'Local check', + '{"schema_version":"codevetter.local-check/v1","limitations":["bounded"]}', + '2026-08-31T01:00:00Z'); + INSERT INTO trex_preview_runs(id, repo_path, source_kind, source_input, base_sha, + head_sha, preview_url, preview_identity, verdict, summary, receipt_json, + duration_ms, ran_at) + VALUES('preview-1', '/repo', 'range', 'main...HEAD', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'https://preview.test', 'identity', + 'passed_with_limits', 'Preview check', '{"limitations":[]}', 1, + '2026-08-31T02:00:00Z'); + INSERT INTO warm_verification_runs(id, repo_path, run_id, schema_version, + protocol_version, outcome, target_sha, change_set_kind, change_set_id, started_at, + finished_at, warm, stale, result_json, created_at) + VALUES('warm-1', '/repo', 'warm-run-1', 1, 1, 'passed', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'range', 'identity', + '2026-08-31T03:00:00Z', '2026-08-31T03:00:01Z', 1, 0, + '{"warm":true,"limitations":[]}', '2026-08-31T03:00:01Z'); + INSERT INTO differential_verification_runs(id, repo_path, run_id, schema_version, + status, classification, reference_sha, candidate_kind, candidate_identity, + plan_identity, duration_ms, cleanup_complete, summary_json, created_at) + VALUES('differential-1', '/repo', 'diff-run-1', 1, 'complete', 'unchanged', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'worktree', 'identity', 'plan', 1, 1, + '{"limitations":[]}', '2026-08-31T04:00:00Z'); + INSERT INTO audience_validation_runs(id, review_id, repo_path, audience, task, + candidate_a, candidate_b, criteria_json, min_responses, required, status, + created_at, updated_at) + VALUES('audience-1', 'review-1', '/repo', 'maintainers', 'Audience check', 'a', 'b', + '["correctness"]', 3, 1, 'collecting', '2026-08-31T05:00:00Z', + '2026-08-31T05:00:00Z'); + INSERT INTO audience_validation_responses(id, run_id, participant_id, provenance, + criterion, candidate_a, candidate_b, preferred_candidate, confidence, task_passed, + feedback, created_at) + VALUES('response-1', 'audience-1', 'participant-1', 'human', 'correctness', 'a', 'b', + 'a', 0.9, 1, 'Clearer evidence', '2026-08-31T05:00:01Z'); + INSERT INTO synthetic_qa_runs(id, review_id, repo_path, loop_id, runner_type, base_url, + route, goal, pass, duration_ms, notes, screenshot_path, artifacts, console_errors, + error, trace_json, created_at) + VALUES('synthetic-1', 'review-1', '/repo', 'loop-1', 'playwright_builtin', + 'http://127.0.0.1:1420', '/review', 'Verify the review flow', 0, 240, 'blocked', + NULL, '["trace.zip"]', 1, 'Expected evidence was missing', + '{"final_url":"http://127.0.0.1:1420/review"}', '2026-08-31T06:00:00Z'); + INSERT INTO trex_pr_runs(id, repo_path, pr_number, head_sha, verdict, confidence, + summary, status_state, duration_ms, ran_at) + VALUES('trex-pr-1', '/repo', 201, + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'NEEDS_REVIEW', 0.82, + 'One evidence gap remains', 'pending', 320, '2026-08-31T07:00:00Z'); + "#, + ) + .expect("seed run families"); + } + + fn seed_large_ledger(connection: &Connection) { + connection + .execute_batch( + r#" + INSERT INTO local_reviews(id, review_type, source_label, repo_path, + repo_full_name, pr_number, status, created_at) + VALUES('review-1', 'pull_request', 'PR #1', '/repo', 'fleet/codevetter', 1, + 'complete', '2026-08-31T00:00:00Z'); + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO local_check_runs(run_id, schema_version, repo_path, base_sha, + head_sha, verdict, task, receipt_json, ran_at) + SELECT printf('local-%03d', x), 'codevetter.local-check/v1', '/repo', + printf('%040d', x), printf('%040d', x + 1), 'passed_with_limits', + printf('Local check %03d', x), + '{"schema_version":"codevetter.local-check/v1","limitations":[]}', + printf('2026-08-31T01:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO trex_preview_runs(id, repo_path, source_kind, source_input, base_sha, + head_sha, preview_url, preview_identity, verdict, summary, receipt_json, + duration_ms, ran_at) + SELECT printf('preview-%03d', x), '/repo', 'range', 'main...HEAD', + printf('%040d', x), printf('%040d', x + 1), 'https://preview.test', + printf('preview-identity-%03d', x), 'passed_with_limits', + printf('Preview %03d', x), '{"limitations":[]}', 1, + printf('2026-08-31T02:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO warm_verification_runs(id, repo_path, run_id, schema_version, + protocol_version, outcome, target_sha, change_set_kind, change_set_id, + started_at, finished_at, warm, stale, result_json, created_at) + SELECT printf('warm-%03d', x), '/repo', printf('warm-run-%03d', x), 1, 1, + 'passed', printf('%040d', x + 1), 'range', printf('warm-change-%03d', x), + printf('2026-08-31T03:%02d:%02dZ', x / 60, x % 60), + printf('2026-08-31T03:%02d:%02dZ', x / 60, x % 60), 1, 0, + '{"warm":true,"limitations":[]}', + printf('2026-08-31T03:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO differential_verification_runs(id, repo_path, run_id, + schema_version, status, classification, reference_sha, candidate_kind, + candidate_identity, plan_identity, duration_ms, cleanup_complete, + summary_json, created_at) + SELECT printf('differential-%03d', x), '/repo', printf('diff-run-%03d', x), 1, + 'complete', 'unchanged', printf('%040d', x), 'worktree', + printf('candidate-%03d', x), printf('plan-%03d', x), 1, 1, + '{"limitations":[]}', + printf('2026-08-31T04:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO audience_validation_runs(id, review_id, repo_path, audience, task, + candidate_a, candidate_b, criteria_json, min_responses, required, status, + created_at, updated_at) + SELECT printf('audience-%03d', x), 'review-1', '/repo', 'maintainers', + printf('Audience check %03d', x), 'a', 'b', '["correctness"]', 3, 1, + 'complete', printf('2026-08-31T05:%02d:%02dZ', x / 60, x % 60), + printf('2026-08-31T05:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO audience_validation_responses(id, run_id, participant_id, provenance, + criterion, candidate_a, candidate_b, preferred_candidate, confidence, + task_passed, created_at) + SELECT printf('response-%03d', x), printf('audience-%03d', x), + printf('participant-%03d', x), 'human', 'correctness', 'a', 'b', 'a', 0.9, 1, + printf('2026-08-31T05:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO synthetic_qa_runs(id, review_id, repo_path, loop_id, runner_type, + route, goal, pass, duration_ms, artifacts, console_errors, trace_json, created_at) + SELECT printf('synthetic-%03d', x), 'review-1', '/repo', + printf('loop-%03d', x), 'playwright_builtin', '/review', + printf('Synthetic QA %03d', x), 1, 10, '[]', 0, + '{"final_url":"http://127.0.0.1/review"}', + printf('2026-08-31T06:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO trex_pr_runs(id, repo_path, pr_number, head_sha, verdict, confidence, + summary, status_state, duration_ms, ran_at) + SELECT printf('trex-pr-%03d', x), '/repo', x, printf('%040d', x + 1), + 'APPROVE', 0.9, printf('PR run %03d', x), 'success', 10, + printf('2026-08-31T07:%02d:%02dZ', x / 60, x % 60) FROM n; + "#, + ) + .expect("seed 700-run ledger"); + } +} diff --git a/apps/desktop/src-tauri/src/commands/sandbox.rs b/apps/desktop/src-tauri/src/commands/sandbox.rs index 2da9570d..2ca5a701 100644 --- a/apps/desktop/src-tauri/src/commands/sandbox.rs +++ b/apps/desktop/src-tauri/src/commands/sandbox.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::Arc; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -158,14 +159,33 @@ pub async fn run_branch_sandbox_inner( app: AppHandle, db: &DbState, input: SandboxRunInput, +) -> Result { + let emitter: SandboxStepEmitter = Arc::new(move |step| { + let _ = app.emit(STEP_EVENT, step); + }); + run_branch_sandbox_with_emitter(db, input, emitter).await +} + +/// Headless sandbox entry point for the CLI/native bridge. The canonical +/// sandbox logic remains Rust-owned; only transient Tauri progress events are +/// omitted when no webview is present. +pub async fn run_branch_sandbox_headless( + db: &DbState, + input: SandboxRunInput, +) -> Result { + run_branch_sandbox_with_emitter(db, input, Arc::new(|_| {})).await +} + +type SandboxStepEmitter = Arc; + +async fn run_branch_sandbox_with_emitter( + db: &DbState, + input: SandboxRunInput, + emit: SandboxStepEmitter, ) -> Result { let started = Instant::now(); let run_id = uuid::Uuid::new_v4().to_string(); - let emit = |s: SandboxStep| { - let _ = app.emit(STEP_EVENT, s); - }; - emit(SandboxStep::Phase { phase: "setup".into(), detail: Some(format!("branch={}", input.branch)), @@ -195,7 +215,7 @@ pub async fn run_branch_sandbox_inner( phase: "install".into(), detail: Some("npm install".into()), }); - if let Err(e) = run_npm_install(&worktree_path).await { + if let Err(e) = run_node_install(&worktree_path).await { let _ = remove_worktree(&input.repo_path, &worktree_path); return Ok(failed_result( run_id, @@ -261,9 +281,9 @@ pub async fn run_branch_sandbox_inner( project_dir: None, // server is already up; don't re-launch }; let brain = CliBrain::new(input.options.provider.clone(), None); - let app_clone = app.clone(); + let emit_agent = emit.clone(); let result = run_with_brain(agent_input, brain, move |step| { - let _ = app_clone.emit(STEP_EVENT, SandboxStep::Agent { step: step.clone() }); + emit_agent(SandboxStep::Agent { step: step.clone() }); }) .await; match result { @@ -442,15 +462,113 @@ async fn has_node_modules(dir: &Path) -> bool { tokio::fs::metadata(dir.join("node_modules")).await.is_ok() } -async fn run_npm_install(dir: &Path) -> Result<(), String> { - let out = Command::new("npm") - .args(["install", "--no-audit", "--no-fund", "--prefer-offline"]) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NodePackageManager { + Pnpm, + Npm, + YarnBerry, + YarnClassic, + Bun, +} + +impl NodePackageManager { + fn run_script(self, script: &str) -> String { + match (self, script) { + (Self::Pnpm, "test") => "pnpm test".to_string(), + (Self::Npm, "test") => "npm test --silent".to_string(), + (Self::YarnBerry | Self::YarnClassic, "test") => "yarn test".to_string(), + (Self::Bun, "test") => "bun test".to_string(), + (Self::Pnpm, _) => format!("pnpm run {script}"), + (Self::Npm, _) => format!("npm run {script} --silent"), + (Self::YarnBerry | Self::YarnClassic, _) => format!("yarn {script}"), + (Self::Bun, _) => format!("bun run {script}"), + } + } +} + +async fn detect_node_package_manager( + dir: &Path, + package_json: Option<&Value>, +) -> NodePackageManager { + if let Some(name) = package_json + .and_then(|value| value.get("packageManager")) + .and_then(Value::as_str) + .and_then(|value| value.split('@').next()) + { + match name { + "pnpm" => return NodePackageManager::Pnpm, + "yarn" => { + return if tokio::fs::metadata(dir.join(".yarnrc.yml")).await.is_ok() { + NodePackageManager::YarnBerry + } else { + NodePackageManager::YarnClassic + }; + } + "bun" => return NodePackageManager::Bun, + "npm" => return NodePackageManager::Npm, + _ => {} + } + } + if tokio::fs::metadata(dir.join("pnpm-lock.yaml")) + .await + .is_ok() + { + NodePackageManager::Pnpm + } else if tokio::fs::metadata(dir.join("yarn.lock")).await.is_ok() { + if tokio::fs::metadata(dir.join(".yarnrc.yml")).await.is_ok() { + NodePackageManager::YarnBerry + } else { + NodePackageManager::YarnClassic + } + } else if tokio::fs::metadata(dir.join("bun.lock")).await.is_ok() + || tokio::fs::metadata(dir.join("bun.lockb")).await.is_ok() + { + NodePackageManager::Bun + } else { + NodePackageManager::Npm + } +} + +async fn run_node_install(dir: &Path) -> Result<(), String> { + let package_json = tokio::fs::read_to_string(dir.join("package.json")) + .await + .ok() + .and_then(|contents| serde_json::from_str::(&contents).ok()); + let manager = detect_node_package_manager(dir, package_json.as_ref()).await; + let (program, args): (&str, Vec<&str>) = match manager { + NodePackageManager::Pnpm => ( + "pnpm", + vec!["install", "--frozen-lockfile", "--prefer-offline"], + ), + NodePackageManager::Npm + if tokio::fs::metadata(dir.join("package-lock.json")) + .await + .is_ok() => + { + ( + "npm", + vec!["ci", "--no-audit", "--no-fund", "--prefer-offline"], + ) + } + NodePackageManager::Npm => ( + "npm", + vec!["install", "--no-audit", "--no-fund", "--prefer-offline"], + ), + NodePackageManager::YarnBerry => ("yarn", vec!["install", "--immutable"]), + NodePackageManager::YarnClassic => ("yarn", vec!["install", "--frozen-lockfile"]), + NodePackageManager::Bun => ("bun", vec!["install", "--frozen-lockfile"]), + }; + let out = Command::new(program) + .args(&args) .current_dir(dir) .output() .await - .map_err(|e| format!("spawn npm install: {e}"))?; + .map_err(|e| format!("spawn {program} install: {e}"))?; if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + return Err(format!( + "{program} install failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); } Ok(()) } @@ -461,19 +579,22 @@ const TEST_TIMEOUT_SECS: u64 = 600; const LOG_TAIL_BYTES: usize = 8 * 1024; pub(crate) async fn discover_test_command(dir: &Path) -> Option { - // 1) package.json scripts.test wins. + // 1) Prefer the repository's declared package manager and strongest + // repository-owned verification script. The closed order avoids running + // arbitrary package scripts selected from untrusted names. if let Ok(contents) = tokio::fs::read_to_string(dir.join("package.json")).await { if let Ok(v) = serde_json::from_str::(&contents) { - if let Some(script) = v - .get("scripts") - .and_then(|s| s.get("test")) - .and_then(|t| t.as_str()) - { - if !script.trim().is_empty() - && !script.contains("Error: no test specified") - && !script.contains("echo \"Error: no test") - { - return Some("npm test --silent".to_string()); + let manager = detect_node_package_manager(dir, Some(&v)).await; + if let Some(scripts) = v.get("scripts").and_then(Value::as_object) { + for script_name in ["test", "test:unit", "check", "lint", "typecheck"] { + if let Some(script) = scripts.get(script_name).and_then(Value::as_str) { + if !script.trim().is_empty() + && !script.contains("Error: no test specified") + && !script.contains("echo \"Error: no test") + { + return Some(manager.run_script(script_name)); + } + } } } } @@ -902,6 +1023,69 @@ mod tests { cleanup(&dir); } + #[tokio::test] + async fn discovers_pnpm_workspace_safe_check_without_a_test_alias() { + let dir = tempdir(); + tokio::fs::write( + dir.join("package.json"), + r#"{ + "name":"workspace", + "packageManager":"pnpm@10.33.2", + "scripts": { "verify": "node verify-cli.mjs", "lint": "biome check ." } + }"#, + ) + .await + .unwrap(); + tokio::fs::write(dir.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n") + .await + .unwrap(); + assert_eq!( + discover_test_command(&dir).await.as_deref(), + Some("pnpm run lint") + ); + cleanup(&dir); + } + + #[tokio::test] + async fn explicit_test_script_precedes_generic_workspace_checks() { + let dir = tempdir(); + tokio::fs::write( + dir.join("package.json"), + r#"{ + "name":"workspace", + "packageManager":"pnpm@10.33.2", + "scripts": { "test": "vitest run", "lint": "biome check ." } + }"#, + ) + .await + .unwrap(); + assert_eq!( + discover_test_command(&dir).await.as_deref(), + Some("pnpm test") + ); + cleanup(&dir); + } + + #[tokio::test] + async fn unit_test_alias_precedes_static_checks() { + let dir = tempdir(); + tokio::fs::write( + dir.join("package.json"), + r#"{ + "name":"workspace", + "packageManager":"pnpm@10.33.2", + "scripts": { "test:unit": "vitest run", "lint": "biome check ." } + }"#, + ) + .await + .unwrap(); + assert_eq!( + discover_test_command(&dir).await.as_deref(), + Some("pnpm run test:unit") + ); + cleanup(&dir); + } + #[tokio::test] async fn skips_default_npm_init_test_placeholder() { let dir = tempdir(); diff --git a/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs b/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs index 20e86d1f..fa73890d 100644 --- a/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs +++ b/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs @@ -45,22 +45,22 @@ pub enum ScenarioCompilerAction { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct ContextSelection { - capabilities: Vec, - auth_profiles: Vec, - states: Vec, - routes: Vec, - include_request_policy: bool, - examples: Vec, + pub capabilities: Vec, + pub auth_profiles: Vec, + pub states: Vec, + pub routes: Vec, + pub include_request_policy: bool, + pub examples: Vec, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ProviderSelection { - kind: String, - provider: String, - model: String, - cost_class: String, - paid_approved: bool, + pub kind: String, + pub provider: String, + pub model: String, + pub cost_class: String, + pub paid_approved: bool, } #[derive(Debug, Deserialize, Serialize)] @@ -160,6 +160,13 @@ pub struct ScenarioCompilerActionResult { pub async fn run_scenario_compiler_action( repo_path: String, action: ScenarioCompilerAction, +) -> Result { + run_scenario_compiler_action_headless(repo_path, action).await +} + +pub async fn run_scenario_compiler_action_headless( + repo_path: String, + action: ScenarioCompilerAction, ) -> Result { let (arguments, expected_action, deadline) = action_arguments(&action)?; let references = arguments.iter().map(String::as_str).collect::>(); diff --git a/apps/desktop/src-tauri/src/commands/session_adapters.rs b/apps/desktop/src-tauri/src/commands/session_adapters.rs index 254020d4..8775570d 100644 --- a/apps/desktop/src-tauri/src/commands/session_adapters.rs +++ b/apps/desktop/src-tauri/src/commands/session_adapters.rs @@ -1456,10 +1456,9 @@ mod tests { dispositions: Vec, } - fn parse_codex_chunks( - raw: &str, - boundaries: usize, - ) -> (i64, i64, i64, Vec<(String, String, Option)>) { + type CodexChunkSummary = (i64, i64, i64, Vec<(String, String, Option)>); + + fn parse_codex_chunks(raw: &str, boundaries: usize) -> CodexChunkSummary { let lines = raw.lines().collect::>(); let mut state = None; let mut input = 0; diff --git a/apps/desktop/src-tauri/src/commands/session_retention.rs b/apps/desktop/src-tauri/src/commands/session_retention.rs index a3e8dab4..4b7b70e6 100644 --- a/apps/desktop/src-tauri/src/commands/session_retention.rs +++ b/apps/desktop/src-tauri/src/commands/session_retention.rs @@ -47,6 +47,25 @@ pub struct SessionRetentionPlan { pub created_at: String, } +pub const SESSION_RETENTION_SCHEMA_VERSION: &str = "codevetter.session-retention/v1"; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionRetentionOperation { + Plan, + Apply, + Checkpoint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionRetentionReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: SessionRetentionOperation, + pub plan: Option, + pub result: Option, +} + #[derive(Debug, Clone)] struct SessionArchiveStat { session_id: String, @@ -62,9 +81,7 @@ pub async fn plan_session_retention( ) -> Result { validate_policy(&policy)?; let conn = db.0.lock().map_err(|error| error.to_string())?; - let plan = build_plan(&conn, policy)?; - persist_plan(&conn, &plan)?; - Ok(plan) + plan_session_retention_with_connection(&conn, policy) } #[tauri::command] @@ -74,7 +91,7 @@ pub async fn apply_session_retention( ) -> Result { let plan_id = bounded_id(&plan_id, "plan id")?; let mut conn = db.0.lock().map_err(|error| error.to_string())?; - apply_plan(&mut conn, &plan_id) + apply_session_retention_with_connection(&mut conn, &plan_id) } #[tauri::command] @@ -127,9 +144,35 @@ pub async fn compact_session_archive( vacuum: Option, ) -> Result { let conn = db.0.lock().map_err(|error| error.to_string())?; + compact_session_archive_with_connection(&conn, vacuum.unwrap_or(false)) +} + +pub fn plan_session_retention_with_connection( + connection: &Connection, + policy: SessionRetentionPolicy, +) -> Result { + validate_policy(&policy)?; + let plan = build_plan(connection, policy)?; + persist_plan(connection, &plan)?; + Ok(plan) +} + +pub fn apply_session_retention_with_connection( + connection: &mut Connection, + plan_id: &str, +) -> Result { + let plan_id = bounded_id(plan_id, "plan id")?; + apply_plan(connection, &plan_id) +} + +pub fn compact_session_archive_with_connection( + connection: &Connection, + vacuum: bool, +) -> Result { + let conn = connection; conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") .map_err(|error| error.to_string())?; - if vacuum.unwrap_or(false) { + if vacuum { conn.execute_batch("VACUUM;") .map_err(|error| error.to_string())?; } @@ -152,7 +195,7 @@ pub async fn compact_session_archive( params![ event_id, run_id, - json!({ "vacuum": vacuum.unwrap_or(false) }).to_string(), + json!({ "vacuum": vacuum }).to_string(), created_at ], ) @@ -161,11 +204,51 @@ pub async fn compact_session_archive( Ok(json!({ "checkpointed": true, - "vacuumed": vacuum.unwrap_or(false), + "vacuumed": vacuum, "createdAt": created_at, })) } +pub fn run_session_retention_operation( + connection: &mut Connection, + operation: SessionRetentionOperation, + policy: Option, + plan_id: Option<&str>, + vacuum: bool, +) -> Result { + let (plan, result) = match operation { + SessionRetentionOperation::Plan => { + let policy = + policy.ok_or_else(|| "Retention planning requires a policy".to_string())?; + ( + Some(plan_session_retention_with_connection(connection, policy)?), + None, + ) + } + SessionRetentionOperation::Apply => { + let plan_id = + plan_id.ok_or_else(|| "Retention apply requires a plan id".to_string())?; + ( + None, + Some(apply_session_retention_with_connection( + connection, plan_id, + )?), + ) + } + SessionRetentionOperation::Checkpoint => ( + None, + Some(compact_session_archive_with_connection(connection, vacuum)?), + ), + }; + Ok(SessionRetentionReceipt { + schema_version: SESSION_RETENTION_SCHEMA_VERSION.to_string(), + generated_at: Utc::now().to_rfc3339(), + operation, + plan, + result, + }) +} + fn validate_policy(policy: &SessionRetentionPolicy) -> Result<(), String> { if policy.max_age_days.is_none() && policy.max_archive_bytes.is_none() { return Err("Set an age or archive-size limit".to_string()); @@ -726,4 +809,27 @@ mod tests { assert_eq!(plan.candidates.len(), 1); assert_eq!(plan.candidate_rows, 1_003); } + + #[test] + fn shared_operation_receipt_preserves_preview_without_touching_source_sessions() { + let mut conn = fixture(); + let receipt = run_session_retention_operation( + &mut conn, + SessionRetentionOperation::Plan, + Some(SessionRetentionPolicy { + max_age_days: Some(30), + max_archive_bytes: None, + }), + None, + false, + ) + .expect("receipt"); + assert_eq!(receipt.schema_version, SESSION_RETENTION_SCHEMA_VERSION); + assert_eq!(receipt.operation, SessionRetentionOperation::Plan); + assert_eq!(receipt.plan.as_ref().expect("plan").candidate_rows, 3); + let source_sessions: i64 = conn + .query_row("SELECT COUNT(*) FROM cc_sessions", [], |row| row.get(0)) + .expect("session count"); + assert_eq!(source_sessions, 4); + } } diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs index 196eba54..c147d044 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs @@ -31,7 +31,7 @@ struct StructuralGraphQueryIndex { static QUERY_INDEXES: OnceLock>>> = OnceLock::new(); -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GraphDirection { Incoming, @@ -221,5 +221,14 @@ pub use projection::{ pub use search::{explain, neighbors, resolve_node, search, search_page}; pub use traversal::{impact, shortest_path}; +/// Materialize the bounded query index without running a synthetic search. +/// +/// Native clients use this through the read-only repository query worker so +/// the first intentional query does not pay index construction latency. The +/// cache and all ranking semantics remain owned by this canonical module. +pub fn prepare_search_index(snapshot: &StructuralGraphSnapshot) { + let _ = query_index(snapshot); +} + #[cfg(test)] mod tests; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/service.rs b/apps/desktop/src-tauri/src/commands/structural_graph/service.rs index 06883a44..14e30491 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/service.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/service.rs @@ -7,8 +7,9 @@ use super::{ StructuralGraphMetadata, }, storage::{ - list_snapshot_summaries, load_latest_snapshot, load_snapshot_by_id, - StructuralGraphStoredSummary, + list_snapshot_summaries, load_edges_by_ids, load_interactive_snapshot_by_id, + load_latest_snapshot, load_latest_snapshot_summary, load_search_snapshot_by_id, + load_snapshot_by_id, load_traversal_edges_by_snapshot_id, StructuralGraphStoredSummary, }, types::StructuralGraphSnapshot, }; @@ -76,6 +77,40 @@ impl<'a> StructuralGraphReadService<'a> { .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) } + pub fn search_snapshot_by_id( + &self, + snapshot_id: &str, + ) -> Result { + load_search_snapshot_by_id(self.connection, &self.repo_path, snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) + } + + pub fn interactive_snapshot_by_id( + &self, + snapshot_id: &str, + ) -> Result { + load_interactive_snapshot_by_id(self.connection, &self.repo_path, snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) + } + + pub fn traversal_edges_by_snapshot_id( + &self, + snapshot_id: &str, + ) -> Result, String> { + load_traversal_edges_by_snapshot_id(self.connection, snapshot_id) + .map_err(|error| error.to_string()) + } + + pub fn edges_by_ids( + &self, + snapshot_id: &str, + edge_ids: &[String], + ) -> Result, String> { + load_edges_by_ids(self.connection, snapshot_id, edge_ids).map_err(|error| error.to_string()) + } + pub fn status(&self) -> Result { self.status_with_current_head(self.current_head.clone()) } @@ -84,7 +119,7 @@ impl<'a> StructuralGraphReadService<'a> { &self, current_head: Option, ) -> Result { - let snapshot = load_latest_snapshot(self.connection, &self.repo_path) + let snapshot = load_latest_snapshot_summary(self.connection, &self.repo_path) .map_err(|error| error.to_string())?; Ok(match snapshot { Some(snapshot) => StructuralGraphReadStatus { @@ -94,12 +129,12 @@ impl<'a> StructuralGraphReadService<'a> { indexed_head: snapshot.repo_head.clone(), snapshot_id: Some(snapshot.id.clone()), schema_version: Some(snapshot.schema_version), - engine_id: Some(snapshot.engine.id.clone()), - engine_version: Some(snapshot.engine.version.clone()), + engine_id: Some(snapshot.engine_id.clone()), + engine_version: Some(snapshot.engine_version.clone()), created_at: Some(snapshot.created_at.clone()), indexed_files: snapshot.coverage.indexed_files, - node_count: snapshot.nodes.len(), - edge_count: snapshot.edges.len(), + node_count: snapshot.node_count, + edge_count: snapshot.edge_count, truncated: snapshot.truncated, }, None => StructuralGraphReadStatus { @@ -120,6 +155,10 @@ impl<'a> StructuralGraphReadService<'a> { }) } + pub fn current_head(&self) -> Option { + self.current_head.clone() + } + pub fn metadata(&self) -> Result { let mut metadata = query::metadata(&self.snapshot()?); metadata.freshness.stale = self @@ -174,6 +213,12 @@ impl<'a> StructuralGraphReadService<'a> { self.search_page(text, filter, limit, None) } + pub fn prepare_search_index(&self) -> Result<(), String> { + let snapshot = self.snapshot()?; + query::prepare_search_index(&snapshot); + Ok(()) + } + pub fn search_page( &self, text: &str, @@ -270,7 +315,8 @@ mod tests { use crate::commands::structural_graph::{ storage::persist_snapshot, types::{ - StructuralGraphCoverage, StructuralGraphEngineInfo, StructuralGraphSnapshot, + GraphOrigin, GraphTrust, StructuralGraphCoverage, StructuralGraphEdge, + StructuralGraphEngineInfo, StructuralGraphNode, StructuralGraphSnapshot, STRUCTURAL_GRAPH_SCHEMA_VERSION, }, }; @@ -296,8 +342,30 @@ mod tests { ignore_fingerprint: None, coverage: StructuralGraphCoverage::default(), files: Vec::new(), - nodes: Vec::new(), - edges: Vec::new(), + nodes: vec![StructuralGraphNode { + id: "node:verify".to_string(), + kind: "function".to_string(), + label: "verify_change".to_string(), + qualified_name: Some("verification::verify_change".to_string()), + path: Some("src/verify.rs".to_string()), + detail: Some("Canonical verification entrypoint".to_string()), + language: Some("rust".to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + }], + edges: vec![StructuralGraphEdge { + id: "edge:self".to_string(), + from: "node:verify".to_string(), + to: "node:verify".to_string(), + kind: "references".to_string(), + evidence: "fixture".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Resolution, + sources: Vec::new(), + candidates: Vec::new(), + }], metrics: Vec::new(), clone_groups: Vec::new(), communities: Vec::new(), @@ -315,7 +383,29 @@ mod tests { "snapshot" ); let overview = service.overview(10).expect("overview"); - assert_eq!(overview.nodes.len(), 0); + assert_eq!(overview.nodes.len(), 1); assert_eq!(overview.context.freshness.stale, Some(false)); + let search_snapshot = service + .search_snapshot_by_id("snapshot") + .expect("search projection"); + assert_eq!(search_snapshot.nodes.len(), 1); + assert!(search_snapshot.edges.is_empty()); + let interactive_snapshot = service + .interactive_snapshot_by_id("snapshot") + .expect("interactive projection"); + assert_eq!(interactive_snapshot.nodes.len(), 1); + assert_eq!(interactive_snapshot.edges.len(), 1); + assert!(interactive_snapshot.metrics.is_empty()); + assert!(interactive_snapshot.files.is_empty()); + let traversal_edges = service + .traversal_edges_by_snapshot_id("snapshot") + .expect("compact traversal edges"); + assert_eq!(traversal_edges.len(), 1); + assert!(traversal_edges[0].evidence.is_empty()); + let hydrated_edges = service + .edges_by_ids("snapshot", &["edge:self".to_string()]) + .expect("bounded hydrated edges"); + assert_eq!(hydrated_edges[0].evidence, "fixture"); + assert_eq!(service.snapshot_by_id("snapshot").unwrap().edges.len(), 1); } } diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs b/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs index aa858234..54b71db8 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs @@ -376,7 +376,150 @@ pub fn load_snapshot_by_id( repo_path: &str, snapshot_id: &str, ) -> Result, StructuralGraphError> { - let metadata = connection + hydrate_snapshot( + connection, + load_snapshot_metadata_by_id(connection, repo_path, snapshot_id)?, + ) +} + +/// Load only the canonical fields required by structural search. +/// +/// Traversal, explanation, and impact continue to hydrate the full snapshot. +/// The persistent native query worker uses this projection so an interactive +/// search does not retain unrelated edges, metrics, files, or diagnostics. +pub fn load_search_snapshot_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + hydrate_search_snapshot( + connection, + load_snapshot_metadata_by_id(connection, repo_path, snapshot_id)?, + ) +} + +/// Load the canonical nodes and edges required by interactive graph queries. +/// +/// Unlike the full snapshot this omits metrics, clones, communities, files, +/// and diagnostics. The native query worker can therefore retain traversal +/// capability without holding analysis-only payloads for its whole lifetime. +pub fn load_interactive_snapshot_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + hydrate_interactive_snapshot( + connection, + load_snapshot_metadata_by_id(connection, repo_path, snapshot_id)?, + ) +} + +/// Load the compact edge fields needed by canonical traversal algorithms. +/// Result edges are hydrated separately, after bounds have reduced the set. +pub fn load_traversal_edges_by_snapshot_id( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT id, from_id, to_id, kind, trust + FROM structural_graph_edges WHERE snapshot_id = ?1 + ORDER BY id", + ) + .map_err(storage_error("prepare structural graph traversal edges"))?; + let edges = statement + .query_map(params![snapshot_id], |row| { + Ok(StructuralGraphEdge { + id: row.get(0)?, + from: row.get(1)?, + to: row.get(2)?, + kind: row.get(3)?, + evidence: String::new(), + trust: GraphTrust::from_storage(&row.get::<_, String>(4)?), + origin: GraphOrigin::LegacyMetadata, + sources: Vec::new(), + candidates: Vec::new(), + }) + }) + .map_err(storage_error("query structural graph traversal edges"))? + .collect::, _>>() + .map_err(storage_error("read structural graph traversal edges"))?; + Ok(edges) +} + +/// Hydrate only bounded result edges after traversal has selected their IDs. +pub fn load_edges_by_ids( + connection: &Connection, + snapshot_id: &str, + edge_ids: &[String], +) -> Result, StructuralGraphError> { + if edge_ids.is_empty() { + return Ok(Vec::new()); + } + let mut edges = Vec::new(); + for chunk in edge_ids.chunks(250) { + let placeholders = (0..chunk.len()) + .map(|index| format!("?{}", index + 2)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT id, from_id, to_id, kind, evidence, trust, origin, candidates_json + FROM structural_graph_edges + WHERE snapshot_id = ?1 AND id IN ({placeholders})" + ); + let mut values = Vec::with_capacity(chunk.len() + 1); + values.push(rusqlite::types::Value::Text(snapshot_id.to_string())); + values.extend(chunk.iter().cloned().map(rusqlite::types::Value::Text)); + let mut statement = connection + .prepare(&sql) + .map_err(storage_error("prepare bounded structural graph edges"))?; + let rows = statement + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + )) + }) + .map_err(storage_error("query bounded structural graph edges"))? + .collect::, _>>() + .map_err(storage_error("read bounded structural graph edges"))?; + let mut sources = load_sources_for_targets(connection, snapshot_id, "edge", chunk)?; + edges.extend( + rows.into_iter() + .map( + |(id, from, to, kind, evidence, trust, origin, candidates_json)| { + Ok(StructuralGraphEdge { + sources: sources.remove(&id).unwrap_or_default(), + id, + from, + to, + kind, + evidence, + trust: GraphTrust::from_storage(&trust), + origin: GraphOrigin::from_storage(&origin), + candidates: from_json(&candidates_json, "edge candidates")?, + }) + }, + ) + .collect::, StructuralGraphError>>()?, + ); + } + edges.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(edges) +} + +fn load_snapshot_metadata_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + connection .query_row( "SELECT id, repo_path, repo_head, schema_version, engine_json, cursor, ignore_fingerprint, coverage_json, truncated, created_at @@ -399,8 +542,7 @@ pub fn load_snapshot_by_id( }, ) .optional() - .map_err(storage_error("load structural graph snapshot by id"))?; - hydrate_snapshot(connection, metadata) + .map_err(storage_error("load structural graph snapshot by id")) } type SnapshotMetadata = ( @@ -461,6 +603,94 @@ fn hydrate_snapshot( })) } +fn hydrate_search_snapshot( + connection: &Connection, + metadata: Option, +) -> Result, StructuralGraphError> { + let Some(( + id, + stored_repo_path, + repo_head, + schema_version, + engine_json, + cursor, + ignore_fingerprint, + coverage_json, + truncated, + created_at, + )) = metadata + else { + return Ok(None); + }; + if schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION { + return Err(StructuralGraphError::UnsupportedSchema(schema_version)); + } + let mut sources = load_node_source_map(connection, &id)?; + Ok(Some(StructuralGraphSnapshot { + schema_version, + nodes: load_nodes(connection, &id, &mut sources)?, + edges: Vec::new(), + metrics: Vec::new(), + clone_groups: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + diagnostics: Vec::new(), + id, + repo_path: stored_repo_path, + repo_head, + created_at, + engine: from_json(&engine_json, "engine")?, + cursor, + ignore_fingerprint, + coverage: from_json(&coverage_json, "coverage")?, + truncated: truncated != 0, + })) +} + +fn hydrate_interactive_snapshot( + connection: &Connection, + metadata: Option, +) -> Result, StructuralGraphError> { + let Some(( + id, + stored_repo_path, + repo_head, + schema_version, + engine_json, + cursor, + ignore_fingerprint, + coverage_json, + truncated, + created_at, + )) = metadata + else { + return Ok(None); + }; + if schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION { + return Err(StructuralGraphError::UnsupportedSchema(schema_version)); + } + let mut sources = load_node_edge_source_map(connection, &id)?; + Ok(Some(StructuralGraphSnapshot { + schema_version, + nodes: load_nodes(connection, &id, &mut sources)?, + edges: load_edges(connection, &id, &mut sources)?, + metrics: Vec::new(), + clone_groups: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + diagnostics: Vec::new(), + id, + repo_path: stored_repo_path, + repo_head, + created_at, + engine: from_json(&engine_json, "engine")?, + cursor, + ignore_fingerprint, + coverage: from_json(&coverage_json, "coverage")?, + truncated: truncated != 0, + })) +} + pub fn load_latest_snapshot_summary( connection: &Connection, repo_path: &str, @@ -713,6 +943,142 @@ fn load_source_map( Ok(sources) } +fn load_sources_for_targets( + connection: &Connection, + snapshot_id: &str, + target_kind: &str, + target_ids: &[String], +) -> Result>, StructuralGraphError> { + if target_ids.is_empty() { + return Ok(HashMap::new()); + } + let placeholders = (0..target_ids.len()) + .map(|index| format!("?{}", index + 3)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT target_id, path, start_line, start_column, end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 AND target_kind = ?2 AND target_id IN ({placeholders}) + ORDER BY target_id, ordinal" + ); + let mut values = Vec::with_capacity(target_ids.len() + 2); + values.push(rusqlite::types::Value::Text(snapshot_id.to_string())); + values.push(rusqlite::types::Value::Text(target_kind.to_string())); + values.extend(target_ids.iter().cloned().map(rusqlite::types::Value::Text)); + let mut statement = connection + .prepare(&sql) + .map_err(storage_error("prepare bounded structural graph sources"))?; + let rows = statement + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + GraphSourceAnchor { + path: row.get(1)?, + start_line: row.get(2)?, + start_column: row.get(3)?, + end_line: row.get(4)?, + end_column: row.get(5)?, + excerpt: row.get(6)?, + }, + )) + }) + .map_err(storage_error("query bounded structural graph sources"))? + .collect::, _>>() + .map_err(storage_error("read bounded structural graph sources"))?; + let mut sources = HashMap::new(); + for (target_id, source) in rows { + sources + .entry(target_id) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + +fn load_node_source_map( + connection: &Connection, + snapshot_id: &str, +) -> Result>, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT target_id, path, start_line, start_column, + end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 AND target_kind = 'node' + ORDER BY target_id, ordinal", + ) + .map_err(storage_error("prepare structural graph node sources"))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + GraphSourceAnchor { + path: row.get(1)?, + start_line: row.get(2)?, + start_column: row.get(3)?, + end_line: row.get(4)?, + end_column: row.get(5)?, + excerpt: row.get(6)?, + }, + )) + }) + .map_err(storage_error("query structural graph node sources"))? + .collect::, _>>() + .map_err(storage_error("read structural graph node sources"))?; + let mut sources = HashMap::new(); + for (target_id, source) in rows { + sources + .entry(("node".to_string(), target_id)) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + +fn load_node_edge_source_map( + connection: &Connection, + snapshot_id: &str, +) -> Result>, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT target_kind, target_id, path, start_line, start_column, + end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 AND target_kind IN ('node', 'edge') + ORDER BY target_kind, target_id, ordinal", + ) + .map_err(storage_error( + "prepare structural graph interactive sources", + ))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + GraphSourceAnchor { + path: row.get(2)?, + start_line: row.get(3)?, + start_column: row.get(4)?, + end_line: row.get(5)?, + end_column: row.get(6)?, + excerpt: row.get(7)?, + }, + )) + }) + .map_err(storage_error("query structural graph interactive sources"))? + .collect::, _>>() + .map_err(storage_error("read structural graph interactive sources"))?; + let mut sources = HashMap::new(); + for (target_kind, target_id, source) in rows { + sources + .entry((target_kind, target_id)) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + fn load_nodes( connection: &Connection, snapshot_id: &str, diff --git a/apps/desktop/src-tauri/src/commands/synthetic_qa.rs b/apps/desktop/src-tauri/src/commands/synthetic_qa.rs index 21cca02a..fe4b43c8 100644 --- a/apps/desktop/src-tauri/src/commands/synthetic_qa.rs +++ b/apps/desktop/src-tauri/src/commands/synthetic_qa.rs @@ -457,13 +457,17 @@ fn scan_playwright_specs(root: &Path) -> Vec { out } +pub fn discover_playwright_specs_headless(root: &Path) -> Vec { + scan_playwright_specs(root) +} + #[tauri::command] pub async fn discover_playwright_specs(repo_path: String) -> Result { let root = PathBuf::from(repo_path.trim()); if !root.is_dir() { return Err("repo_path must be an existing directory".into()); } - let specs = scan_playwright_specs(&root); + let specs = discover_playwright_specs_headless(&root); Ok(json!({ "specs": specs })) } diff --git a/apps/desktop/src-tauri/src/commands/tool_collectors.rs b/apps/desktop/src-tauri/src/commands/tool_collectors.rs new file mode 100644 index 00000000..fe2a96de --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/tool_collectors.rs @@ -0,0 +1,843 @@ +//! Local, bounded adapters for external verification collectors. +//! +//! Collectors remain subordinate evidence. This module owns source identity, +//! process limits, redaction, and the normalized receipt; a tool exit code does +//! not become a CodeVetter verdict. + +use std::collections::BTreeSet; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; + +use super::trex_preview::{resolve_scope_change, TrexSourceReceipt}; + +const GITLEAKS_VERSION: &str = "8.30.1"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); +const MAX_STDOUT_BYTES: usize = 256 * 1024; +const MAX_STDERR_BYTES: usize = 256 * 1024; +const MAX_REPORT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum CollectorKind { + Gitleaks, + CargoAudit, + CargoLlvmCov, +} + +impl CollectorKind { + pub fn parse(value: &str) -> Result { + match value { + "gitleaks" => Ok(Self::Gitleaks), + "cargo-audit" => Ok(Self::CargoAudit), + "cargo-llvm-cov" => Ok(Self::CargoLlvmCov), + _ => Err(format!( + "unsupported collector `{value}`; expected gitleaks, cargo-audit, or cargo-llvm-cov" + )), + } + } + + fn binary_name(self) -> &'static str { + match self { + Self::Gitleaks => "gitleaks", + Self::CargoAudit => "cargo-audit", + Self::CargoLlvmCov => "cargo-llvm-cov", + } + } + + fn expected_version(self) -> &'static str { + match self { + Self::Gitleaks => GITLEAKS_VERSION, + Self::CargoAudit => "0.22.2", + Self::CargoLlvmCov => "0.9.0", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CollectorStatus { + Clean, + Findings, + Unavailable, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CollectorToolIdentity { + pub name: String, + pub version: String, + pub source: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectorResult { + pub collector: CollectorKind, + pub status: CollectorStatus, + pub duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool: Option, + pub finding_count: usize, + pub evidence: Value, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCollectionReceipt { + pub schema_version: String, + pub ran_at: String, + pub repo_path: String, + pub source: TrexSourceReceipt, + pub collectors: Vec, + pub limitations: Vec, +} + +#[derive(Debug, Clone)] +pub struct ToolCollectionInput { + pub repo_path: PathBuf, + pub change: String, + pub collectors: Vec, +} + +#[derive(Debug, Clone, Default)] +struct ToolPaths { + gitleaks: Option, + cargo_audit: Option, + cargo_llvm_cov: Option, +} + +#[derive(Debug, Clone)] +struct ProductBinary { + path: PathBuf, + source: &'static str, +} + +impl ToolPaths { + fn product() -> Self { + Self { + gitleaks: resolve_product_binary( + "CODEVETTER_GITLEAKS_BIN", + CollectorKind::Gitleaks.binary_name(), + ), + cargo_audit: resolve_product_binary( + "CODEVETTER_CARGO_AUDIT_BIN", + CollectorKind::CargoAudit.binary_name(), + ), + cargo_llvm_cov: resolve_product_binary( + "CODEVETTER_CARGO_LLVM_COV_BIN", + CollectorKind::CargoLlvmCov.binary_name(), + ), + } + } + + fn get(&self, kind: CollectorKind) -> Option<&ProductBinary> { + match kind { + CollectorKind::Gitleaks => self.gitleaks.as_ref(), + CollectorKind::CargoAudit => self.cargo_audit.as_ref(), + CollectorKind::CargoLlvmCov => self.cargo_llvm_cov.as_ref(), + } + } +} + +pub async fn collect_tool_evidence( + input: ToolCollectionInput, +) -> Result { + collect_tool_evidence_with_paths(input, ToolPaths::product()).await +} + +async fn collect_tool_evidence_with_paths( + input: ToolCollectionInput, + paths: ToolPaths, +) -> Result { + if input.collectors.is_empty() { + return Err("At least one collector is required".into()); + } + let repo = canonical_clean_repository(&input.repo_path)?; + let repo_text = repo.to_string_lossy().into_owned(); + let source = resolve_scope_change(&repo_text, &input.change).await?; + require_checked_out_head(&repo, &source.head_sha)?; + let selected = input.collectors.into_iter().collect::>(); + let mut collectors = Vec::with_capacity(selected.len()); + for kind in selected { + let started = Instant::now(); + let result = match (kind, paths.get(kind)) { + (CollectorKind::Gitleaks, Some(binary)) => { + run_gitleaks(binary, &repo, &source, started).await + } + (_, Some(binary)) => preflight_only(kind, binary, started).await, + (_, None) => unavailable( + kind, + started, + format!( + "The pinned {} {} product binary is not bundled or explicitly provided", + kind.binary_name(), + kind.expected_version() + ), + ), + }; + collectors.push(result); + } + let limitations = collectors + .iter() + .filter(|result| { + matches!( + result.status, + CollectorStatus::Unavailable | CollectorStatus::Error + ) + }) + .flat_map(|result| result.limitations.iter().cloned()) + .collect(); + Ok(ToolCollectionReceipt { + schema_version: "codevetter.tool-collection/v1".into(), + ran_at: chrono::Utc::now().to_rfc3339(), + repo_path: repo_text, + source, + collectors, + limitations, + }) +} + +async fn run_gitleaks( + binary: &ProductBinary, + repo: &Path, + source: &TrexSourceReceipt, + started: Instant, +) -> CollectorResult { + let identity = match tool_identity(CollectorKind::Gitleaks, binary).await { + Ok(identity) => identity, + Err(error) => return error_result(CollectorKind::Gitleaks, started, error), + }; + let range = format!("{}..{}", source.base_sha, source.head_sha); + let mut command = Command::new(&binary.path); + command + .args([ + "git", + "--no-banner", + "--no-color", + "--redact=100", + "--report-format", + "json", + "--report-path", + "-", + ]) + .arg("--log-opts") + .arg(&range) + .arg(repo) + .current_dir(repo) + .env_clear() + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin") + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let execution = + execute_bounded(command, DEFAULT_TIMEOUT, MAX_REPORT_BYTES, MAX_STDERR_BYTES).await; + let result = match execution { + Err(error) => error_result(CollectorKind::Gitleaks, started, error), + Ok(output) if !matches!(output.code, 0 | 1) => error_result( + CollectorKind::Gitleaks, + started, + format!( + "gitleaks exited with {}: {}", + output.code, + safe_diagnostic(&output.stderr) + ), + ), + Ok(output) => match read_gitleaks_report(&output.stdout) + .and_then(|findings| validate_gitleaks_attribution(findings, source)) + { + Ok(findings) => { + let finding_count = findings.len(); + let status = if finding_count == 0 { + CollectorStatus::Clean + } else { + CollectorStatus::Findings + }; + CollectorResult { + collector: CollectorKind::Gitleaks, + status, + duration_ms: elapsed_ms(started), + tool: Some(identity), + finding_count, + evidence: json!({ + "range": range, + "configuration": gitleaks_configuration_identity(repo), + "redaction": "secret and match values dropped before normalization", + "findings": findings, + "process_exit_code": output.code, + }), + limitations: Vec::new(), + } + } + Err(error) => error_result(CollectorKind::Gitleaks, started, error), + }, + }; + result +} + +async fn preflight_only( + kind: CollectorKind, + binary: &ProductBinary, + started: Instant, +) -> CollectorResult { + match tool_identity(kind, binary).await { + Ok(identity) => CollectorResult { + collector: kind, + status: CollectorStatus::Unavailable, + duration_ms: elapsed_ms(started), + tool: Some(identity), + finding_count: 0, + evidence: json!({"preflight": "binary identity verified"}), + limitations: vec![format!( + "{} execution remains claim-closed until its offline data/toolchain prerequisite is packaged and qualified", + kind.binary_name() + )], + }, + Err(error) => error_result(kind, started, error), + } +} + +async fn tool_identity( + kind: CollectorKind, + binary: &ProductBinary, +) -> Result { + if !binary.path.is_file() { + return Err(format!("{} binary is missing", kind.binary_name())); + } + let argument = match kind { + CollectorKind::Gitleaks => "version", + CollectorKind::CargoAudit | CollectorKind::CargoLlvmCov => "--version", + }; + let mut command = Command::new(&binary.path); + command + .arg(argument) + .env_clear() + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin") + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = execute_bounded( + command, + Duration::from_secs(10), + MAX_STDOUT_BYTES, + MAX_STDERR_BYTES, + ) + .await?; + if output.code != 0 { + return Err(format!( + "{} version probe exited with {}", + kind.binary_name(), + output.code + )); + } + let version = String::from_utf8(output.stdout) + .map_err(|_| format!("{} returned a non-UTF-8 version", kind.binary_name()))?; + if !version + .split_ascii_whitespace() + .any(|token| token.trim_start_matches('v') == kind.expected_version()) + { + return Err(format!( + "{} version mismatch: expected {}, received {}", + kind.binary_name(), + kind.expected_version(), + safe_diagnostic(version.as_bytes()) + )); + } + Ok(CollectorToolIdentity { + name: kind.binary_name().into(), + version: kind.expected_version().into(), + source: binary.source.into(), + sha256: sha256_file(&binary.path)?, + }) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .map_err(|error| format!("open {} binary for identity: {error}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("hash {} binary: {error}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +#[derive(Debug)] +struct ProcessOutput { + code: i32, + stdout: Vec, + stderr: Vec, +} + +async fn execute_bounded( + mut command: Command, + timeout: Duration, + stdout_limit: usize, + stderr_limit: usize, +) -> Result { + let mut child = command + .spawn() + .map_err(|error| format!("launch collector: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "collector stdout was unavailable".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "collector stderr was unavailable".to_string())?; + let execution = tokio::time::timeout(timeout, async { + let (status, stdout, stderr) = tokio::join!( + child.wait(), + read_capped(stdout, stdout_limit), + read_capped(stderr, stderr_limit) + ); + (status, stdout, stderr) + }) + .await; + let (status, stdout, stderr) = match execution { + Ok(output) => output, + Err(_) => { + let _ = child.kill().await; + return Err(format!( + "collector exceeded the {} second limit", + timeout.as_secs() + )); + } + }; + let status = status.map_err(|error| format!("wait for collector: {error}"))?; + let (stdout, stdout_exceeded) = + stdout.map_err(|error| format!("read collector stdout: {error}"))?; + let (stderr, stderr_exceeded) = + stderr.map_err(|error| format!("read collector stderr: {error}"))?; + if stdout_exceeded || stderr_exceeded { + return Err("collector output exceeded the bounded evidence limit".into()); + } + Ok(ProcessOutput { + code: status.code().unwrap_or(-1), + stdout, + stderr, + }) +} + +async fn read_capped( + mut reader: R, + limit: usize, +) -> std::io::Result<(Vec, bool)> { + let mut output = Vec::with_capacity(limit.min(64 * 1024)); + let mut exceeded = false; + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + break; + } + let remaining = limit.saturating_sub(output.len()); + output.extend_from_slice(&buffer[..read.min(remaining)]); + exceeded |= read > remaining; + } + Ok((output, exceeded)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawGitleaksFinding { + #[serde(rename = "RuleID")] + rule_id: String, + #[serde(default)] + description: String, + file: String, + #[serde(default)] + start_line: u64, + #[serde(default)] + end_line: u64, + #[serde(default)] + commit: String, + #[serde(default)] + fingerprint: String, +} + +#[derive(Debug, Serialize)] +struct GitleaksFinding { + rule_id: String, + description: String, + file: String, + start_line: u64, + end_line: u64, + commit: String, + fingerprint: String, +} + +fn read_gitleaks_report(bytes: &[u8]) -> Result, String> { + if bytes.len() > MAX_REPORT_BYTES { + return Err("gitleaks report is oversized".into()); + } + let raw_findings: Vec = + serde_json::from_slice(bytes).map_err(|error| format!("parse gitleaks report: {error}"))?; + let mut findings = raw_findings + .into_iter() + .map(|finding| { + Ok(GitleaksFinding { + rule_id: safe_text(&finding.rule_id, 120), + description: safe_text(&finding.description, 240), + file: normalize_relative_path(&finding.file)?, + start_line: finding.start_line, + end_line: finding.end_line, + commit: safe_hex(&finding.commit, 40), + fingerprint: safe_text(&finding.fingerprint, 240), + }) + }) + .collect::, String>>()?; + findings.sort_by(|left, right| { + (&left.file, left.start_line, &left.rule_id).cmp(&( + &right.file, + right.start_line, + &right.rule_id, + )) + }); + Ok(findings) +} + +fn validate_gitleaks_attribution( + findings: Vec, + source: &TrexSourceReceipt, +) -> Result, String> { + let commits = source + .commits + .iter() + .map(String::as_str) + .collect::>(); + let changed_paths = source + .changed_paths + .iter() + .map(String::as_str) + .collect::>(); + if findings.iter().any(|finding| { + !commits.contains(finding.commit.as_str()) || !changed_paths.contains(finding.file.as_str()) + }) { + return Err("gitleaks returned a finding outside the resolved change".into()); + } + Ok(findings) +} + +fn gitleaks_configuration_identity(repo: &Path) -> Value { + for name in [".gitleaks.toml", "gitleaks.toml"] { + let path = repo.join(name); + if let Ok(bytes) = std::fs::read(&path) { + return json!({ + "source": name, + "sha256": format!("{:x}", Sha256::digest(bytes)), + }); + } + } + json!({"source": "gitleaks-8.30.1-embedded-default"}) +} + +fn normalize_relative_path(value: &str) -> Result { + let normalized = value.replace('\\', "/"); + let path = Path::new(&normalized); + if path.is_absolute() + || path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err("gitleaks returned a non-contained finding path".into()); + } + Ok(normalized) +} + +fn canonical_clean_repository(path: &Path) -> Result { + let canonical = path + .canonicalize() + .map_err(|error| format!("repository {} is unavailable: {error}", path.display()))?; + if !canonical.is_dir() { + return Err("Tool collection requires a Git repository root".into()); + } + let reported_root = git_text(&canonical, &["rev-parse", "--show-toplevel"])?; + let reported_root = PathBuf::from(reported_root) + .canonicalize() + .map_err(|error| format!("canonicalize Git repository root: {error}"))?; + if reported_root != canonical { + return Err("Tool collection requires the Git repository root, not a subdirectory".into()); + } + if !git_text( + &canonical, + &["status", "--porcelain=v1", "--untracked-files=normal"], + )? + .is_empty() + { + return Err( + "Tool collection requires a clean checkout so evidence maps to one immutable source" + .into(), + ); + } + Ok(canonical) +} + +fn require_checked_out_head(repo: &Path, expected: &str) -> Result<(), String> { + let head = git_text(repo, &["rev-parse", "HEAD"])?; + if head != expected { + return Err("Resolved change head is not the checked-out repository HEAD".into()); + } + Ok(()) +} + +fn git_text(repo: &Path, arguments: &[&str]) -> Result { + let output = std::process::Command::new("git") + .args(arguments) + .current_dir(repo) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("Could not run Git: {error}"))?; + if !output.status.success() { + return Err(format!( + "Git could not inspect the local checkout: {}", + safe_diagnostic(&output.stderr) + )); + } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_string()) + .map_err(|_| "Git returned non-UTF-8 evidence".into()) +} + +fn resolve_product_binary(variable: &str, name: &str) -> Option { + #[cfg(any(test, debug_assertions))] + { + if let Some(path) = std::env::var_os(variable).map(PathBuf::from) { + if path.is_file() { + return Some(ProductBinary { + path, + source: "explicit_debug_override", + }); + } + } + } + #[cfg(not(any(test, debug_assertions)))] + let _ = variable; + std::env::current_exe() + .ok() + .and_then(|executable| executable.parent().map(|parent| parent.join(name))) + .filter(|path| path.is_file()) + .map(|path| ProductBinary { + path, + source: "application_bundle_sibling", + }) +} + +fn unavailable(kind: CollectorKind, started: Instant, reason: String) -> CollectorResult { + CollectorResult { + collector: kind, + status: CollectorStatus::Unavailable, + duration_ms: elapsed_ms(started), + tool: None, + finding_count: 0, + evidence: json!({"preflight": "unavailable"}), + limitations: vec![reason], + } +} + +fn error_result(kind: CollectorKind, started: Instant, reason: String) -> CollectorResult { + CollectorResult { + collector: kind, + status: CollectorStatus::Error, + duration_ms: elapsed_ms(started), + tool: None, + finding_count: 0, + evidence: json!({"error_category": "collector_execution"}), + limitations: vec![safe_text(&reason, 500)], + } +} + +fn safe_diagnostic(bytes: &[u8]) -> String { + safe_text(&String::from_utf8_lossy(bytes), 500) +} + +fn safe_text(value: &str, limit: usize) -> String { + value + .chars() + .filter_map(|character| match character { + '\n' | '\r' | '\t' => Some(' '), + value if value.is_control() => None, + value => Some(value), + }) + .take(limit) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn safe_hex(value: &str, limit: usize) -> String { + value + .chars() + .filter(|character| character.is_ascii_hexdigit()) + .take(limit) + .collect() +} + +fn elapsed_ms(started: Instant) -> u64 { + started.elapsed().as_millis().try_into().unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + fn executable(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("write fixture executable"); + let mut permissions = std::fs::metadata(path) + .expect("fixture metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("fixture permissions"); + } + + fn repository() -> (TempDir, String, String) { + let directory = tempfile::tempdir().expect("temp repository"); + let repo = directory.path(); + for args in [ + vec!["init"], + vec!["config", "user.email", "fixture@example.com"], + vec!["config", "user.name", "Fixture"], + ] { + assert!(std::process::Command::new("git") + .args(args) + .current_dir(repo) + .status() + .expect("git") + .success()); + } + std::fs::write(repo.join("README.md"), "base\n").expect("base file"); + assert!(std::process::Command::new("git") + .args(["add", "README.md"]) + .current_dir(repo) + .status() + .expect("git add") + .success()); + assert!(std::process::Command::new("git") + .args(["commit", "-m", "base"]) + .current_dir(repo) + .status() + .expect("git commit") + .success()); + let base = git_text(repo, &["rev-parse", "HEAD"]).expect("base sha"); + std::fs::write(repo.join("README.md"), "base\nchange\n").expect("changed file"); + assert!(std::process::Command::new("git") + .args(["add", "README.md"]) + .current_dir(repo) + .status() + .expect("git add") + .success()); + assert!(std::process::Command::new("git") + .args(["commit", "-m", "change"]) + .current_dir(repo) + .status() + .expect("git commit") + .success()); + let head = git_text(repo, &["rev-parse", "HEAD"]).expect("head sha"); + (directory, base, head) + } + + #[tokio::test] + async fn gitleaks_receipt_drops_raw_secret_fields() { + let (repo, base, head) = repository(); + let tools = tempfile::tempdir().expect("tool directory"); + let binary = tools.path().join("gitleaks"); + executable( + &binary, + r##"#!/bin/sh +if [ "$1" = "version" ]; then printf '8.30.1\n'; exit 0; fi +printf '[{"RuleID":"fixture-token","Description":"fixture","File":"README.md","StartLine":2,"EndLine":2,"Commit":"HEAD_SHA","Fingerprint":"safe-fingerprint","Secret":"must-not-survive","Match":"token=must-not-survive"}]' +exit 1 +"##, + ); + let script = std::fs::read_to_string(&binary) + .expect("read fixture executable") + .replace("HEAD_SHA", &head); + executable(&binary, &script); + let receipt = collect_tool_evidence_with_paths( + ToolCollectionInput { + repo_path: repo.path().into(), + change: format!("{base}..{head}"), + collectors: vec![CollectorKind::Gitleaks], + }, + ToolPaths { + gitleaks: Some(ProductBinary { + path: binary, + source: "test_fixture", + }), + ..ToolPaths::default() + }, + ) + .await + .expect("collector receipt"); + assert_eq!(receipt.collectors[0].status, CollectorStatus::Findings); + assert_eq!(receipt.collectors[0].finding_count, 1); + let serialized = serde_json::to_string(&receipt).expect("serialize receipt"); + assert!(!serialized.contains("must-not-survive")); + assert!(!serialized.contains("\"Secret\"")); + assert!(!serialized.contains("\"Match\"")); + } + + #[tokio::test] + async fn missing_product_tools_are_explicitly_unavailable() { + let (repo, base, head) = repository(); + let receipt = collect_tool_evidence_with_paths( + ToolCollectionInput { + repo_path: repo.path().into(), + change: format!("{base}..{head}"), + collectors: vec![CollectorKind::CargoAudit, CollectorKind::CargoLlvmCov], + }, + ToolPaths::default(), + ) + .await + .expect("collector receipt"); + assert!(receipt + .collectors + .iter() + .all(|collector| collector.status == CollectorStatus::Unavailable)); + assert_eq!(receipt.limitations.len(), 2); + } + + #[test] + fn gitleaks_findings_must_belong_to_the_resolved_change() { + let source = TrexSourceReceipt { + kind: super::super::trex_preview::TrexChangeKind::Range, + input: "base..head".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/changed.rs".into()], + }; + let finding = GitleaksFinding { + rule_id: "fixture".into(), + description: "fixture".into(), + file: "src/outside.rs".into(), + start_line: 1, + end_line: 1, + commit: "c".repeat(40), + fingerprint: "fixture".into(), + }; + assert!(validate_gitleaks_attribution(vec![finding], &source).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/trex_preview.rs b/apps/desktop/src-tauri/src/commands/trex_preview.rs index 9a8cabcf..b569f924 100644 --- a/apps/desktop/src-tauri/src/commands/trex_preview.rs +++ b/apps/desktop/src-tauri/src/commands/trex_preview.rs @@ -54,6 +54,10 @@ pub struct TrexPreviewRunInput { pub change_kind: TrexChangeKind, pub change: String, pub preview_url: String, + #[serde(default)] + pub target_route: Option, + #[serde(default)] + pub target_goal: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -87,6 +91,8 @@ pub struct TrexPreviewIdentity { pub struct TrexPreviewRoute { pub route: String, pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -174,7 +180,12 @@ pub async fn execute_trex_preview( } TrexChangeKind::Range => resolve_range(&input.repo_path, input.change.trim()).await?, }; - let (routes, mut limitations) = derive_routes(&source.changed_paths); + let (mut routes, mut limitations) = derive_routes(&source.changed_paths); + apply_selected_target( + &mut routes, + input.target_route.as_deref(), + input.target_goal.as_deref(), + )?; let preview = probe_preview_identity(&preview_url, &source.head_sha).await?; let run_id = format!("trex-preview-{}", uuid::Uuid::new_v4()); let artifact_dir = app_data_dir.join("synthetic-qa").join(&run_id); @@ -265,10 +276,12 @@ async fn run_preview_journeys( preview_url.to_string(), Some("generic-page-smoke".into()), Some("playwright_builtin".into()), - Some(format!( - "T-Rex change-preview smoke selected from {}", - selected.reason - )), + Some(selected.goal.clone().unwrap_or_else(|| { + format!( + "T-Rex change-preview smoke selected from {}", + selected.reason + ) + })), None, Some("none".into()), None, @@ -356,18 +369,16 @@ async fn run_preview_journeys( .join(route_artifact_name(&selected.route)) .join("failure.jpg"); if std::fs::create_dir_all(screenshot.parent().unwrap_or(artifact_dir)).is_ok() - { - if browser + && browser .snapshot(SnapshotOpts { screenshot_path: Some(&screenshot), max_elements: 0, }) .await .is_ok() - { - screenshot_path = Some(screenshot.to_string_lossy().into_owned()); - artifacts.push(screenshot.to_string_lossy().into_owned()); - } + { + screenshot_path = Some(screenshot.to_string_lossy().into_owned()); + artifacts.push(screenshot.to_string_lossy().into_owned()); } } let duration_ms = started.elapsed().as_millis() as u64; @@ -376,10 +387,12 @@ async fn run_preview_journeys( journeys.push(SyntheticQaRunResult { loop_id: "generic-page-smoke".into(), route: selected.route.clone(), - goal: format!( - "T-Rex change-preview smoke selected from {}", - selected.reason - ), + goal: selected.goal.clone().unwrap_or_else(|| { + format!( + "T-Rex change-preview smoke selected from {}", + selected.reason + ) + }), pass, notes: if pass { format!( @@ -745,6 +758,7 @@ fn derive_routes(changed_paths: &[String]) -> (Vec, Vec (Vec, Vec (Vec, Vec, + route: Option<&str>, + goal: Option<&str>, +) -> Result<(), String> { + let Some(route) = route.map(str::trim).filter(|route| !route.is_empty()) else { + return Ok(()); + }; + if !route.starts_with('/') || route.starts_with("//") || route.chars().count() > 240 { + return Err("target_route must be a bounded browser path beginning with /".into()); + } + let goal = goal.map(str::trim).filter(|goal| !goal.is_empty()); + if goal.is_some_and(|goal| goal.chars().count() > 500) { + return Err("target_goal must be at most 500 characters".into()); + } + routes.retain(|candidate| candidate.route != route); + let selected = TrexPreviewRoute { + route: route.to_string(), + reason: "Selected QA workflow target".into(), + goal: goal.map(ToOwned::to_owned), + }; + let index = usize::from( + routes + .first() + .is_some_and(|candidate| candidate.route == "/"), + ); + routes.insert(index, selected); + routes.truncate(MAX_ROUTES); + Ok(()) +} + enum RouteDerivation { Route(String), Dynamic, @@ -1264,6 +1310,29 @@ mod tests { assert!(limitations.iter().any(|item| item.contains("[id]"))); } + #[test] + fn selected_qa_target_is_bounded_deduplicated_and_keeps_the_user_goal() { + let (mut routes, _) = derive_routes(&["src/pages/checkout.tsx".into()]); + apply_selected_target( + &mut routes, + Some("/checkout"), + Some("Complete guest checkout"), + ) + .expect("selected target"); + assert_eq!(routes[0].route, "/"); + assert_eq!(routes[1].route, "/checkout"); + assert_eq!(routes[1].reason, "Selected QA workflow target"); + assert_eq!(routes[1].goal.as_deref(), Some("Complete guest checkout")); + assert_eq!( + routes + .iter() + .filter(|route| route.route == "/checkout") + .count(), + 1 + ); + assert!(apply_selected_target(&mut routes, Some("https://unsafe.test"), None).is_err()); + } + #[test] fn execution_plans_and_command_output_are_bounded() { let paths = (0..12) @@ -1332,6 +1401,7 @@ mod tests { let routes = vec![TrexPreviewRoute { route: "/".into(), reason: "root".into(), + goal: None, }]; let passing = vec![passing_journey("/")]; assert_eq!( @@ -1386,6 +1456,7 @@ mod tests { routes: vec![TrexPreviewRoute { route: "/settings".into(), reason: "Derived from src/pages/settings.tsx".into(), + goal: None, }], journeys: vec![passing_journey("/settings")], verdict: TrexPreviewVerdict::PassedWithLimits, diff --git a/apps/desktop/src-tauri/src/commands/trex_watcher.rs b/apps/desktop/src-tauri/src/commands/trex_watcher.rs index 2a37eba4..0e87dd19 100644 --- a/apps/desktop/src-tauri/src/commands/trex_watcher.rs +++ b/apps/desktop/src-tauri/src/commands/trex_watcher.rs @@ -24,7 +24,9 @@ use tauri::{AppHandle, Manager, State}; use tokio::process::Command; use tokio::sync::oneshot; -use crate::commands::sandbox::{run_branch_sandbox_inner, SandboxOptions, SandboxRunInput}; +use crate::commands::sandbox::{ + run_branch_sandbox_headless, run_branch_sandbox_inner, SandboxOptions, SandboxRunInput, +}; use crate::DbState; const PREF_GITHUB_TOKEN: &str = "github_token"; @@ -89,6 +91,274 @@ pub struct StartTrexWatcherInput { pub base_branch: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrexWatcherReceipt { + pub schema_version: u8, + pub operation: String, + pub watcher: Option, + pub watchers: Vec, + pub runs: Vec, + pub inspected_prs: u32, + pub skipped_unchanged: u32, + pub message: String, +} + +impl TrexWatcherReceipt { + fn empty(operation: &str, message: impl Into) -> Self { + Self { + schema_version: 1, + operation: operation.to_string(), + watcher: None, + watchers: vec![], + runs: vec![], + inspected_prs: 0, + skipped_unchanged: 0, + message: message.into(), + } + } +} + +// ─── Headless CLI/native bridge ───────────────────────────────────────────── + +pub fn enable_trex_watcher_headless( + db: &DbState, + input: StartTrexWatcherInput, +) -> Result { + let repo_path = canonical_repo_path(&input.repo_path)?; + let interval = input + .interval_secs + .unwrap_or(DEFAULT_INTERVAL_SECS) + .max(MIN_INTERVAL_SECS); + upsert_watcher_row(db, &repo_path, interval, true, input.base_branch.as_deref())?; + let watcher = read_watcher_row(db, &repo_path)? + .ok_or_else(|| "watcher row missing after upsert".to_string())?; + let mut receipt = TrexWatcherReceipt::empty( + "enable", + "Watcher configuration saved. The host app owns scheduling while it is open.", + ); + receipt.watcher = Some(watcher); + Ok(receipt) +} + +pub fn disable_trex_watcher_headless( + db: &DbState, + repo_path: &str, +) -> Result { + let repo_path = canonical_repo_path(repo_path)?; + let existing = read_watcher_row(db, &repo_path)? + .ok_or_else(|| format!("no watcher registered for {repo_path}"))?; + set_watcher_enabled(db, &repo_path, false)?; + let mut receipt = TrexWatcherReceipt::empty("disable", "Watcher scheduling disabled."); + receipt.watcher = Some(TrexWatcher { + enabled: false, + ..existing + }); + Ok(receipt) +} + +pub fn list_trex_watchers_headless(db: &DbState) -> Result { + let watchers = list_watchers(db)?; + let mut receipt = TrexWatcherReceipt::empty( + "list", + format!("{} watcher configuration(s)", watchers.len()), + ); + receipt.watchers = watchers; + Ok(receipt) +} + +pub fn list_trex_pr_runs_headless( + db: &DbState, + repo_path: Option<&str>, + limit: u32, +) -> Result { + let canonical = repo_path.map(canonical_repo_path).transpose()?; + let runs = list_pr_runs(db, canonical.as_deref(), limit.clamp(1, 100))?; + let mut receipt = TrexWatcherReceipt::empty("runs", format!("{} watcher run(s)", runs.len())); + receipt.runs = runs; + Ok(receipt) +} + +/// Run one complete watcher poll in the foreground. This is intentionally not +/// a daemon: native macOS owns its app-lifetime schedule and each CLI process +/// remains alive until every newly discovered PR run has persisted a receipt. +pub async fn poll_trex_watcher_headless( + db: &DbState, + repo_path: &str, +) -> Result { + let repo_path = canonical_repo_path(repo_path)?; + let watcher = read_watcher_row(db, &repo_path)? + .ok_or_else(|| format!("no watcher registered for {repo_path}"))?; + set_last_polled(db, &repo_path)?; + let prs = match list_open_prs(&repo_path).await { + Ok(prs) => prs, + Err(error) => { + set_last_error(db, &repo_path, &error)?; + return Err(error); + } + }; + + let inspected_prs = prs.len().min(MAX_PRS_PER_TICK) as u32; + let mut skipped_unchanged = 0; + let mut runs = Vec::new(); + for pr in prs.into_iter().take(MAX_PRS_PER_TICK) { + if !pr_head_requires_run( + latest_pr_run_sha(db, &repo_path, pr.number)?.as_deref(), + &pr.head_sha, + ) { + skipped_unchanged += 1; + continue; + } + let run = execute_pr_headless(db, &watcher, pr).await; + insert_pr_run(db, &run)?; + runs.push(run); + } + + let mut receipt = TrexWatcherReceipt::empty( + "poll", + format!( + "Inspected {inspected_prs} open PR(s); completed {} new run(s); skipped {skipped_unchanged} unchanged.", + runs.len() + ), + ); + receipt.watcher = read_watcher_row(db, &repo_path)?; + receipt.runs = runs; + receipt.inspected_prs = inspected_prs; + receipt.skipped_unchanged = skipped_unchanged; + Ok(receipt) +} + +/// Explicitly rerun one currently open PR even when its head SHA already has a +/// retained receipt. Automatic polls remain deduplicated; this separate command +/// is the recovery boundary for infrastructure-limited attempts. +pub async fn retry_trex_watcher_headless( + db: &DbState, + repo_path: &str, + pr_number: i64, +) -> Result { + if pr_number <= 0 { + return Err("watcher retry requires a positive PR number".to_string()); + } + let repo_path = canonical_repo_path(repo_path)?; + let watcher = read_watcher_row(db, &repo_path)? + .ok_or_else(|| format!("no watcher registered for {repo_path}"))?; + set_last_polled(db, &repo_path)?; + let pr = list_open_prs(&repo_path) + .await? + .into_iter() + .find(|pr| pr.number == pr_number) + .ok_or_else(|| format!("PR #{pr_number} is not currently open for {repo_path}"))?; + let run = execute_pr_headless(db, &watcher, pr).await; + insert_pr_run(db, &run)?; + + let mut receipt = TrexWatcherReceipt::empty( + "retry", + format!( + "Retried PR #{} at exact head {} and persisted one replacement attempt.", + run.pr_number, run.head_sha + ), + ); + receipt.watcher = read_watcher_row(db, &repo_path)?; + receipt.runs = vec![run]; + receipt.inspected_prs = 1; + Ok(receipt) +} + +fn pr_head_requires_run(latest_persisted_sha: Option<&str>, incoming_sha: &str) -> bool { + latest_persisted_sha != Some(incoming_sha) +} + +fn canonical_repo_path(repo_path: &str) -> Result { + let path = std::fs::canonicalize(repo_path) + .map_err(|error| format!("repository {repo_path} is unavailable: {error}"))?; + if !path.join(".git").exists() { + return Err(format!("repository {repo_path} has no .git directory")); + } + Ok(path.to_string_lossy().into_owned()) +} + +async fn execute_pr_headless(db: &DbState, watcher: &TrexWatcher, pr: OpenPr) -> TrexPrRun { + let token = resolve_github_token(db).await; + let remote = remote_owner_repo(&watcher.repo_path).await.ok(); + if let (Some(token), Some((owner, repo))) = (token.as_deref(), remote.as_ref()) { + let _ = post_status( + token, + owner, + repo, + &pr.head_sha, + "pending", + "T-Rex sandbox running…", + None, + ) + .await; + } + + let started = std::time::Instant::now(); + let result = match materialize_pr_head(&watcher.repo_path, pr.number, &pr.head_sha).await { + Ok(()) => { + run_branch_sandbox_headless( + db, + SandboxRunInput { + repo_path: watcher.repo_path.clone(), + branch: pr.head_sha.clone(), + base_branch: watcher.base_branch.clone(), + review_id: None, + options: SandboxOptions::default(), + }, + ) + .await + } + Err(error) => Err(format!( + "PR #{} head {} could not be materialized: {error}", + pr.number, pr.head_sha + )), + }; + let duration_ms = started.elapsed().as_millis() as i64; + let (verdict, confidence, summary) = match result { + Ok(result) => (result.verdict, result.confidence, result.summary), + Err(error) => ( + "BLOCK".to_string(), + 0.0, + format!("T-Rex sandbox failed to run: {error}"), + ), + }; + let (status_state, status_error) = match (token.as_deref(), remote.as_ref()) { + (Some(token), Some((owner, repo))) => { + let state = verdict_to_gh_state(&verdict); + match post_status( + token, + owner, + repo, + &pr.head_sha, + state, + &truncate_for_status(&summary), + None, + ) + .await + { + Ok(()) => (Some(state.to_string()), None), + Err(error) => (None, Some(error)), + } + } + _ => ( + None, + Some("missing github_token or remote — status not posted".to_string()), + ), + }; + TrexPrRun { + id: uuid::Uuid::new_v4().to_string(), + repo_path: watcher.repo_path.clone(), + pr_number: pr.number, + head_sha: pr.head_sha, + verdict, + confidence, + summary, + status_state, + status_error, + duration_ms, + ran_at: chrono::Utc::now().to_rfc3339(), + } +} + // ─── Tauri commands ───────────────────────────────────────────────────────── #[tauri::command] @@ -271,8 +541,6 @@ async fn tick_once( for pr in prs.into_iter().take(MAX_PRS_PER_TICK) { let pr_number = pr.number; let head_sha = pr.head_sha; - let head_ref = pr.head_ref.clone(); - // Skip if a previous tick already kicked this PR and it's still running. if let Ok(mut s) = in_flight.lock() { if s.contains(&pr_number) { @@ -298,7 +566,7 @@ async fn tick_once( let in_flight_c = in_flight.clone(); runtime_spawn(async move { - let token = read_github_token(&db_c); + let token = resolve_github_token(&db_c).await; let remote = remote_owner_repo(&repo_path_c).await.ok(); if let (Some(tok), Some((owner, repo))) = (token.as_deref(), remote.as_ref()) { let _ = post_status( @@ -314,14 +582,21 @@ async fn tick_once( } let started = std::time::Instant::now(); - let input = SandboxRunInput { - repo_path: repo_path_c.clone(), - branch: head_ref, - base_branch: base_c, - review_id: None, - options: SandboxOptions::default(), + let run = match materialize_pr_head(&repo_path_c, pr_number, &head_sha).await { + Ok(()) => { + let input = SandboxRunInput { + repo_path: repo_path_c.clone(), + branch: head_sha.clone(), + base_branch: base_c, + review_id: None, + options: SandboxOptions::default(), + }; + run_branch_sandbox_inner(app_c.clone(), &db_c, input).await + } + Err(error) => Err(format!( + "PR #{pr_number} head {head_sha} could not be materialized: {error}" + )), }; - let run = run_branch_sandbox_inner(app_c.clone(), &db_c, input).await; let duration_ms = started.elapsed().as_millis() as i64; let (verdict, confidence, summary, error) = match &run { @@ -379,7 +654,6 @@ async fn tick_once( struct OpenPr { number: i64, - head_ref: String, head_sha: String, } @@ -391,7 +665,7 @@ async fn list_open_prs(repo_path: &str) -> Result, String> { "--state", "open", "--json", - "number,headRefName,headRefOid", + "number,headRefOid", "--limit", "30", ]) @@ -411,27 +685,75 @@ async fn list_open_prs(repo_path: &str) -> Result, String> { let mut out = Vec::with_capacity(arr.len()); for item in arr { let number = item.get("number").and_then(|x| x.as_i64()).unwrap_or(0); - let head_ref = item - .get("headRefName") - .and_then(|x| x.as_str()) - .unwrap_or("") - .to_string(); let head_sha = item .get("headRefOid") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); - if number > 0 && !head_ref.is_empty() && !head_sha.is_empty() { - out.push(OpenPr { - number, - head_ref, - head_sha, - }); + if number > 0 && is_full_git_sha(&head_sha) { + out.push(OpenPr { number, head_sha }); } } Ok(out) } +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// Make GitHub's immutable PR head commit available to the local object database +/// without changing the user's branch, index, working tree, or durable refs. +async fn materialize_pr_head( + repo_path: &str, + pr_number: i64, + head_sha: &str, +) -> Result<(), String> { + if pr_number <= 0 || !is_full_git_sha(head_sha) { + return Err("GitHub returned an invalid pull-request identity".to_string()); + } + if git_commit_exists(repo_path, head_sha).await? { + return Ok(()); + } + + let pull_ref = format!("+refs/pull/{pr_number}/head"); + let output = Command::new("git") + .args([ + "fetch", + "--no-tags", + "--no-write-fetch-head", + "origin", + &pull_ref, + ]) + .current_dir(repo_path) + .output() + .await + .map_err(|error| format!("git fetch {pull_ref}: {error}"))?; + if !output.status.success() { + return Err(format!( + "git fetch {pull_ref} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + if git_commit_exists(repo_path, head_sha).await? { + Ok(()) + } else { + Err(format!( + "fetched PR #{pr_number}, but GitHub's declared head {head_sha} is unavailable" + )) + } +} + +async fn git_commit_exists(repo_path: &str, head_sha: &str) -> Result { + let commit = format!("{head_sha}^{{commit}}"); + let output = Command::new("git") + .args(["cat-file", "-e", &commit]) + .current_dir(repo_path) + .output() + .await + .map_err(|error| format!("git cat-file {head_sha}: {error}"))?; + Ok(output.status.success()) +} + async fn remote_owner_repo(repo_path: &str) -> Result<(String, String), String> { let output = Command::new("git") .args(["remote", "get-url", "origin"]) @@ -583,6 +905,16 @@ fn set_last_polled(db: &DbState, repo_path: &str) -> Result<(), String> { Ok(()) } +fn set_last_error(db: &DbState, repo_path: &str, error: &str) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE trex_watchers SET last_error = ?1 WHERE repo_path = ?2", + params![error, repo_path], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + fn read_watcher_row(db: &DbState, repo_path: &str) -> Result, String> { let conn = db.0.lock().map_err(|e| e.to_string())?; let result = conn.query_row( @@ -719,6 +1051,33 @@ fn list_pr_runs( Ok(rows) } +async fn resolve_github_token(db: &DbState) -> Option { + let saved = read_github_token(db); + let gh_env = std::env::var("GH_TOKEN").ok(); + let github_env = std::env::var("GITHUB_TOKEN").ok(); + if let Some(token) = first_non_empty_token([saved, gh_env, github_env]) { + return Some(token); + } + + let output = Command::new("gh") + .args(["auth", "token"]) + .output() + .await + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|token| !token.is_empty()) +} + +fn first_non_empty_token(candidates: impl IntoIterator>) -> Option { + candidates + .into_iter() + .flatten() + .find(|candidate| !candidate.trim().is_empty()) +} + fn read_github_token(db: &DbState) -> Option { let conn = db.0.lock().ok()?; read_pref(&conn, PREF_GITHUB_TOKEN) @@ -738,6 +1097,38 @@ fn read_pref(conn: &Connection, key: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + fn test_db() -> DbState { + let connection = Connection::open_in_memory().expect("open test database"); + connection + .execute_batch( + "CREATE TABLE trex_watchers ( + repo_path TEXT PRIMARY KEY, + interval_secs INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + base_branch TEXT, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE trex_pr_runs ( + id TEXT PRIMARY KEY, + repo_path TEXT NOT NULL, + pr_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + verdict TEXT NOT NULL, + confidence REAL NOT NULL, + summary TEXT NOT NULL, + status_state TEXT, + status_error TEXT, + duration_ms INTEGER NOT NULL DEFAULT 0, + ran_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) + .expect("create watcher schema"); + DbState(Arc::new(Mutex::new(connection))) + } #[test] fn owner_repo_from_https() { @@ -785,4 +1176,185 @@ mod tests { assert_eq!(out.chars().count(), 138); // 137 + '…' assert!(out.ends_with('…')); } + + #[test] + fn headless_configuration_is_persisted_and_disable_is_explicit() { + let repository = tempfile::tempdir().expect("temporary repository"); + std::fs::create_dir(repository.path().join(".git")).expect("fake git directory"); + let db = test_db(); + + let enabled = enable_trex_watcher_headless( + &db, + StartTrexWatcherInput { + repo_path: repository.path().to_string_lossy().into_owned(), + interval_secs: Some(10), + base_branch: Some("main".to_string()), + }, + ) + .expect("enable watcher"); + let watcher = enabled.watcher.expect("watcher receipt"); + assert!(watcher.enabled); + assert_eq!(watcher.interval_secs, MIN_INTERVAL_SECS); + assert_eq!(watcher.base_branch.as_deref(), Some("main")); + + let listed = list_trex_watchers_headless(&db).expect("list watchers"); + assert_eq!(listed.watchers.len(), 1); + assert_eq!(listed.schema_version, 1); + + let disabled = + disable_trex_watcher_headless(&db, &watcher.repo_path).expect("disable watcher"); + assert_eq!(disabled.operation, "disable"); + assert!(!disabled.watcher.expect("disabled watcher").enabled); + } + + #[test] + fn watcher_runs_new_and_updated_pr_heads_but_skips_unchanged_heads() { + assert!(pr_head_requires_run(None, "new-head")); + assert!(pr_head_requires_run(Some("previous-head"), "updated-head")); + assert!(!pr_head_requires_run(Some("same-head"), "same-head")); + } + + #[test] + fn watcher_accepts_only_exact_git_commit_identities() { + assert!(is_full_git_sha("0123456789abcdef0123456789abcdef01234567")); + assert!(is_full_git_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01")); + assert!(!is_full_git_sha("main")); + assert!(!is_full_git_sha("0123456789abcdef0123456789abcdef0123456")); + assert!(!is_full_git_sha( + "../../0123456789abcdef0123456789abcdef0123" + )); + } + + #[test] + fn watcher_token_resolution_ignores_empty_candidates() { + assert_eq!( + first_non_empty_token([ + None, + Some(" ".to_string()), + Some("gho_fixture".to_string()), + ]), + Some("gho_fixture".to_string()) + ); + assert_eq!(first_non_empty_token([None, Some(String::new())]), None); + } + + #[tokio::test] + async fn watcher_materializes_an_exact_pr_head_without_changing_the_checkout() { + let remote = tempfile::tempdir().expect("bare remote"); + run_git(remote.path(), &["init", "--bare"]); + + let seed = tempfile::tempdir().expect("seed repository"); + run_git(seed.path(), &["init"]); + run_git(seed.path(), &["config", "user.name", "CodeVetter Test"]); + run_git( + seed.path(), + &["config", "user.email", "codevetter@example.test"], + ); + std::fs::write(seed.path().join("fixture.txt"), "main\n").expect("main fixture"); + run_git(seed.path(), &["add", "fixture.txt"]); + run_git(seed.path(), &["commit", "-m", "main"]); + run_git(seed.path(), &["branch", "-M", "main"]); + run_git( + seed.path(), + &["remote", "add", "origin", &remote.path().to_string_lossy()], + ); + run_git(seed.path(), &["push", "origin", "main"]); + run_git(remote.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]); + + std::fs::write(seed.path().join("fixture.txt"), "pull request\n").expect("PR fixture"); + run_git(seed.path(), &["commit", "-am", "pull request"]); + let head_sha = git_stdout(seed.path(), &["rev-parse", "HEAD"]); + run_git(seed.path(), &["push", "origin", "HEAD:refs/pull/7/head"]); + + let checkout_parent = tempfile::tempdir().expect("checkout parent"); + let checkout = checkout_parent.path().join("checkout"); + let remote_url = format!("file://{}", remote.path().display()); + run_git( + checkout_parent.path(), + &[ + "clone", + "--branch", + "main", + "--single-branch", + &remote_url, + &checkout.to_string_lossy(), + ], + ); + let before_head = git_stdout(&checkout, &["rev-parse", "HEAD"]); + assert!(!git_commit_exists(&checkout.to_string_lossy(), &head_sha) + .await + .expect("inspect missing PR head")); + + materialize_pr_head(&checkout.to_string_lossy(), 7, &head_sha) + .await + .expect("materialize exact PR head"); + + assert!(git_commit_exists(&checkout.to_string_lossy(), &head_sha) + .await + .expect("inspect fetched PR head")); + assert_eq!(git_stdout(&checkout, &["rev-parse", "HEAD"]), before_head); + assert!(git_stdout(&checkout, &["status", "--porcelain"]).is_empty()); + assert!(!checkout.join(".git").join("FETCH_HEAD").exists()); + } + + fn run_git(cwd: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git command"); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_stdout(cwd: &Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git command"); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + #[test] + fn headless_run_listing_is_bounded_and_repo_scoped() { + let repository = tempfile::tempdir().expect("temporary repository"); + std::fs::create_dir(repository.path().join(".git")).expect("fake git directory"); + let repo_path = canonical_repo_path(&repository.path().to_string_lossy()) + .expect("canonical repository"); + let db = test_db(); + for index in 0..3 { + insert_pr_run( + &db, + &TrexPrRun { + id: format!("run-{index}"), + repo_path: repo_path.clone(), + pr_number: 42, + head_sha: format!("sha-{index}"), + verdict: "APPROVE".to_string(), + confidence: 1.0, + summary: "qualified".to_string(), + status_state: Some("success".to_string()), + status_error: None, + duration_ms: 10, + ran_at: format!("2026-09-01T00:00:0{index}Z"), + }, + ) + .expect("insert run"); + } + let receipt = + list_trex_pr_runs_headless(&db, Some(&repo_path), 2).expect("list bounded runs"); + assert_eq!(receipt.runs.len(), 2); + assert!(receipt.runs.iter().all(|run| run.repo_path == repo_path)); + } } diff --git a/apps/desktop/src-tauri/src/commands/unpack.rs b/apps/desktop/src-tauri/src/commands/unpack.rs index e3c419c1..3af1691e 100644 --- a/apps/desktop/src-tauri/src/commands/unpack.rs +++ b/apps/desktop/src-tauri/src/commands/unpack.rs @@ -31,6 +31,7 @@ use crate::commands::unpack_scan::{ use crate::commands::unpack_snapshot::build_snapshot_commit_range; use crate::db::queries; use crate::DbState; +use serde::{Deserialize, Serialize}; #[allow(unused_imports)] use serde_json::{json, Value}; use std::collections::HashMap; @@ -47,6 +48,53 @@ const CLIENT_ALL_FILES_LIMIT: usize = 512; pub use crate::commands::unpack_types::*; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnpackReportSummary { + pub id: String, + pub repo_path: String, + pub repo_name: String, + pub commit_sha: Option, + pub status: String, + pub error_message: Option, + pub agent_used: Option, + pub model_used: Option, + pub files_scanned: i64, + pub files_skipped: i64, + pub runtime_ms: Option, + pub cost_usd: Option, + pub started_at: Option, + pub completed_at: Option, + pub created_at: String, + pub analysis_ready: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnpackExportReceipt { + pub schema_version: String, + pub report_id: String, + pub format: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnpackReportRecord { + #[serde(flatten)] + pub summary: UnpackReportSummary, + pub inventory_json: Option, + pub report_json: Option, + pub bytes_scanned: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnpackScanReceipt { + pub schema_version: String, + pub report_id: String, + pub status: String, + pub created_at: String, + pub inventory: RepoInventory, + pub profiles: Vec, +} + // ─── Tauri commands ───────────────────────────────────────────────────────── fn emit_unpack_progress( @@ -290,9 +338,17 @@ pub async fn list_repo_unpack_reports( limit: Option, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; - let limit = limit.unwrap_or(50); + let rows = list_repo_unpack_reports_from_connection(&conn, repo_path.as_deref(), limit)?; + Ok(json!({ "reports": rows })) +} - let rows: Vec = if let Some(path) = repo_path { +pub fn list_repo_unpack_reports_from_connection( + conn: &rusqlite::Connection, + repo_path: Option<&str>, + limit: Option, +) -> Result, String> { + let limit = limit.unwrap_or(50).clamp(1, 100); + let rows = if let Some(path) = repo_path { let mut stmt = conn .prepare( "SELECT id, repo_path, repo_name, commit_sha, status, error_message, @@ -308,7 +364,8 @@ pub async fn list_repo_unpack_reports( let iter = stmt .query_map(rusqlite::params![path, limit], row_to_summary) .map_err(|e| e.to_string())?; - iter.filter_map(Result::ok).collect() + iter.collect::, _>>() + .map_err(|e| e.to_string())? } else { let mut stmt = conn .prepare( @@ -324,16 +381,23 @@ pub async fn list_repo_unpack_reports( let iter = stmt .query_map(rusqlite::params![limit], row_to_summary) .map_err(|e| e.to_string())?; - iter.filter_map(Result::ok).collect() + iter.collect::, _>>() + .map_err(|e| e.to_string())? }; - - Ok(json!({ "reports": rows })) + Ok(rows) } #[tauri::command] pub async fn get_repo_unpack_report(db: State<'_, DbState>, id: String) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; + serde_json::to_value(get_repo_unpack_report_from_connection(&conn, &id)?) + .map_err(|e| e.to_string()) +} +pub fn get_repo_unpack_report_from_connection( + conn: &rusqlite::Connection, + id: &str, +) -> Result { let mut row = conn .query_row( "SELECT id, repo_path, repo_name, commit_sha, status, error_message, @@ -343,39 +407,14 @@ pub async fn get_repo_unpack_report(db: State<'_, DbState>, id: String) -> Resul FROM repo_unpacked_reports WHERE id = ?1", rusqlite::params![id], - |r| { - Ok(json!({ - "id": r.get::<_, String>(0)?, - "repo_path": r.get::<_, String>(1)?, - "repo_name": r.get::<_, String>(2)?, - "commit_sha": r.get::<_, Option>(3)?, - "status": r.get::<_, String>(4)?, - "error_message": r.get::<_, Option>(5)?, - "agent_used": r.get::<_, Option>(6)?, - "model_used": r.get::<_, Option>(7)?, - "inventory_json": r.get::<_, Option>(8)?, - "report_json": r.get::<_, Option>(9)?, - "files_scanned": r.get::<_, i64>(10)?, - "files_skipped": r.get::<_, i64>(11)?, - "bytes_scanned": r.get::<_, i64>(12)?, - "runtime_ms": r.get::<_, Option>(13)?, - "cost_usd": r.get::<_, Option>(14)?, - "started_at": r.get::<_, Option>(15)?, - "completed_at": r.get::<_, Option>(16)?, - "created_at": r.get::<_, String>(17)?, - })) - }, + row_to_record, ) .map_err(|e| format!("Report not found: {e}"))?; - if let Some(inv_json) = row - .get("inventory_json") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { + if let Some(inv_json) = row.inventory_json.as_deref().filter(|s| !s.is_empty()) { if let Ok(inv) = serde_json::from_str::(inv_json) { if let Ok(trimmed) = serde_json::to_string(&trim_inventory_for_client(inv)) { - row["inventory_json"] = json!(trimmed); + row.inventory_json = Some(trimmed); } } } @@ -402,6 +441,14 @@ pub async fn compare_unpack_snapshot_commits( .map_err(|e| format!("snapshot comparison task join error: {e}"))? } +pub fn compare_unpack_snapshot_commits_headless( + repo_path: &str, + base_commit: &str, + head_commit: &str, +) -> Result { + build_snapshot_commit_range(repo_path, base_commit, head_commit, 24) +} + #[tauri::command] pub async fn get_unpack_outcome_evidence( db: State<'_, DbState>, @@ -438,6 +485,28 @@ pub async fn export_repo_unpack_report( format: String, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; + serde_json::to_value(export_repo_unpack_report_from_connection( + &conn, &id, &format, + )?) + .map_err(|error| error.to_string()) +} + +pub fn export_repo_unpack_report_from_connection( + conn: &rusqlite::Connection, + id: &str, + format: &str, +) -> Result { + let id = id.trim(); + if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) { + return Err("report_id must contain 1-128 non-control characters".to_string()); + } + let format = format.trim(); + if !matches!( + format, + "markdown" | "html" | "repo_graph_json" | "agent_context_markdown" | "repo_memory_markdown" + ) { + return Err(format!("Unsupported Repo Unpack export format '{format}'")); + } let (repo_name, report_json, inventory_json, created_at, agent_used, model_used) = conn .query_row( "SELECT repo_name, report_json, inventory_json, created_at, @@ -474,7 +543,7 @@ pub async fn export_repo_unpack_report( inventory.as_ref(), ); - let content = match format.as_str() { + let content = match format { "html" => render_html(&repo_name, &body), "repo_graph_json" => { let Some(inventory) = inventory.as_ref() else { @@ -506,7 +575,7 @@ pub async fn export_repo_unpack_report( history_files.extend(inventory.all_files.iter().take(100).cloned()); let history_files = history_files.into_iter().take(100).collect::>(); let temporal_history = crate::commands::history_query::build_review_history_slice( - &conn, + conn, &inventory.repo_path, &history_files, ) @@ -524,10 +593,16 @@ pub async fn export_repo_unpack_report( }; render_repo_memory_markdown(&repo_name, &created_at, inventory, Some(&report)) } - _ => body, + "markdown" => body, + _ => unreachable!("export format is validated before rendering"), }; - Ok(json!({ "content": content, "format": format })) + Ok(UnpackExportReceipt { + schema_version: "codevetter.unpack-export/v1".to_string(), + report_id: id.to_string(), + format: format.to_string(), + content, + }) } // ─── Inventory builder (deterministic) ────────────────────────────────────── @@ -859,6 +934,91 @@ pub fn build_inventory_with_progress( }) } +/// Persist one deterministic inventory build without depending on Tauri state. +/// +/// Tauri and the standalone CLI both call this boundary so native scan receipts +/// retain one identity, schema, and SQLite write path. Agent synthesis remains a +/// separate, explicitly invoked operation. +pub fn persist_unpack_scan_snapshot_from_connection( + conn: &rusqlite::Connection, + report_id: String, + build: InventoryBuildResult, +) -> Result { + let report_id = report_id.trim().to_string(); + if report_id.is_empty() || report_id.len() > 128 || report_id.chars().any(char::is_control) { + return Err("report_id must contain 1-128 non-control characters".to_string()); + } + + let InventoryBuildResult { + inventory, + profile: build_profile, + } = build; + let mut persist_profiler = + super::unpack_scan_profile::UnpackScanProfiler::new("local_scan_persist"); + let inventory_json = serde_json::to_string(&inventory).map_err(|error| error.to_string())?; + persist_profiler.step("serialize", "JSON serialize (inventory → SQLite)"); + let created_at = chrono::Utc::now().to_rfc3339(); + + crate::db::with_busy_retry( + || { + conn.execute( + "INSERT INTO repo_unpacked_reports + (id, repo_path, repo_name, commit_sha, status, inventory_json, + files_scanned, files_skipped, bytes_scanned, started_at, completed_at, created_at) + VALUES (?1, ?2, ?3, ?4, 'scan_only', ?5, ?6, ?7, ?8, ?9, ?9, ?9)", + rusqlite::params![ + &report_id, + &inventory.repo_path, + &inventory.repo_name, + &inventory.commit_sha, + &inventory_json, + inventory.files_scanned as i64, + inventory.files_skipped as i64, + inventory.bytes_scanned as i64, + &created_at, + ], + ) + }, + 15, + ) + .map_err(|error| error.to_string())?; + persist_profiler.step("db_insert", "SQLite insert"); + + conn.execute( + "UPDATE repo_projects SET last_unpack_at = ?2 WHERE repo_path = ?1", + rusqlite::params![&inventory.repo_path, &created_at], + ) + .map_err(|error| error.to_string())?; + persist_profiler.step("touch_project", "Update repo project metadata"); + + Ok(UnpackScanReceipt { + schema_version: "codevetter.unpack-scan/v1".to_string(), + report_id, + status: "scan_only".to_string(), + created_at, + inventory: trim_inventory_for_client(inventory), + profiles: vec![build_profile, persist_profiler.finish()], + }) +} + +pub fn scan_and_persist_unpack_snapshot( + conn: &rusqlite::Connection, + repo_path: &str, + report_id: Option, + progress: Option, +) -> Result { + let repo_path = repo_path.trim(); + if repo_path.is_empty() { + return Err("repo_path is required".to_string()); + } + let build = build_inventory_with_progress(repo_path, progress, InventoryBuildProfile::Full)?; + persist_unpack_scan_snapshot_from_connection( + conn, + report_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + build, + ) +} + fn read_git_metadata(root: &Path) -> (Option, Option, Option) { if let Some(metadata) = read_git_metadata_from_files(root) { return metadata; @@ -1777,25 +1937,51 @@ fn mark_unpack_failed( } } -fn row_to_summary(r: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(json!({ - "id": r.get::<_, String>(0)?, - "repo_path": r.get::<_, String>(1)?, - "repo_name": r.get::<_, String>(2)?, - "commit_sha": r.get::<_, Option>(3)?, - "status": r.get::<_, String>(4)?, - "error_message": r.get::<_, Option>(5)?, - "agent_used": r.get::<_, Option>(6)?, - "model_used": r.get::<_, Option>(7)?, - "files_scanned": r.get::<_, i64>(8)?, - "files_skipped": r.get::<_, i64>(9)?, - "runtime_ms": r.get::<_, Option>(10)?, - "cost_usd": r.get::<_, Option>(11)?, - "started_at": r.get::<_, Option>(12)?, - "completed_at": r.get::<_, Option>(13)?, - "created_at": r.get::<_, String>(14)?, - "analysis_ready": r.get::<_, bool>(15)?, - })) +fn row_to_summary(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UnpackReportSummary { + id: r.get(0)?, + repo_path: r.get(1)?, + repo_name: r.get(2)?, + commit_sha: r.get(3)?, + status: r.get(4)?, + error_message: r.get(5)?, + agent_used: r.get(6)?, + model_used: r.get(7)?, + files_scanned: r.get(8)?, + files_skipped: r.get(9)?, + runtime_ms: r.get(10)?, + cost_usd: r.get(11)?, + started_at: r.get(12)?, + completed_at: r.get(13)?, + created_at: r.get(14)?, + analysis_ready: r.get(15)?, + }) +} + +fn row_to_record(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UnpackReportRecord { + summary: UnpackReportSummary { + id: r.get(0)?, + repo_path: r.get(1)?, + repo_name: r.get(2)?, + commit_sha: r.get(3)?, + status: r.get(4)?, + error_message: r.get(5)?, + agent_used: r.get(6)?, + model_used: r.get(7)?, + files_scanned: r.get(10)?, + files_skipped: r.get(11)?, + runtime_ms: r.get(13)?, + cost_usd: r.get(14)?, + started_at: r.get(15)?, + completed_at: r.get(16)?, + created_at: r.get(17)?, + analysis_ready: r.get::<_, Option>(9)?.is_some(), + }, + inventory_json: r.get(8)?, + report_json: r.get(9)?, + bytes_scanned: r.get(12)?, + }) } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/commands/unpack_tests.rs b/apps/desktop/src-tauri/src/commands/unpack_tests.rs index 4f4b2392..f6d187eb 100644 --- a/apps/desktop/src-tauri/src/commands/unpack_tests.rs +++ b/apps/desktop/src-tauri/src/commands/unpack_tests.rs @@ -922,3 +922,141 @@ fn opportunistic_unpack_db_lock_does_not_wait() { drop(guard); assert!(lock_unpack_db(&db, true).is_ok()); } + +#[test] +fn stored_unpack_projection_preserves_identity_and_bounds_inventory_payload() { + let connection = rusqlite::Connection::open_in_memory().expect("memory db"); + connection + .execute_batch( + "CREATE TABLE repo_unpacked_reports ( + id TEXT PRIMARY KEY, repo_path TEXT NOT NULL, repo_name TEXT NOT NULL, + commit_sha TEXT, status TEXT NOT NULL, error_message TEXT, + agent_used TEXT, model_used TEXT, inventory_json TEXT, report_json TEXT, + files_scanned INTEGER NOT NULL, files_skipped INTEGER NOT NULL, + bytes_scanned INTEGER NOT NULL, runtime_ms INTEGER, cost_usd REAL, + started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL + );", + ) + .expect("schema"); + let mut inventory = minimal_inventory(); + inventory.files_scanned = 700; + inventory.all_files = (0..700) + .map(|index| format!("src/file-{index}.rs")) + .collect(); + connection + .execute( + "INSERT INTO repo_unpacked_reports VALUES + (?1, ?2, ?3, ?4, 'scan_only', NULL, NULL, NULL, ?5, NULL, + 700, 3, 42000, 12, NULL, ?6, ?6, ?6)", + rusqlite::params![ + "snapshot-1", + "/tmp/demo", + "demo", + "1234567890abcdef", + serde_json::to_string(&inventory).expect("inventory"), + "2026-08-31T00:00:00Z", + ], + ) + .expect("insert"); + + let summaries = + list_repo_unpack_reports_from_connection(&connection, Some("/tmp/demo"), Some(200)) + .expect("summaries"); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, "snapshot-1"); + assert!(!summaries[0].analysis_ready); + + let record = + get_repo_unpack_report_from_connection(&connection, "snapshot-1").expect("snapshot record"); + assert_eq!( + record.summary.commit_sha.as_deref(), + Some("1234567890abcdef") + ); + let projected: RepoInventory = serde_json::from_str( + record + .inventory_json + .as_deref() + .expect("inventory projection"), + ) + .expect("projected inventory"); + assert!(projected.all_files.is_empty()); + assert!(projected.all_files_capped); + assert_eq!(projected.files_scanned, 700); +} + +#[test] +fn shared_unpack_scan_persistence_emits_one_bounded_receipt_for_cli_and_tauri() { + let connection = rusqlite::Connection::open_in_memory().expect("memory db"); + connection + .execute_batch( + "CREATE TABLE repo_unpacked_reports ( + id TEXT PRIMARY KEY, repo_path TEXT NOT NULL, repo_name TEXT NOT NULL, + commit_sha TEXT, status TEXT NOT NULL, error_message TEXT, + agent_used TEXT, model_used TEXT, inventory_json TEXT, report_json TEXT, + files_scanned INTEGER NOT NULL, files_skipped INTEGER NOT NULL, + bytes_scanned INTEGER NOT NULL, runtime_ms INTEGER, cost_usd REAL, + started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL + ); + CREATE TABLE repo_projects ( + repo_path TEXT PRIMARY KEY, + last_unpack_at TEXT + ); + INSERT INTO repo_projects (repo_path) VALUES ('/tmp/demo');", + ) + .expect("schema"); + let mut inventory = minimal_inventory(); + inventory.files_scanned = 700; + inventory.all_files = (0..700) + .map(|index| format!("src/file-{index}.rs")) + .collect(); + let mut profiler = crate::commands::unpack_scan_profile::UnpackScanProfiler::new("full_scan"); + profiler.step("fixture", "Fixture scan"); + + let receipt = persist_unpack_scan_snapshot_from_connection( + &connection, + "scan-shared-1".to_string(), + InventoryBuildResult { + inventory, + profile: profiler.finish(), + }, + ) + .expect("persist shared scan"); + + assert_eq!(receipt.schema_version, "codevetter.unpack-scan/v1"); + assert_eq!(receipt.report_id, "scan-shared-1"); + assert_eq!(receipt.status, "scan_only"); + assert!(receipt.inventory.all_files.is_empty()); + assert!(receipt.inventory.all_files_capped); + assert_eq!(receipt.profiles.len(), 2); + assert_eq!(receipt.profiles[0].stage, "full_scan"); + assert_eq!(receipt.profiles[1].stage, "local_scan_persist"); + let export = export_repo_unpack_report_from_connection( + &connection, + "scan-shared-1", + "repo_memory_markdown", + ) + .expect("export shared scan"); + assert_eq!(export.schema_version, "codevetter.unpack-export/v1"); + assert_eq!(export.report_id, "scan-shared-1"); + assert_eq!(export.format, "repo_memory_markdown"); + assert!(export.content.contains("Repo Memory")); + assert!( + export_repo_unpack_report_from_connection(&connection, "scan-shared-1", "pdf").is_err() + ); + assert_eq!( + list_repo_unpack_reports_from_connection(&connection, Some("/tmp/demo"), Some(10)) + .expect("list persisted scan")[0] + .id, + "scan-shared-1" + ); + assert!(persist_unpack_scan_snapshot_from_connection( + &connection, + "\n".to_string(), + InventoryBuildResult { + inventory: minimal_inventory(), + profile: crate::commands::unpack_scan_profile::UnpackScanProfiler::new("full_scan") + .finish(), + }, + ) + .is_err()); +} diff --git a/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs b/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs index a26ad61f..49aa134c 100644 --- a/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs +++ b/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs @@ -736,6 +736,23 @@ pub async fn prepare_differential_verification( reference_revision: String, candidate_kind: String, candidate_revision: Option, +) -> Result { + prepare_differential_verification_headless( + repo_path, + run_id, + reference_revision, + candidate_kind, + candidate_revision, + ) + .await +} + +pub async fn prepare_differential_verification_headless( + repo_path: String, + run_id: String, + reference_revision: String, + candidate_kind: String, + candidate_revision: Option, ) -> Result { if !valid_id(&run_id) || !valid_bounded_text(&reference_revision) { return Err("Differential run identity or reference is invalid".into()); @@ -916,6 +933,12 @@ async fn run_differential_cli( #[tauri::command] pub async fn get_warm_verification_daemon_health( repo_path: String, +) -> Result, String> { + get_warm_verification_daemon_health_headless(repo_path).await +} + +pub async fn get_warm_verification_daemon_health_headless( + repo_path: String, ) -> Result, String> { let package = find_verify_package(&repo_path)?; let output = execute_verify( @@ -940,6 +963,12 @@ pub async fn get_warm_verification_daemon_health( #[tauri::command] pub async fn start_warm_verification_daemon(repo_path: String) -> Result { + start_warm_verification_daemon_headless(repo_path).await +} + +pub async fn start_warm_verification_daemon_headless( + repo_path: String, +) -> Result { let package = find_verify_package(&repo_path)?; let output = execute_verify( &package, @@ -954,6 +983,12 @@ pub async fn start_warm_verification_daemon(repo_path: String) -> Result Result { + stop_warm_verification_daemon_headless(repo_path).await +} + +pub async fn stop_warm_verification_daemon_headless( + repo_path: String, +) -> Result { let value = run_cli(&repo_path, &["daemon", "stop"], STOP_TIMEOUT).await?; let active_run_ids = response_payload(value, "shutdown_ack", "active_run_ids")?; let active_run_ids: Vec = serde_json::from_value(active_run_ids) @@ -970,6 +1005,15 @@ pub async fn run_warm_changed_verification( repo_path: String, detailed_capture: bool, run_id: String, +) -> Result { + run_warm_changed_verification_headless(db.inner(), repo_path, detailed_capture, run_id).await +} + +pub async fn run_warm_changed_verification_headless( + db: &DbState, + repo_path: String, + detailed_capture: bool, + run_id: String, ) -> Result { if !valid_id(&run_id) { return Err("Run identity is invalid".into()); @@ -1009,6 +1053,25 @@ pub async fn run_differential_verification( reference_revision: String, candidate_kind: String, candidate_revision: Option, +) -> Result { + run_differential_verification_headless( + db.inner(), + repo_path, + run_id, + reference_revision, + candidate_kind, + candidate_revision, + ) + .await +} + +pub async fn run_differential_verification_headless( + db: &DbState, + repo_path: String, + run_id: String, + reference_revision: String, + candidate_kind: String, + candidate_revision: Option, ) -> Result { if !valid_id(&run_id) || !valid_bounded_text(&reference_revision) { return Err("Differential run identity or reference is invalid".into()); @@ -1050,6 +1113,13 @@ pub async fn run_differential_verification( pub async fn cleanup_differential_verification_artifacts( repo_path: String, dry_run: bool, +) -> Result { + cleanup_differential_verification_artifacts_headless(repo_path, dry_run).await +} + +pub async fn cleanup_differential_verification_artifacts_headless( + repo_path: String, + dry_run: bool, ) -> Result { let command = if dry_run { vec!["differential", "cleanup", "--dry-run"] @@ -1071,6 +1141,13 @@ pub async fn cleanup_differential_verification_artifacts( pub async fn cancel_warm_verification_run( repo_path: String, run_id: String, +) -> Result { + cancel_warm_verification_run_headless(repo_path, run_id).await +} + +pub async fn cancel_warm_verification_run_headless( + repo_path: String, + run_id: String, ) -> Result { if !valid_id(&run_id) { return Err("Run identity is invalid".into()); @@ -1086,6 +1163,13 @@ pub async fn cancel_warm_verification_run( pub async fn cancel_differential_verification_run( repo_path: String, run_id: String, +) -> Result { + cancel_differential_verification_run_headless(repo_path, run_id).await +} + +pub async fn cancel_differential_verification_run_headless( + repo_path: String, + run_id: String, ) -> Result { if !valid_id(&run_id) { return Err("Differential run identity is invalid".into()); @@ -1114,6 +1198,13 @@ pub async fn cancel_differential_verification_run( pub async fn cleanup_warm_verification_artifacts( repo_path: String, dry_run: bool, +) -> Result { + cleanup_warm_verification_artifacts_headless(repo_path, dry_run).await +} + +pub async fn cleanup_warm_verification_artifacts_headless( + repo_path: String, + dry_run: bool, ) -> Result { let command = if dry_run { vec!["cleanup", "--dry-run"] @@ -1132,6 +1223,12 @@ pub async fn cleanup_warm_verification_artifacts( #[tauri::command] pub async fn get_current_warm_verification_identity( repo_path: String, +) -> Result { + get_current_warm_verification_identity_headless(repo_path).await +} + +pub async fn get_current_warm_verification_identity_headless( + repo_path: String, ) -> Result { let value = run_cli(&repo_path, &["current"], STOP_TIMEOUT).await?; let identity: CurrentWarmVerificationIdentity = serde_json::from_value(value) diff --git a/apps/desktop/src-tauri/src/commands/xray.rs b/apps/desktop/src-tauri/src/commands/xray.rs index f402920b..bb320778 100644 --- a/apps/desktop/src-tauri/src/commands/xray.rs +++ b/apps/desktop/src-tauri/src/commands/xray.rs @@ -425,6 +425,13 @@ fn build(conn: &rusqlite::Connection, request: XrayRequest) -> Result Result { + build(conn, request) +} + fn scan_payload(payload: &AgentPrXray) -> Vec { let serialized = serde_json::to_string(payload).unwrap_or_default(); let lower = serialized.to_ascii_lowercase(); @@ -717,12 +724,11 @@ pub async fn build_agent_pr_xray( request: XrayRequest, ) -> Result { let conn = db.0.lock().map_err(|error| error.to_string())?; - build(&conn, request) + build_agent_pr_xray_from_connection(&conn, request) } -#[tauri::command] -pub async fn save_agent_pr_xray( - db: State<'_, DbState>, +pub fn save_agent_pr_xray_to_path( + conn: &rusqlite::Connection, request: SaveXrayRequest, ) -> Result { let path = Path::new(request.path.trim()); @@ -744,10 +750,7 @@ pub async fn save_agent_pr_xray( .parent() .ok_or("X-Ray destination needs a parent directory")?; fs::canonicalize(parent).map_err(|_| "X-Ray destination directory is unavailable")?; - let result = { - let conn = db.0.lock().map_err(|error| error.to_string())?; - build(&conn, request.xray)? - }; + let result = build_agent_pr_xray_from_connection(conn, request.xray)?; if !result.eligible { return Err(format!( "X-Ray export is blocked: {}", @@ -774,6 +777,15 @@ pub async fn save_agent_pr_xray( Ok(path.to_string_lossy().into_owned()) } +#[tauri::command] +pub async fn save_agent_pr_xray( + db: State<'_, DbState>, + request: SaveXrayRequest, +) -> Result { + let conn = db.0.lock().map_err(|error| error.to_string())?; + save_agent_pr_xray_to_path(&conn, request) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/db/queries.rs b/apps/desktop/src-tauri/src/db/queries.rs index fb59c079..314d5d08 100644 --- a/apps/desktop/src-tauri/src/db/queries.rs +++ b/apps/desktop/src-tauri/src/db/queries.rs @@ -3107,6 +3107,17 @@ pub fn get_agent_usage_by_day( let since = (Local::now().date_naive() - Duration::days(days.max(1) - 1)) .format("%Y-%m-%d") .to_string(); + get_agent_usage_by_day_since(conn, &since) +} + +/// Per-day, per-agent usage at or after an explicit local-calendar date. +/// +/// The explicit boundary keeps UI/CLI projections and deterministic tests on +/// the same attribution contract without depending on the process clock. +pub fn get_agent_usage_by_day_since( + conn: &Connection, + since: &str, +) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "WITH session_total AS ( SELECT session_id, SUM(msg_count) AS total_n @@ -3161,6 +3172,31 @@ pub fn get_agent_usage_by_day( Ok(rows) } +/// Count distinct sessions with attributed activity at or after `since`. +/// `None` returns the exact all-time session count, including legacy sessions +/// that do not have per-day attribution rows. +pub fn get_agent_session_count_since( + conn: &Connection, + agent_type: &str, + since: Option<&str>, +) -> Result { + match since { + Some(since) => conn.query_row( + "SELECT COUNT(DISTINCT s.id) + FROM cc_sessions s + JOIN cc_session_days d ON d.session_id = s.id + WHERE s.agent_type = ?1 AND d.day >= ?2", + params![agent_type, since], + |row| row.get(0), + ), + None => conn.query_row( + "SELECT COUNT(*) FROM cc_sessions WHERE agent_type = ?1", + params![agent_type], + |row| row.get(0), + ), + } +} + /// One model's token usage within one session (row shape for /// `session_model_usage`). `input_tokens` includes cache read/creation tokens, /// mirroring the cc_sessions totals. @@ -3686,11 +3722,13 @@ mod tests { disposition: "accepted".into(), }; assert_eq!( - append_codex_usage_observations(&conn, "s", &[observation.clone()]).unwrap(), + append_codex_usage_observations(&conn, "s", std::slice::from_ref(&observation)) + .unwrap(), 1 ); assert_eq!( - append_codex_usage_observations(&conn, "s", &[observation.clone()]).unwrap(), + append_codex_usage_observations(&conn, "s", std::slice::from_ref(&observation)) + .unwrap(), 0 ); reconcile_codex_usage_totals(&conn, "s").expect("reconcile"); @@ -3777,7 +3815,7 @@ mod tests { commit_codex_usage_batch( &conn, &source, - &[observation.clone()], + std::slice::from_ref(&observation), Some(&checkpoint), &coverage, ) @@ -3881,8 +3919,14 @@ mod tests { observation_watermark: source.last_observed_at.clone(), }; assert_eq!( - commit_codex_usage_batch(&conn, &source, &[observation.clone()], None, &coverage) - .expect("first commit"), + commit_codex_usage_batch( + &conn, + &source, + std::slice::from_ref(&observation), + None, + &coverage, + ) + .expect("first commit"), 1 ); assert_eq!( diff --git a/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs b/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs index 940687f6..60450693 100644 --- a/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs +++ b/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs @@ -196,10 +196,30 @@ pub fn run_migration(conn: &Connection) -> Result<(), rusqlite::Error> { CREATE INDEX IF NOT EXISTS idx_local_performance_receipts_kind ON local_performance_receipts(receipt_kind, created_at DESC); + CREATE TABLE IF NOT EXISTS local_check_runs ( + run_id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL, + repo_path TEXT NOT NULL, + base_sha TEXT NOT NULL, + head_sha TEXT NOT NULL, + verdict TEXT NOT NULL, + task TEXT NOT NULL, + receipt_json TEXT NOT NULL, + ran_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_local_check_runs_repo_time + ON local_check_runs(repo_path, ran_at DESC); + INSERT OR IGNORE INTO verification_workbench_schema_migrations (version, migration_identity, applied_at) VALUES (1, 'verification-workbench-v1', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); + + INSERT OR IGNORE INTO verification_workbench_schema_migrations + (version, migration_identity, applied_at) + VALUES + (2, 'verification-workbench-local-check-runs-v2', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); "#, )?; @@ -255,6 +275,7 @@ mod tests { "managed_work_checkpoints", "intent_closure_receipts", "local_performance_receipts", + "local_check_runs", ] { assert!(table_exists(&conn, table), "missing {table}"); } @@ -268,6 +289,15 @@ mod tests { ) .expect("migration row"); assert_eq!(identity, "verification-workbench-v1"); + let run_identity: String = conn + .query_row( + "SELECT migration_identity + FROM verification_workbench_schema_migrations WHERE version = 2", + [], + |row| row.get(0), + ) + .expect("local-check migration row"); + assert_eq!(run_identity, "verification-workbench-local-check-runs-v2"); } #[test] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dcb58600..4e426685 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ //! repository interpretation. pub mod agent; +pub mod application; +pub mod capabilities; pub mod commands; pub mod db; pub mod mcp; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 10621b90..48489232 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -528,6 +528,9 @@ fn main() { commands::review::set_finding_disposition, commands::review::list_reviews, commands::review::get_standards_pack_usage, + commands::rubric_settings::get_rubric_settings, + commands::rubric_settings::set_active_rubric_pack, + commands::rubric_settings::save_rubric_pack, commands::review::run_cli_review, commands::review::cancel_cli_review, commands::xray::build_agent_pr_xray, diff --git a/apps/desktop/src-tauri/src/mcp/contracts.rs b/apps/desktop/src-tauri/src/mcp/contracts.rs index 7325a444..1e5e6bea 100644 --- a/apps/desktop/src-tauri/src/mcp/contracts.rs +++ b/apps/desktop/src-tauri/src/mcp/contracts.rs @@ -5,10 +5,15 @@ use std::sync::Arc; pub(crate) fn tool_definitions() -> Vec { let specs = [ + ( + "capability_catalog", + "Return the canonical UI, CLI, and agent capability glossary and parity matrix", + &[] as &[&str], + ), ( "graph_query", "Search the canonical structural graph or return a compact overview", - &[] as &[&str], + &[], ), ( "graph_get_node", @@ -85,6 +90,21 @@ pub(crate) fn tool_definitions() -> Vec { "Prepare a bounded source-backed review packet for one exact repository change", &["task", "change"], ), + ( + "resolve_evidence_scope", + "Resolve one flow, exact change, or bounded codebase into deterministic testing or performance candidates without executing them", + &["consumer", "scope_kind"], + ), + ( + "qa_workspace_inspect", + "Inspect secret-safe saved QA workflows, discovered Playwright specs, and optional post-fix rerun setup without executing browser or project code", + &[], + ), + ( + "verification_get_receipt", + "Read one canonical persisted local-check receipt in this authorized repository scope without executing verification", + &["run_id"], + ), ( "review_list_manifests", "List bounded deterministic review coverage manifests for this authorized repository", @@ -153,6 +173,7 @@ fn input_schema(name: &str, required: &[&str]) -> Arc { "cursor", "rule_id", "review_id", + "run_id", "task", ] { properties.insert( @@ -160,10 +181,31 @@ fn input_schema(name: &str, required: &[&str]) -> Arc { json!({"type": "string", "maxLength": 4096}), ); } + properties.insert( + "run_id".to_string(), + json!({ + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }), + ); properties.insert( "change".to_string(), json!({"type": "string", "minLength": 1, "maxLength": 512}), ); + properties.insert( + "consumer".to_string(), + json!({"type": "string", "enum": ["testing", "performance"]}), + ); + properties.insert( + "scope_kind".to_string(), + json!({"type": "string", "enum": ["flow", "change", "codebase"]}), + ); + properties.insert( + "scope_value".to_string(), + json!({"type": "string", "minLength": 1, "maxLength": 512}), + ); properties.insert( "limit".to_string(), json!({"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE}), @@ -370,6 +412,7 @@ fn output_schema() -> Arc { pub(crate) fn tool_fields(name: &str) -> Option<&'static [&'static str]> { Some(match name { + "capability_catalog" => &[], "graph_query" => &["query", "filter", "limit", "cursor"], "graph_get_node" => &["node"], "graph_get_neighbors" => &["node", "direction", "filter", "limit", "cursor"], @@ -386,6 +429,9 @@ pub(crate) fn tool_fields(name: &str) -> Option<&'static [&'static str]> { "history_compare" => &["before", "after"], "history_get_evidence" => &["ids"], "prepare_review" => &["task", "change"], + "resolve_evidence_scope" => &["consumer", "scope_kind", "scope_value"], + "qa_workspace_inspect" => &["fix_completed_at"], + "verification_get_receipt" => &["run_id"], "review_list_manifests" => &["review_id", "limit", "cursor"], "archaeology_list_rules" => &["filter", "limit", "cursor"], "archaeology_list_domains" => &["limit", "cursor"], diff --git a/apps/desktop/src-tauri/src/mcp/server/mod.rs b/apps/desktop/src-tauri/src/mcp/server/mod.rs index e298ae8f..24def6a2 100644 --- a/apps/desktop/src-tauri/src/mcp/server/mod.rs +++ b/apps/desktop/src-tauri/src/mcp/server/mod.rs @@ -6,6 +6,7 @@ use crate::{ history_read::{ contributors::HistoryContributorScope, HistoryReadService, HistorySearchKind, }, + local_check::get_local_check_receipt, mcp_access::{record_mcp_audit, require_enabled_scope}, structural_graph::{ query::{GraphDirection, GraphQueryFilter}, diff --git a/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs b/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs index 8d9ae8ad..e78a42a4 100644 --- a/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs +++ b/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs @@ -168,6 +168,31 @@ fn verification_scope( })) } +pub(super) fn resolve_agent_evidence_scope( + repo_path: &str, + consumer: &str, + kind: &str, + value: Option<&str>, +) -> Result { + let consumer = match consumer { + "testing" => EvidenceScopeConsumer::Testing, + "performance" => EvidenceScopeConsumer::Performance, + _ => return Err("Evidence-scope consumer must be testing or performance".to_string()), + }; + let kind = match kind { + "flow" => EvidenceScopeKind::Flow, + "change" => EvidenceScopeKind::Change, + "codebase" => EvidenceScopeKind::Codebase, + _ => return Err("Evidence-scope kind must be flow, change, or codebase".to_string()), + }; + tauri::async_runtime::block_on(resolve_evidence_scope(EvidenceScopeInput { + repo_path: repo_path.to_string(), + kind, + value: value.map(str::to_string), + consumer, + })) +} + fn scope_value(scope: Result) -> Value { match scope { Ok(plan) => json!({"status": "ready", "plan": plan}), diff --git a/apps/desktop/src-tauri/src/mcp/server/tests.rs b/apps/desktop/src-tauri/src/mcp/server/tests.rs index f0a50300..0c63a65e 100644 --- a/apps/desktop/src-tauri/src/mcp/server/tests.rs +++ b/apps/desktop/src-tauri/src/mcp/server/tests.rs @@ -10,6 +10,19 @@ use rmcp::{ClientHandler, ServiceExt}; use rusqlite::params; use std::{fs, process::Command}; +const SURFACE_PARITY_FIXTURE: &str = + include_str!("../../../tests/fixtures/surface-parity/evidence-scope-v1.json"); +const LOCAL_CHECK_PARITY_FIXTURE: &str = + include_str!("../../../tests/fixtures/surface-parity/local-check-v1.json"); + +fn surface_parity_fixture() -> Value { + serde_json::from_str(SURFACE_PARITY_FIXTURE).expect("surface parity fixture") +} + +fn local_check_parity_fixture() -> Value { + serde_json::from_str(LOCAL_CHECK_PARITY_FIXTURE).expect("local-check parity fixture") +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timed_out_sql_workers_release_all_query_capacity() { let semaphore = Arc::new(Semaphore::new(4)); @@ -59,6 +72,9 @@ async fn timed_out_sql_workers_release_all_query_capacity() { #[test] fn every_tool_is_explicitly_read_only_and_schema_bounded() { + let fixture = surface_parity_fixture(); + assert_eq!(fixture["authority"]["mcp"], "read_only_projection"); + assert_eq!(fixture["authority"]["mcp_may_execute"], false); let tools = tool_definitions(); assert_eq!( tools @@ -66,6 +82,7 @@ fn every_tool_is_explicitly_read_only_and_schema_bounded() { .map(|tool| tool.name.as_ref()) .collect::>(), vec![ + "capability_catalog", "graph_query", "graph_get_node", "graph_get_neighbors", @@ -82,6 +99,9 @@ fn every_tool_is_explicitly_read_only_and_schema_bounded() { "history_compare", "history_get_evidence", "prepare_review", + "resolve_evidence_scope", + "qa_workspace_inspect", + "verification_get_receipt", "review_list_manifests", "archaeology_list_rules", "archaeology_list_domains", @@ -147,13 +167,28 @@ impl ClientHandler for TestClient {} #[tokio::test] async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { let fixture = tempfile::tempdir().expect("fixture"); + let surface_fixture = surface_parity_fixture(); + let local_check_fixture = local_check_parity_fixture(); let repo = fixture.path().join("repo"); fs::create_dir(&repo).expect("repo"); git(&repo, &["init"]); git(&repo, &["config", "user.email", "fixture@codevetter.local"]); git(&repo, &["config", "user.name", "CodeVetter Fixture"]); fs::write(repo.join("main.rs"), "fn main() {}\n").expect("source"); - git(&repo, &["add", "main.rs"]); + for (relative_path, content) in surface_fixture["repository"]["files"] + .as_object() + .expect("surface parity files") + { + let path = repo.join(relative_path); + fs::create_dir_all(path.parent().expect("surface fixture parent")) + .expect("surface fixture directory"); + fs::write( + path, + content.as_str().expect("surface fixture file content"), + ) + .expect("surface fixture file"); + } + git(&repo, &["add", "."]); git(&repo, &["commit", "-m", "fixture release"]); git(&repo, &["tag", "v1.0.0"]); let head = git_output(&repo, &["rev-parse", "HEAD"]); @@ -221,6 +256,33 @@ async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { params![repo_path, repo_id, "2026-01-01T00:00:00Z"], ) .expect("scope"); + let mut canonical_local_check = local_check_fixture["canonical_receipt"].clone(); + canonical_local_check["repo_path"] = Value::String(repo_path.clone()); + connection + .execute( + "INSERT INTO local_check_runs ( + run_id, schema_version, repo_path, base_sha, head_sha, + verdict, task, receipt_json, ran_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + canonical_local_check["run_id"].as_str().expect("run id"), + canonical_local_check["schema_version"] + .as_str() + .expect("receipt schema"), + repo_path, + canonical_local_check["source"]["base_sha"] + .as_str() + .expect("base sha"), + canonical_local_check["source"]["head_sha"] + .as_str() + .expect("head sha"), + canonical_local_check["verdict"].as_str().expect("verdict"), + canonical_local_check["task"].as_str().expect("task"), + serde_json::to_string(&canonical_local_check).expect("receipt JSON"), + canonical_local_check["ran_at"].as_str().expect("run time"), + ], + ) + .expect("local-check parity receipt"); persist_snapshot( &connection, &StructuralGraphSnapshot { @@ -265,7 +327,7 @@ async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { }); let client = TestClient.serve(client_transport).await.expect("client"); let tools = client.list_tools(None).await.expect("tools"); - assert_eq!(tools.tools.len(), 24); + assert_eq!(tools.tools.len(), 28); assert!(tools.tools.iter().all(|tool| tool.output_schema.is_some())); let templates = client .list_resource_templates(None) @@ -389,6 +451,110 @@ async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { ); assert_eq!(prepared["data"]["data"]["source"]["head_sha"], head); assert!(prepared.to_string().find(&repo_path).is_none()); + let performance_scope = client + .call_tool( + CallToolRequestParams::new("resolve_evidence_scope").with_arguments( + json!({"consumer": "performance", "scope_kind": "codebase"}) + .as_object() + .expect("arguments") + .clone(), + ), + ) + .await + .expect("performance scope") + .structured_content + .expect("performance scope structured"); + assert_eq!(performance_scope["data"]["data"]["schema_version"], 1); + assert_eq!(performance_scope["data"]["data"]["consumer"], "performance"); + assert_eq!(performance_scope["data"]["data"]["kind"], "codebase"); + assert!(performance_scope.to_string().find(&repo_path).is_none()); + let request = &surface_fixture["request"]; + let expected = &surface_fixture["expected"]; + let parity_scope = client + .call_tool( + CallToolRequestParams::new("resolve_evidence_scope").with_arguments( + json!({ + "consumer": request["consumer"], + "scope_kind": request["kind"], + "scope_value": request["value"] + }) + .as_object() + .expect("surface parity arguments") + .clone(), + ), + ) + .await + .expect("surface parity MCP scope") + .structured_content + .expect("surface parity MCP structured content"); + let parity_plan = &parity_scope["data"]["data"]; + assert_eq!(parity_plan["schema_version"], expected["schema_version"]); + assert_eq!(parity_plan["status"], expected["status"]); + assert_eq!( + parity_plan["candidates"].as_array().map(Vec::len), + expected["candidate_count"] + .as_u64() + .map(|count| count as usize) + ); + assert_eq!( + parity_plan["candidates"][0]["id"], + expected["first_candidate"]["id"] + ); + assert_eq!( + parity_plan["candidates"][0]["target"], + expected["first_candidate"]["target"] + ); + assert!(parity_scope.to_string().find(&repo_path).is_none()); + let local_expected = &local_check_fixture["expected"]; + let parity_receipt = client + .call_tool( + CallToolRequestParams::new("verification_get_receipt").with_arguments( + json!({"run_id": local_expected["run_id"]}) + .as_object() + .expect("receipt parity arguments") + .clone(), + ), + ) + .await + .expect("surface parity MCP receipt") + .structured_content + .expect("surface parity MCP receipt content"); + let receipt_projection = &parity_receipt["data"]["data"]; + assert_eq!( + receipt_projection["schema_version"], + local_expected["mcp_projection_schema"] + ); + assert_eq!(receipt_projection["authority"], "read_only_projection"); + assert_eq!( + receipt_projection["receipt"]["schema_version"], + local_expected["receipt_schema"] + ); + assert_eq!( + receipt_projection["receipt"]["request_id"], + local_expected["request_id"] + ); + assert_eq!( + receipt_projection["receipt"]["verdict"], + local_expected["verdict"] + ); + assert_eq!( + receipt_projection["receipt"]["stages"]["performance"]["status"], + local_expected["performance_status"] + ); + assert_eq!( + receipt_projection["receipt"]["stages"]["review"]["evidence"]["cross_review"]["strategy"], + "claude_then_codex_independent" + ); + assert_eq!( + receipt_projection["receipt"]["stages"]["review"]["evidence"]["cross_review"]["passes"][1] + ["reviewer"], + "codex" + ); + assert!(receipt_projection["receipt"]["limitations"] + .as_array() + .is_some_and(|limitations| limitations.contains(&local_expected["limitation"]))); + assert!(receipt_projection["receipt"].get("repo_path").is_none()); + assert!(parity_receipt.to_string().find(&repo_path).is_none()); let first_page = client .call_tool( CallToolRequestParams::new("history_list_releases") @@ -696,6 +862,50 @@ fn request_validation_rejects_unknown_and_out_of_bounds_arguments() { .expect("arguments") .clone(); assert!(validate_tool_arguments("prepare_review", &arguments).is_err()); + + arguments = json!({ + "consumer": "performance", + "scope_kind": "change", + "scope_value": "main...HEAD" + }) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_ok()); + + arguments = json!({"consumer": "testing", "scope_kind": "codebase"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_ok()); + + arguments = json!({"consumer": "performance", "scope_kind": "flow"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_err()); + + arguments = json!({ + "consumer": "performance", + "scope_kind": "codebase", + "scope_value": "must-not-be-present" + }) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_err()); + + arguments = json!({"run_id": "local-check-surface-parity"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("verification_get_receipt", &arguments).is_ok()); + + arguments = json!({"run_id": "../foreign receipt"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("verification_get_receipt", &arguments).is_err()); } #[test] diff --git a/apps/desktop/src-tauri/src/mcp/server/tools.rs b/apps/desktop/src-tauri/src/mcp/server/tools.rs index 0245673c..b6722183 100644 --- a/apps/desktop/src-tauri/src/mcp/server/tools.rs +++ b/apps/desktop/src-tauri/src/mcp/server/tools.rs @@ -41,6 +41,7 @@ pub(super) fn dispatch_tool( let limit = bounded_limit(arguments.get("limit")); let filter = optional_field::(&arguments, "filter")?.unwrap_or_default(); let data = match name { + "capability_catalog" => serde_json::to_value(crate::capabilities::capability_registry()), "prepare_review" => serde_json::to_value(prepare_review_packet( connection, repo_path, @@ -49,6 +50,32 @@ pub(super) fn dispatch_tool( required_string(&arguments, "task")?, required_string(&arguments, "change")?, )?), + "resolve_evidence_scope" => serde_json::to_value(resolve_agent_evidence_scope( + repo_path, + required_string(&arguments, "consumer")?, + required_string(&arguments, "scope_kind")?, + optional_string(&arguments, "scope_value")?, + )?), + "qa_workspace_inspect" => { + serde_json::to_value(crate::commands::qa_workspace::run_qa_workspace_headless( + connection, + PathBuf::from(repo_path), + crate::commands::qa_workspace::QaWorkspaceMutation::Inspect, + optional_string(&arguments, "fix_completed_at")?, + )?) + } + "verification_get_receipt" => { + let receipt = get_local_check_receipt( + connection, + repo_path, + required_string(&arguments, "run_id")?, + )?; + Ok(json!({ + "schema_version": "codevetter.verification-receipt-projection/v1", + "authority": "read_only_projection", + "receipt": receipt, + })) + } "graph_query" => { let query = optional_string(&arguments, "query")?; let fingerprint = serde_json::to_string(&(query.map(str::to_ascii_lowercase), &filter)) diff --git a/apps/desktop/src-tauri/src/mcp/validation.rs b/apps/desktop/src-tauri/src/mcp/validation.rs index 85c01700..c43fe041 100644 --- a/apps/desktop/src-tauri/src/mcp/validation.rs +++ b/apps/desktop/src-tauri/src/mcp/validation.rs @@ -76,11 +76,18 @@ pub(crate) fn validate_tool_arguments( "to", "entity", "review_id", + "run_id", "task", "change", + "scope_value", + "fix_completed_at", ] { if let Some(value) = arguments.get(field) { - let maximum = if field == "change" { 512 } else { 4_096 }; + let maximum = match field { + "run_id" => 128, + "change" | "scope_value" => 512, + _ => 4_096, + }; let text = value .as_str() .filter(|text| text.len() <= maximum) @@ -90,6 +97,17 @@ pub(crate) fn validate_tool_arguments( } } } + if let Some(run_id) = arguments.get("run_id").and_then(Value::as_str) { + if !run_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err( + "'run_id' may contain only ASCII letters, numbers, dash, underscore, dot, or colon" + .to_string(), + ); + } + } if let Some(value) = arguments.get("cursor") { value .as_str() @@ -99,6 +117,37 @@ pub(crate) fn validate_tool_arguments( validate_integer(arguments, "limit", 1, MAX_PAGE_SIZE)?; validate_integer(arguments, "depth", 1, MAX_HOPS)?; + if name == "resolve_evidence_scope" { + let consumer = arguments + .get("consumer") + .and_then(Value::as_str) + .filter(|value| matches!(*value, "testing" | "performance")) + .ok_or_else(|| "'consumer' must be testing or performance".to_string())?; + let _ = consumer; + let kind = arguments + .get("scope_kind") + .and_then(Value::as_str) + .filter(|value| matches!(*value, "flow" | "change" | "codebase")) + .ok_or_else(|| "'scope_kind' must be flow, change, or codebase".to_string())?; + match (kind, arguments.get("scope_value")) { + ("codebase", None) => {} + ("codebase", Some(_)) => { + return Err("'scope_value' must be omitted for codebase scope".to_string()) + } + (_, Some(_)) => {} + (_, None) => { + return Err("'scope_value' is required for flow and change scope".to_string()) + } + } + } + + if name == "qa_workspace_inspect" { + if let Some(value) = arguments.get("fix_completed_at").and_then(Value::as_str) { + chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| "'fix_completed_at' must be an RFC3339 timestamp".to_string())?; + } + } + if name.starts_with("graph_") { if let Some(value) = arguments.get("filter") { validate_object_keys(value, "filter", &["node_kinds", "edge_kinds", "trust"])?; diff --git a/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json b/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json new file mode 100644 index 00000000..43aa0d42 --- /dev/null +++ b/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json @@ -0,0 +1,66 @@ +{ + "schema_version": "codevetter.surface-parity-fixture/v1", + "authority": { + "rust": "authoritative_resolver", + "cli": "supervised_projection", + "native": "supervised_projection", + "mcp": "read_only_projection", + "mcp_may_execute": false + }, + "request": { + "consumer": "performance", + "kind": "flow", + "value": "coupon total" + }, + "repository": { + "files": { + "vitest.config.ts": "export default {};\n", + "src/cart/coupon.ts": "export const couponTotal = (value: number) => value;\n", + "src/cart/coupon.test.ts": "import { couponTotal } from './coupon';\ntest('coupon total', () => couponTotal(2));\n" + } + }, + "expected": { + "schema_version": 1, + "status": "ready", + "candidate_count": 1, + "first_candidate": { + "id": "scope-336fa25dbb5e59fc", + "adapter": "vitest", + "target": "src/cart/coupon.test.ts", + "confidence_milli": 950, + "testing_supported": true, + "performance_supported": true + }, + "limitation_contains": "Human-language scope is a deterministic local search" + }, + "canonical_receipt": { + "schema_version": 1, + "plan_id": "scope:surface-parity-v1", + "repository_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dirty": false, + "kind": "flow", + "original_input": "coupon total", + "consumer": "performance", + "status": "ready", + "candidates": [ + { + "id": "scope-336fa25dbb5e59fc", + "adapter": "vitest", + "target": "src/cart/coupon.test.ts", + "name": null, + "reason": "Matched the described flow through local path/content evidence (score 35)", + "source_paths": [ + "src/cart/coupon.test.ts", + "src/cart/coupon.ts" + ], + "confidence_milli": 950, + "testing_supported": true, + "performance_supported": true + } + ], + "uncovered_paths": [], + "limitations": [ + "Human-language scope is a deterministic local search, not model interpretation." + ] + } +} diff --git a/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json b/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json new file mode 100644 index 00000000..d738885b --- /dev/null +++ b/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json @@ -0,0 +1,113 @@ +{ + "schema_version": "codevetter.local-check-surface-parity-fixture/v1", + "authority": { + "rust": "authoritative_service", + "cli": "supervised_execution", + "native": "supervised_execution", + "mcp": "read_only_projection", + "mcp_may_execute": false + }, + "request": { + "schema_version": "codevetter.verification-command/v1", + "request_id": "surface-parity-local-check", + "operation": "execute", + "repo_path": "/fixture/repo", + "change": "main...HEAD", + "task": "Preserve checkout totals" + }, + "expected": { + "receipt_schema": "codevetter.local-check/v1", + "request_id": "surface-parity-local-check", + "run_id": "local-check-surface-parity", + "verdict": "no_confidence", + "exit_code": 2, + "performance_status": "no_confidence", + "limitation": "The performance collector is unavailable in this fixture.", + "mcp_projection_schema": "codevetter.verification-receipt-projection/v1" + }, + "canonical_receipt": { + "schema_version": "codevetter.local-check/v1", + "request_id": "surface-parity-local-check", + "run_id": "local-check-surface-parity", + "ran_at": "2026-09-01T00:00:00Z", + "repo_path": "/fixture/repo", + "task": "Preserve checkout totals", + "source": { + "kind": "range", + "input": "main...HEAD", + "base_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "commits": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "changed_paths": [ + "src/cart.ts" + ] + }, + "stages": { + "review": { + "status": "completed", + "duration_ms": 18, + "target": null, + "evidence": { + "summary": "The bounded review completed without a qualified finding.", + "findings": [], + "cross_review": { + "schema_version": "codevetter.cross-review/v1", + "strategy": "claude_then_codex_independent", + "status": "completed", + "counts": { + "corroborated": 0, + "claude_only": 0, + "codex_only": 0, + "conflicting": 0 + }, + "passes": [ + { "reviewer": "claude", "status": "completed" }, + { "reviewer": "codex", "status": "completed" } + ], + "proof_boundary": "Reviewer agreement is review coverage, never executable proof." + } + }, + "limitations": [] + }, + "correctness": { + "status": "passed", + "duration_ms": 12, + "target": { + "adapter": "vitest", + "target": "src/cart.test.ts", + "name": null, + "source": "selected:fixture" + }, + "evidence": { + "verdict": "passed" + }, + "limitations": [] + }, + "performance": { + "status": "no_confidence", + "duration_ms": 0, + "target": null, + "evidence": {}, + "limitations": [ + "The performance collector is unavailable in this fixture." + ] + }, + "optimization": { + "status": "no_confidence", + "duration_ms": 0, + "target": null, + "evidence": {}, + "limitations": [ + "No paired optimization claim can be made without performance evidence." + ] + } + }, + "verdict": "no_confidence", + "limitations": [ + "The performance collector is unavailable in this fixture.", + "No paired optimization claim can be made without performance evidence." + ] + } +} diff --git a/apps/desktop/src-tauri/tests/mcp_stdio.rs b/apps/desktop/src-tauri/tests/mcp_stdio.rs index d7b3aa9a..2d938eff 100644 --- a/apps/desktop/src-tauri/tests/mcp_stdio.rs +++ b/apps/desktop/src-tauri/tests/mcp_stdio.rs @@ -26,10 +26,22 @@ fn stdio_boundary_is_json_only_scoped_and_paginated() { "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })); let tool_definitions = tools["result"]["tools"].as_array().expect("tools"); - assert_eq!(tool_definitions.len(), 24); + assert_eq!(tool_definitions.len(), 28); + assert!(tool_definitions.iter().any(|tool| { + tool["name"] == "capability_catalog" && tool["inputSchema"]["additionalProperties"] == false + })); + assert!(tool_definitions.iter().any(|tool| { + tool["name"] == "resolve_evidence_scope" + && tool["inputSchema"]["additionalProperties"] == false + })); assert!(tool_definitions.iter().any(|tool| { tool["name"] == "prepare_review" && tool["inputSchema"]["additionalProperties"] == false })); + assert!(tool_definitions.iter().any(|tool| { + tool["name"] == "verification_get_receipt" + && tool["inputSchema"]["additionalProperties"] == false + && tool["annotations"]["readOnlyHint"] == true + })); assert!(tool_definitions.iter().any(|tool| { tool["name"] == "history_list_landmarks" && tool["inputSchema"]["additionalProperties"] == false diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 57d9df5e..5bddcdb1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -11,6 +11,7 @@ import Sidebar from '@/components/sidebar'; import UpdateChecker from '@/components/update-checker'; import { trackAppLaunch } from '@/lib/analytics'; import { ProjectWorkspaceProvider } from '@/lib/project-workspace'; +import { migrateLegacyRubricConfig } from '@/lib/rubric-migration'; import { getPreference, isTauriAvailable } from '@/lib/tauri-ipc'; import { useWindowVisibilityClass } from '@/lib/use-visibility'; @@ -154,6 +155,12 @@ export default function App() { trackAppLaunch(); }, []); + useEffect(() => { + // A failed attempt leaves the sanitized WebView copy intact. Opening the + // incumbent Rubrics surface retries and exposes its existing sync warning. + void migrateLegacyRubricConfig().catch(() => undefined); + }, []); + return ( } /> diff --git a/apps/desktop/src/lib/performance-workbench.ts b/apps/desktop/src/lib/performance-workbench.ts index 795e5766..2bd86ddb 100644 --- a/apps/desktop/src/lib/performance-workbench.ts +++ b/apps/desktop/src/lib/performance-workbench.ts @@ -41,6 +41,14 @@ export interface PerformanceRunReceipt { owned_process_reaped: boolean; temporary_profiles_retained: boolean; }; + resources?: { + sampler: string | null; + sample_interval_ms: number; + samples: number; + peak_rss_bytes: number | null; + peak_processes: number | null; + limitations: string[]; + }; } export type PerformanceBridgeFixtureKind = diff --git a/apps/desktop/src/lib/review-service.test.ts b/apps/desktop/src/lib/review-service.test.ts index 19c1677d..647c3888 100644 --- a/apps/desktop/src/lib/review-service.test.ts +++ b/apps/desktop/src/lib/review-service.test.ts @@ -7,7 +7,6 @@ import { getActiveStandardsPack, getStandardsPacks, loadReviewConfig, - PROVIDER_PRESETS, type ReviewConfig, saveReviewConfig, } from './review-service'; @@ -29,10 +28,8 @@ class MemoryStorage { } const validConfig: ReviewConfig = { - gatewayBaseUrl: 'https://gateway.example/v1', - gatewayApiKey: 'sk-test', - gatewayModel: 'auto', - reviewTone: 'direct', + activeStandardsPack: 'product-safety', + customRules: ['Check authorization'], }; beforeEach(() => { @@ -45,20 +42,48 @@ describe('loadReviewConfig', () => { assert.equal(loadReviewConfig(), null); }); - it('returns null when required credentials are missing', () => { - saveReviewConfig({ ...validConfig, gatewayApiKey: '' }); - assert.equal(loadReviewConfig(), null); - }); - it('returns null on malformed JSON', () => { localStorage.setItem('codevetter_review_config', '{not json'); assert.equal(loadReviewConfig(), null); + assert.equal(localStorage.getItem('codevetter_review_config'), null); }); it('round-trips a valid config', () => { saveReviewConfig(validConfig); assert.deepEqual(loadReviewConfig(), validConfig); }); + + it('migrates legacy provider config without retaining the credential', () => { + localStorage.setItem( + 'codevetter_review_config', + JSON.stringify({ + ...validConfig, + gatewayApiKey: 'sk-legacy-secret', + gatewayBaseUrl: 'https://api.example.test/v1', + gatewayModel: 'legacy-model', + reviewTone: 'direct', + }) + ); + + assert.deepEqual(loadReviewConfig(), validConfig); + const stored = localStorage.getItem('codevetter_review_config') ?? ''; + assert.equal(stored.includes('sk-legacy-secret'), false); + assert.equal(stored.includes('gatewayApiKey'), false); + assert.equal(stored.includes('gatewayBaseUrl'), false); + assert.equal(stored.includes('gatewayModel'), false); + }); + + it('persists only allowlisted review-standard fields', () => { + saveReviewConfig({ + ...validConfig, + gatewayApiKey: 'sk-should-not-persist', + } as ReviewConfig & { gatewayApiKey: string }); + + const stored = localStorage.getItem('codevetter_review_config'); + assert.ok(stored); + assert.deepEqual(JSON.parse(stored), validConfig); + assert.equal(stored.includes('sk-should-not-persist'), false); + }); }); describe('getStandardsPacks', () => { @@ -122,14 +147,3 @@ describe('buildActiveStandardsContext', () => { assert.equal((context.match(/Custom rule:/g) ?? []).length, 1); }); }); - -describe('PROVIDER_PRESETS', () => { - it('exposes a base url and model for each known provider', () => { - for (const key of ['anthropic', 'openai', 'openrouter']) { - const preset = PROVIDER_PRESETS[key]; - assert.ok(preset, `missing preset for ${key}`); - assert.match(preset.baseUrl, /^https:\/\//); - assert.ok(preset.model.length > 0); - } - }); -}); diff --git a/apps/desktop/src/lib/review-service.ts b/apps/desktop/src/lib/review-service.ts index d5b3839a..1905812c 100644 --- a/apps/desktop/src/lib/review-service.ts +++ b/apps/desktop/src/lib/review-service.ts @@ -1,13 +1,6 @@ -/** - * Review config persistence and provider presets. - * Used by the Settings page to configure AI provider credentials. - */ +/** Review-standards persistence. Provider credentials are never stored here. */ export interface ReviewConfig { - gatewayBaseUrl: string; - gatewayApiKey: string; - gatewayModel: string; - reviewTone: string; customRules?: string[]; activeStandardsPack?: string; standardsPacks?: StandardsPack[]; @@ -55,20 +48,57 @@ export const DEFAULT_STANDARDS_PACKS: StandardsPack[] = [ }, ]; +function isStandardsPack(value: unknown): value is StandardsPack { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const candidate = value as Partial; + return ( + typeof candidate.id === 'string' && + typeof candidate.name === 'string' && + typeof candidate.focus === 'string' && + Array.isArray(candidate.checks) && + candidate.checks.every((check) => typeof check === 'string') + ); +} + +function sanitizeReviewConfig(value: unknown): ReviewConfig | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Partial; + const config: ReviewConfig = {}; + + if (Array.isArray(candidate.customRules)) { + config.customRules = candidate.customRules.filter( + (rule): rule is string => typeof rule === 'string' + ); + } + if (typeof candidate.activeStandardsPack === 'string') { + config.activeStandardsPack = candidate.activeStandardsPack; + } + if (Array.isArray(candidate.standardsPacks)) { + config.standardsPacks = candidate.standardsPacks.filter(isStandardsPack); + } + return config; +} + export function loadReviewConfig(): ReviewConfig | null { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; - const config = JSON.parse(raw) as ReviewConfig; - if (!config.gatewayApiKey || !config.gatewayBaseUrl) return null; + const config = sanitizeReviewConfig(JSON.parse(raw)); + if (!config) { + localStorage.removeItem(STORAGE_KEY); + return null; + } + const sanitized = JSON.stringify(config); + if (sanitized !== raw) localStorage.setItem(STORAGE_KEY, sanitized); return config; } catch { + localStorage.removeItem(STORAGE_KEY); return null; } } export function saveReviewConfig(config: ReviewConfig): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitizeReviewConfig(config) ?? {})); } export function getStandardsPacks(config: ReviewConfig | null): StandardsPack[] { @@ -123,18 +153,3 @@ export function getActiveStandardsPackId(): string | null { if (!config?.activeStandardsPack) return null; return getActiveStandardsPack(config).id; } - -export const PROVIDER_PRESETS: Record = { - anthropic: { - baseUrl: 'https://api.anthropic.com/v1', - model: 'claude-sonnet-4-20250514', - }, - openai: { - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4o', - }, - openrouter: { - baseUrl: 'https://openrouter.ai/api/v1', - model: 'anthropic/claude-sonnet-4-20250514', - }, -}; diff --git a/apps/desktop/src/lib/rubric-migration.test.ts b/apps/desktop/src/lib/rubric-migration.test.ts new file mode 100644 index 00000000..39c98732 --- /dev/null +++ b/apps/desktop/src/lib/rubric-migration.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ReviewConfig } from '@/lib/review-service'; +import type { RubricSettingsReceipt } from '@/lib/tauri-ipc'; +import { migrateLegacyRubricConfig } from './rubric-migration'; + +const legacyConfig: ReviewConfig = { + activeStandardsPack: 'team-safety', + customRules: ['Preserve the audit trail.'], + standardsPacks: [ + { + id: 'team-safety', + name: 'Team Safety', + focus: 'Team-specific regressions', + checks: ['Check the audit trail.'], + }, + ], +}; + +function receipt(migrated: boolean): RubricSettingsReceipt { + return { + schema_version: 'codevetter.rubric-settings/v1', + generated_at: '2026-09-02T00:00:00Z', + operation: 'read', + active_pack_id: 'team-safety', + custom_rules: legacyConfig.customRules ?? [], + packs: [], + saved_pack_id: null, + migrated_legacy_config: migrated, + }; +} + +test('browser-only startup does not inspect or migrate WebView state', async () => { + let loaded = false; + let invoked = false; + const status = await migrateLegacyRubricConfig({ + isTauriAvailable: () => false, + loadReviewConfig: () => { + loaded = true; + return legacyConfig; + }, + getRubricSettings: async () => { + invoked = true; + return receipt(true); + }, + }); + assert.equal(status, 'not_tauri'); + assert.equal(loaded, false); + assert.equal(invoked, false); +}); + +test('Tauri startup sends the exact sanitized legacy config to Rust once', async () => { + let received: ReviewConfig | null = null; + const status = await migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => legacyConfig, + getRubricSettings: async (config) => { + received = config; + return receipt(true); + }, + }); + assert.equal(status, 'migrated'); + assert.deepEqual(received, legacyConfig); +}); + +test('startup distinguishes absent legacy state from an existing canonical preference', async () => { + const noLegacy = await migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => null, + getRubricSettings: async () => assert.fail('Rust should not be called without legacy state'), + }); + assert.equal(noLegacy, 'no_legacy_config'); + + const canonical = await migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => legacyConfig, + getRubricSettings: async () => receipt(false), + }); + assert.equal(canonical, 'already_canonical'); +}); + +test('migration errors remain observable to the startup caller', async () => { + await assert.rejects( + migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => legacyConfig, + getRubricSettings: async () => { + throw new Error('canonical store unavailable'); + }, + }), + /canonical store unavailable/ + ); +}); diff --git a/apps/desktop/src/lib/rubric-migration.ts b/apps/desktop/src/lib/rubric-migration.ts new file mode 100644 index 00000000..d611fd87 --- /dev/null +++ b/apps/desktop/src/lib/rubric-migration.ts @@ -0,0 +1,30 @@ +import { loadReviewConfig, type ReviewConfig } from '@/lib/review-service'; +import { getRubricSettings, isTauriAvailable, type RubricSettingsReceipt } from '@/lib/tauri-ipc'; + +export type LegacyRubricMigrationStatus = + | 'not_tauri' + | 'no_legacy_config' + | 'migrated' + | 'already_canonical'; + +interface LegacyRubricMigrationDependencies { + isTauriAvailable: () => boolean; + loadReviewConfig: () => ReviewConfig | null; + getRubricSettings: (legacyConfig: ReviewConfig) => Promise; +} + +const defaultDependencies: LegacyRubricMigrationDependencies = { + isTauriAvailable, + loadReviewConfig, + getRubricSettings, +}; + +export async function migrateLegacyRubricConfig( + dependencies: LegacyRubricMigrationDependencies = defaultDependencies +): Promise { + if (!dependencies.isTauriAvailable()) return 'not_tauri'; + const legacyConfig = dependencies.loadReviewConfig(); + if (!legacyConfig) return 'no_legacy_config'; + const receipt = await dependencies.getRubricSettings(legacyConfig); + return receipt.migrated_legacy_config ? 'migrated' : 'already_canonical'; +} diff --git a/apps/desktop/src/lib/tauri-ipc.ts b/apps/desktop/src/lib/tauri-ipc.ts index 2fd44ba1..b35978c2 100644 --- a/apps/desktop/src/lib/tauri-ipc.ts +++ b/apps/desktop/src/lib/tauri-ipc.ts @@ -7,6 +7,7 @@ import { } from '@tauri-apps/plugin-notification'; import { buildActiveStandardsContext, getActiveStandardsPackId } from '@/lib/review-service'; +import type { ReviewConfig, StandardsPack } from '@/lib/review-service'; import type { EvidenceScopeInput, EvidenceScopePlan } from '@/lib/evidence-scope'; import type { DaemonHealth, VerifyResult } from '@/lib/warm-verification/contracts'; import type { @@ -962,6 +963,43 @@ export async function getStandardsPackUsage(): Promise return resp.usage; } +export type RubricSettingsOperation = 'read' | 'select' | 'upsert'; + +export interface RubricPackReceipt extends StandardsPack { + built_in: boolean; + active: boolean; + review_count: number; + total_findings: number; + prompt_preview: string; +} + +export interface RubricSettingsReceipt { + schema_version: 'codevetter.rubric-settings/v1'; + generated_at: string; + operation: RubricSettingsOperation; + active_pack_id: string | null; + custom_rules: string[]; + packs: RubricPackReceipt[]; + saved_pack_id: string | null; + migrated_legacy_config: boolean; +} + +export async function getRubricSettings( + legacyConfig?: ReviewConfig | null +): Promise { + return safeInvoke('get_rubric_settings', { + legacyConfig: legacyConfig ?? null, + }); +} + +export async function setActiveRubricPack(packId: string): Promise { + return safeInvoke('set_active_rubric_pack', { packId }); +} + +export async function saveRubricPack(pack: StandardsPack): Promise { + return safeInvoke('save_rubric_pack', { pack }); +} + // ─── CLI Review ────────────────────────────────────────────────────────────── export interface CliReviewFinding { @@ -1766,7 +1804,11 @@ export async function runCliReview( qaRuns?: ReviewQaRunEvidence[]; } ): Promise { - const standardsContext = buildActiveStandardsContext(); + const canonicalRubrics = await getRubricSettings().catch(() => null); + const canonicalPack = + canonicalRubrics?.packs.find((pack) => pack.id === canonicalRubrics.active_pack_id) ?? + canonicalRubrics?.packs[0]; + const standardsContext = canonicalPack?.prompt_preview ?? buildActiveStandardsContext(); const projectWithStandards = projectDescription.trim() ? `${projectDescription}\n\n${standardsContext}` : standardsContext; @@ -1778,7 +1820,7 @@ export async function runCliReview( changeDescription, agent: agent ?? null, qaRuns: options?.qaRuns ?? null, - standardsPack: getActiveStandardsPackId(), + standardsPack: canonicalRubrics?.active_pack_id ?? getActiveStandardsPackId(), }); } diff --git a/apps/desktop/src/lib/warm-verification/differential-cli.test.ts b/apps/desktop/src/lib/warm-verification/differential-cli.test.ts index 0c426ee8..3b1c0ce6 100644 --- a/apps/desktop/src/lib/warm-verification/differential-cli.test.ts +++ b/apps/desktop/src/lib/warm-verification/differential-cli.test.ts @@ -80,6 +80,13 @@ describe('differential CLI contract', () => { assert.equal(differentialExitCode('cleanup', cleanup(true)), 0); assert.equal(differentialExitCode('cleanup', cleanup(false)), 3); assert.equal(differentialExitCode('run', prepared('ready')), 3); + assert.equal( + differentialExitCode('cleanup', { + type: 'error', + error: { code: 'cleanup_failed', message: 'Cleanup failed.', retryable: false }, + }), + 3 + ); }); }); diff --git a/apps/desktop/src/lib/warm-verification/differential-cli.ts b/apps/desktop/src/lib/warm-verification/differential-cli.ts index 61edc3ee..312b7518 100644 --- a/apps/desktop/src/lib/warm-verification/differential-cli.ts +++ b/apps/desktop/src/lib/warm-verification/differential-cli.ts @@ -142,22 +142,21 @@ export function differentialExitCode( command: Command, response: DifferentialDaemonResponse ): 0 | 2 | 3 { - if (command === 'prepare' && response.type === 'differential_prepared') { - return response.summary.status === 'ready' ? 0 : 3; + switch (response.type) { + case 'differential_prepared': + return command === 'prepare' && response.summary.status === 'ready' ? 0 : 3; + case 'differential_result': + if (command !== 'run' || response.summary.status !== 'complete') return 3; + return response.summary.classification === 'regressed' ? 2 : 0; + case 'differential_status': + if (command === 'cancel') return response.summary.state === 'not_found' ? 3 : 0; + if (command !== 'status' || response.summary.state !== 'completed') return 3; + return response.summary.classification === 'regressed' ? 2 : 0; + case 'differential_cleanup': + return command === 'cleanup' && response.summary.complete ? 0 : 3; + case 'error': + return 3; } - if (command === 'run' && response.type === 'differential_result') { - if (response.summary.status !== 'complete') return 3; - return response.summary.classification === 'regressed' ? 2 : 0; - } - if ((command === 'status' || command === 'cancel') && response.type === 'differential_status') { - if (command === 'cancel') return response.summary.state === 'not_found' ? 3 : 0; - if (response.summary.state !== 'completed') return 3; - return response.summary.classification === 'regressed' ? 2 : 0; - } - if (command === 'cleanup' && response.type === 'differential_cleanup') { - return response.summary.complete ? 0 : 3; - } - return 3; } function daemonRequest(options: DifferentialCliOptions): DifferentialDaemonRequest { @@ -178,7 +177,9 @@ function print(options: DifferentialCliOptions, response: DifferentialDaemonResp process.stdout.write(`${JSON.stringify(response)}\n`); return; } - if (response.type === 'differential_prepared') { + if (response.type === 'error') { + process.stderr.write(`${response.error.code}: ${response.error.message}\n`); + } else if (response.type === 'differential_prepared') { const summary = response.summary; process.stdout.write( `${summary.status} · ${summary.scenario_count} scenario(s) · cache=${summary.source_cache_hits}/2+${Number(summary.dependency_cache_hit)}\n` diff --git a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts index 04d2dc15..c2195462 100644 --- a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts +++ b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts @@ -143,6 +143,15 @@ describe('differential daemon wire contracts', () => { error_codes: [], }, }, + { + type: 'error', + error: { + code: 'differential_unavailable', + message: 'The comparison service is unavailable.', + remediation: 'Restart the owned verifier.', + retryable: true, + }, + }, ]; responses.forEach((value) => assert.equal(validateDifferentialDaemonResponseEnvelope(response(value)).ok, true) diff --git a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts index c13159c9..6a7bc81d 100644 --- a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts +++ b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts @@ -7,6 +7,7 @@ import { VERIFY_CONTRACT_LIMITS, type ContractIssue, type ContractValidation, + type DaemonError, } from './contracts'; import { DIFFERENTIAL_CLASSIFICATIONS, @@ -122,7 +123,8 @@ export type DifferentialDaemonResponse = | { type: 'differential_prepared'; summary: DifferentialPreparedSummary } | { type: 'differential_result'; summary: DifferentialRunSummary } | { type: 'differential_status'; summary: DifferentialStatusSummary } - | { type: 'differential_cleanup'; summary: DifferentialCleanupSummary }; + | { type: 'differential_cleanup'; summary: DifferentialCleanupSummary } + | { type: 'error'; error: DaemonError }; export interface DifferentialDaemonResponseEnvelope { protocol_version: 1; request_id: string; @@ -287,6 +289,25 @@ function validateRequest(value: unknown, issues: ContractIssue[]) { function validateResponse(value: unknown, issues: ContractIssue[]) { const response = object(value, '$.response', issues); if (!response) return; + if (response.type === 'error') { + exactKeys(response, '$.response', ['type', 'error'], issues); + const error = object(response.error, '$.response.error', issues); + if (!error) return; + exactKeys( + error, + '$.response.error', + error.remediation === undefined + ? ['code', 'message', 'retryable'] + : ['code', 'message', 'remediation', 'retryable'], + issues + ); + stringField(error, 'code', '$.response.error', issues, { pattern: ID }); + stringField(error, 'message', '$.response.error', issues); + if (error.remediation !== undefined) + stringField(error, 'remediation', '$.response.error', issues); + boolean(error, 'retryable', '$.response.error', issues); + return; + } exactKeys(response, '$.response', ['type', 'summary'], issues); const rules: Record = { differential_prepared: prepared, diff --git a/apps/desktop/src/pages/Rubrics.tsx b/apps/desktop/src/pages/Rubrics.tsx index 41eaada9..02eaade5 100644 --- a/apps/desktop/src/pages/Rubrics.tsx +++ b/apps/desktop/src/pages/Rubrics.tsx @@ -23,14 +23,16 @@ import { saveReviewConfig, type StandardsPack, } from '@/lib/review-service'; -import { getStandardsPackUsage, isTauriAvailable } from '@/lib/tauri-ipc'; +import { + getRubricSettings, + isTauriAvailable, + type RubricSettingsReceipt, + saveRubricPack, + setActiveRubricPack, +} from '@/lib/tauri-ipc'; function fallbackConfig(): ReviewConfig { return { - gatewayBaseUrl: '', - gatewayApiKey: '', - gatewayModel: 'auto', - reviewTone: 'direct', activeStandardsPack: DEFAULT_STANDARDS_PACKS[0].id, standardsPacks: [], }; @@ -81,47 +83,76 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) { const [usage, setUsage] = useState>({}); const [expandedPreview, setExpandedPreview] = useState(null); const [copiedPreview, setCopiedPreview] = useState(null); + const [syncIssue, setSyncIssue] = useState(null); const packs = getStandardsPacks(config); const activePack = getActiveStandardsPack(config); const customRules = config.customRules ?? []; - // Usage is keyed by pack NAME (the value persisted on each review), not id. useEffect(() => { if (!isTauriAvailable()) return; let cancelled = false; - getStandardsPackUsage() - .then((rows) => { + getRubricSettings(loadReviewConfig()) + .then((receipt) => { if (cancelled) return; - const map: Record = {}; - for (const row of rows) { - map[row.standards_pack] = { - reviewCount: row.review_count, - totalFindings: row.total_findings, - }; - } - setUsage(map); + applyCanonicalReceipt(receipt); }) - .catch(() => { - // Non-fatal — packs simply show "no usage yet". + .catch((error) => { + if (cancelled) return; + setSyncIssue(error instanceof Error ? error.message : String(error)); }); return () => { cancelled = true; }; }, []); - function persist(next: ReviewConfig) { + function applyCanonicalReceipt(receipt: RubricSettingsReceipt) { + const standardsPacks = receipt.packs + .filter((pack) => !pack.built_in) + .map(({ id, name, focus, checks }) => ({ id, name, focus, checks })); + const persisted: ReviewConfig = { + customRules: receipt.custom_rules, + standardsPacks, + ...(receipt.active_pack_id ? { activeStandardsPack: receipt.active_pack_id } : {}), + }; + saveReviewConfig(persisted); + setConfig({ + ...persisted, + activeStandardsPack: receipt.active_pack_id ?? DEFAULT_STANDARDS_PACKS[0].id, + }); + setUsage( + Object.fromEntries( + receipt.packs.map((pack) => [ + pack.id, + { reviewCount: pack.review_count, totalFindings: pack.total_findings }, + ]) + ) + ); + setSyncIssue(null); + setSaved(true); + window.setTimeout(() => setSaved(false), 1600); + } + + function persistLocal(next: ReviewConfig) { setConfig(next); saveReviewConfig(next); setSaved(true); window.setTimeout(() => setSaved(false), 1600); } - function selectPack(packId: string) { - persist({ ...config, activeStandardsPack: packId }); + async function selectPack(packId: string) { + if (!isTauriAvailable()) { + persistLocal({ ...config, activeStandardsPack: packId }); + return; + } + try { + applyCanonicalReceipt(await setActiveRubricPack(packId)); + } catch (error) { + setSyncIssue(error instanceof Error ? error.message : String(error)); + } } - function clonePack(source: StandardsPack) { + async function clonePack(source: StandardsPack) { const cloneName = `${source.name} (copy)`; const cloneId = uniquePackId(makePackId(cloneName), packs); const clone: StandardsPack = { @@ -130,12 +161,21 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) { focus: source.focus, checks: [...source.checks], }; - persist({ - ...config, - activeStandardsPack: clone.id, - standardsPacks: [...(config.standardsPacks ?? []), clone], - }); - setExpandedPreview(clone.id); + if (!isTauriAvailable()) { + persistLocal({ + ...config, + activeStandardsPack: clone.id, + standardsPacks: [...(config.standardsPacks ?? []), clone], + }); + setExpandedPreview(clone.id); + return; + } + try { + applyCanonicalReceipt(await saveRubricPack(clone)); + setExpandedPreview(clone.id); + } catch (error) { + setSyncIssue(error instanceof Error ? error.message : String(error)); + } } async function copyPreview(pack: StandardsPack) { @@ -149,7 +189,7 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) { } } - function addCustomPack() { + async function addCustomPack() { const checks = draftChecks .split('\n') .map((line) => line.trim()) @@ -166,14 +206,26 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) { checks, }; - persist({ - ...config, - activeStandardsPack: pack.id, - standardsPacks: [...(config.standardsPacks ?? []), pack], - }); - setDraftName(''); - setDraftFocus(''); - setDraftChecks(''); + if (!isTauriAvailable()) { + persistLocal({ + ...config, + activeStandardsPack: pack.id, + standardsPacks: [...(config.standardsPacks ?? []), pack], + }); + setDraftName(''); + setDraftFocus(''); + setDraftChecks(''); + return; + } + + try { + applyCanonicalReceipt(await saveRubricPack(pack)); + setDraftName(''); + setDraftFocus(''); + setDraftChecks(''); + } catch (error) { + setSyncIssue(error instanceof Error ? error.message : String(error)); + } } return ( @@ -218,6 +270,11 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) { Saved )} + {syncIssue && ( +
+ Canonical rubric sync failed. Existing local settings were kept: {syncIssue} +
+ )}
@@ -243,7 +300,7 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index 0d0eca8d..8557710d 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -6,12 +6,6 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; -import { - loadReviewConfig, - PROVIDER_PRESETS, - type ReviewConfig, - saveReviewConfig, -} from '@/lib/review-service'; import type { GitHubAuthStatus, LinearUser, @@ -590,48 +584,6 @@ export default function Settings() { const [claudeCodePath, setClaudeCodePath] = usePref('claude_cli_path', ''); const [codexPath, setCodexPath] = usePref('codex_cli_path', ''); - // AI Provider - const [aiProvider, setAiProvider] = useState('anthropic'); - const [aiBaseUrl, setAiBaseUrl] = useState(''); - const [aiApiKey, setAiApiKey] = useState(''); - const [aiModel, setAiModel] = useState(''); - const [aiConfigSaved, setAiConfigSaved] = useState(false); - - useEffect(() => { - const existing = loadReviewConfig(); - if (existing) { - setAiBaseUrl(existing.gatewayBaseUrl); - setAiApiKey(existing.gatewayApiKey); - setAiModel(existing.gatewayModel); - // Detect provider from URL - if (existing.gatewayBaseUrl.includes('anthropic')) setAiProvider('anthropic'); - else if (existing.gatewayBaseUrl.includes('openai.com')) setAiProvider('openai'); - else if (existing.gatewayBaseUrl.includes('openrouter')) setAiProvider('openrouter'); - else setAiProvider('custom'); - } - }, []); - - function handleProviderChange(provider: string) { - setAiProvider(provider); - setAiConfigSaved(false); - if (provider !== 'custom' && PROVIDER_PRESETS[provider]) { - setAiBaseUrl(PROVIDER_PRESETS[provider].baseUrl); - setAiModel(PROVIDER_PRESETS[provider].model); - } - } - - function handleSaveAiConfig() { - const config: ReviewConfig = { - gatewayBaseUrl: aiBaseUrl, - gatewayApiKey: aiApiKey, - gatewayModel: aiModel, - reviewTone: defaultTone, - }; - saveReviewConfig(config); - setAiConfigSaved(true); - setTimeout(() => setAiConfigSaved(false), 2000); - } - // Notifications const [notifyReviewDone, toggleNotifyReviewDone] = useBoolPref('notify_review_done', true); const [notifyAgentError, toggleNotifyAgentError] = useBoolPref('notify_agent_error', true); @@ -1500,82 +1452,6 @@ export default function Settings() {

- -

- AI Provider -

-
- - - - - { - setAiApiKey(v); - setAiConfigSaved(false); - }} - /> - - {aiProvider === 'custom' && ( - <> - - { - setAiBaseUrl(v); - setAiConfigSaved(false); - }} - /> - - )} - - - - { - setAiModel(v); - setAiConfigSaved(false); - }} - /> - -
- - {!aiApiKey && ( - API key required to run reviews - )} -
-
); diff --git a/apps/desktop/src/pages/TRex.tsx b/apps/desktop/src/pages/TRex.tsx index 11991dc9..8aa2e6ff 100644 --- a/apps/desktop/src/pages/TRex.tsx +++ b/apps/desktop/src/pages/TRex.tsx @@ -408,7 +408,7 @@ export default function TRex() { { @@ -439,7 +439,10 @@ export default function TRex() { onCleanup={handleWarmCleanup} /> - + diff --git a/apps/desktop/tests/e2e/review-warm-evidence.spec.ts b/apps/desktop/tests/e2e/review-warm-evidence.spec.ts index 3bb2e647..0cc4a3c0 100644 --- a/apps/desktop/tests/e2e/review-warm-evidence.spec.ts +++ b/apps/desktop/tests/e2e/review-warm-evidence.spec.ts @@ -412,7 +412,8 @@ test('Review presents deterministic coverage and rejected candidate counts', asy await expect(decision.getByRole('link', { name: 'Runtime evidence' })).toBeVisible(); }); -test('Review shows readiness for an external review agent', async ({ page }) => { +test('Review shows readiness for an external review agent', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); await installReviewMock(page, false); await navigateTo(page, '/review'); await waitForNoSpinners(page); diff --git a/apps/desktop/tests/e2e/settings.spec.ts b/apps/desktop/tests/e2e/settings.spec.ts index a5d350f5..a9f5648a 100644 --- a/apps/desktop/tests/e2e/settings.spec.ts +++ b/apps/desktop/tests/e2e/settings.spec.ts @@ -15,67 +15,10 @@ test.describe('Settings page', () => { consoleErrors.assertNoErrors(); }); - // ─── General tab ────────────────────────────────────────────────────── - - test('General tab is selected by default and shows AI Provider section', async ({ page }) => { - // "General" should be the active category - await expect(page.locator('text=General').first()).toBeVisible(); - - // AI Provider section heading - await expect(page.getByRole('heading', { name: 'AI Provider' })).toBeVisible(); - }); - - // ─── Provider dropdown ──────────────────────────────────────────────── - - test('Can select AI provider from dropdown', async ({ page }) => { - // The provider dropdown is a