diff --git a/.github/workflows/cookiecutter-bootstrap.yml b/.github/workflows/cookiecutter-bootstrap.yml index c26f4db6c..9b76b493e 100644 --- a/.github/workflows/cookiecutter-bootstrap.yml +++ b/.github/workflows/cookiecutter-bootstrap.yml @@ -6,30 +6,44 @@ on: paths: - '.github/workflows/cookiecutter-bootstrap.yml' - 'docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md' + - 'docs/documentation-manifest.json' - 'docs/schemas/comparevi-cookiecutter-*.json' + - 'docs/schemas/template-*.json' - 'package.json' - 'tests/Test-CompareVICookiecutterBootstrap.Tests.ps1' - 'tests/New-CompareVICookiecutterScaffold.Tests.ps1' - 'tests/fixtures/cookiecutter/*.json' + - 'tools/docker/Dockerfile.tools' - 'tools/Test-CompareVICookiecutterBootstrap.ps1' - 'tools/New-CompareVICookiecutterScaffold.ps1' - 'tools/cookiecutter/**' - 'tools/policy/comparevi-cookiecutter-templates.json' + - 'tools/policy/template-*.json' + - 'tools/priority/template-*.mjs' + - 'tools/priority/__tests__/template-*.test.mjs' + - 'tools/priority/__tests__/template-*-schema.test.mjs' - 'tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs' push: branches: [main] paths: - '.github/workflows/cookiecutter-bootstrap.yml' - 'docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md' + - 'docs/documentation-manifest.json' - 'docs/schemas/comparevi-cookiecutter-*.json' + - 'docs/schemas/template-*.json' - 'package.json' - 'tests/Test-CompareVICookiecutterBootstrap.Tests.ps1' - 'tests/New-CompareVICookiecutterScaffold.Tests.ps1' - 'tests/fixtures/cookiecutter/*.json' + - 'tools/docker/Dockerfile.tools' - 'tools/Test-CompareVICookiecutterBootstrap.ps1' - 'tools/New-CompareVICookiecutterScaffold.ps1' - 'tools/cookiecutter/**' - 'tools/policy/comparevi-cookiecutter-templates.json' + - 'tools/policy/template-*.json' + - 'tools/priority/template-*.mjs' + - 'tools/priority/__tests__/template-*.test.mjs' + - 'tools/priority/__tests__/template-*-schema.test.mjs' - 'tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs' workflow_dispatch: @@ -64,11 +78,124 @@ jobs: shell: bash run: npm ci + - name: Resolve pinned template dependency policy + id: template-policy + shell: bash + run: | + set -euo pipefail + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require('fs'); + const policy = JSON.parse(fs.readFileSync('tools/policy/template-dependency.json', 'utf8')); + console.log(`repository=${policy.templateRepositorySlug}`); + console.log(`ref=${policy.templateReleaseRef}`); + console.log(`cookiecutter_version=${policy.cookiecutterVersion}`); + console.log(`container_image=${policy.container.image}`); + console.log(`execution_plane=${policy.container.executionPlane}`); + console.log(`default_context_path=${policy.rendering.defaultContextPath}`); + NODE + - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.12' + - name: Build local tools image for cookiecutter conveyor + if: ${{ matrix.runner == 'ubuntu-latest' }} + shell: bash + run: | + set -euo pipefail + docker build -f tools/docker/Dockerfile.tools -t comparevi-tools:cookiecutter . + + - name: Render pinned template dependency in the tools container + if: ${{ matrix.runner == 'ubuntu-latest' }} + shell: bash + run: | + set -euo pipefail + proof_root="tests/results/_agent/cookiecutter-bootstrap/${{ matrix.proof_id }}" + mkdir -p "$proof_root" + node tools/npm/run-script.mjs priority:template:render:container -- \ + --workspace-root "$GITHUB_WORKSPACE/$proof_root/container-workspaces" \ + --lane-id "cookiecutter-bootstrap-${{ matrix.proof_id }}" \ + --run-id "${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.proof_id }}" \ + --container-image comparevi-tools:cookiecutter \ + --output "$proof_root/template-cookiecutter-container.json" + generated_root="$(node -e "const fs=require('fs'); const receipt=JSON.parse(fs.readFileSync('$proof_root/template-cookiecutter-container.json','utf8')); const generatedRoot = receipt.result.hostProjectDir || receipt.result.projectDir; if (!generatedRoot) { process.exit(1); } process.stdout.write(generatedRoot);")" + test -n "$generated_root" + test -f "$generated_root/README.md" + test -f "$generated_root/.github/workflows/validate.yml" + grep -q 'Hosted Linux consumer lane' "$generated_root/.github/workflows/validate.yml" + grep -q 'Hosted Windows consumer lane' "$generated_root/.github/workflows/validate.yml" + cat > "$proof_root/pinned-template-dependency.json" <> "$GITHUB_OUTPUT" + const fs = require('fs'); + const policy = JSON.parse(fs.readFileSync('tools/policy/template-dependency.json', 'utf8')); + console.log(`repository=${policy.templateRepositorySlug}`); + console.log(`ref=${policy.templateReleaseRef}`); + console.log(`cookiecutter_version=${policy.cookiecutterVersion}`); + console.log(`container_image=${policy.container.image}`); + NODE + + - name: Write template agent verification report + shell: bash + run: | + set -euo pipefail + generated_root="$(node -e "const fs=require('fs'); const path=require('path'); const receipt=JSON.parse(fs.readFileSync('tests/results/_agent/cookiecutter-bootstrap/linux/template-cookiecutter-container.json','utf8')); let generatedRoot = receipt.result.hostProjectDir || receipt.result.projectDir; const projectDir = receipt.result.projectDir; const containerOutputRoot = receipt.run.containerOutputRoot; if (projectDir && containerOutputRoot && projectDir.startsWith(containerOutputRoot + '/')) { const relativeProjectDir = projectDir.slice(containerOutputRoot.length + 1).split('/').filter(Boolean); generatedRoot = path.join('tests','results','_agent','cookiecutter-bootstrap','linux','container-workspaces', receipt.run.laneId, receipt.run.runToken, 'output', ...relativeProjectDir); } if (!generatedRoot) { process.exit(1); } process.stdout.write(generatedRoot);")" + node tools/npm/run-script.mjs priority:template:agent:verify -- \ + --iteration-label "cookiecutter-bootstrap-${GITHUB_RUN_ID}" \ + --iteration-ref "${GITHUB_REF_NAME:-${GITHUB_SHA}}" \ + --iteration-head-sha "${GITHUB_SHA}" \ + --verification-status pass \ + --provider hosted-github-workflow \ + --run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --template-repo "${{ steps.template-policy.outputs.repository }}" \ + --template-version "${{ steps.template-policy.outputs.ref }}" \ + --template-ref "${{ steps.template-policy.outputs.ref }}" \ + --cookiecutter-version "${{ steps.template-policy.outputs.cookiecutter_version }}" \ + --execution-plane hosted-ubuntu-container \ + --container-image comparevi-tools:cookiecutter \ + --generated-consumer-workspace-root "$generated_root" \ + --lane-id logical-lane-template-verification \ + --agent-id Darwin \ + --funding-window-id heuristic-default + + - name: Upload template verification report + if: ${{ always() }} + uses: actions/upload-artifact@v5 + with: + name: template-agent-verification-${{ github.run_id }} + path: tests/results/_agent/promotion/template-agent-verification-report.json + if-no-files-found: error diff --git a/.github/workflows/downstream-onboarding-feedback.yml b/.github/workflows/downstream-onboarding-feedback.yml index 21415492d..aef58356e 100644 --- a/.github/workflows/downstream-onboarding-feedback.yml +++ b/.github/workflows/downstream-onboarding-feedback.yml @@ -16,6 +16,11 @@ on: required: false default: '' type: string + consumer_issue_repo: + description: 'Consumer repository slug (owner/repo) for hardening issues' + required: false + default: '' + type: string started_at: description: 'Optional onboarding start timestamp (ISO-8601 UTC)' required: false @@ -47,6 +52,7 @@ jobs: GH_TOKEN: ${{ github.token }} DOWNSTREAM_REPO: ${{ inputs.downstream_repo }} DOWNSTREAM_BRANCH: ${{ inputs.downstream_branch }} + DOWNSTREAM_CONSUMER_ISSUE_REPO: ${{ inputs.consumer_issue_repo || vars.DOWNSTREAM_CONSUMER_ISSUE_REPO }} DOWNSTREAM_STARTED_AT: ${{ inputs.started_at }} DOWNSTREAM_CREATE_ISSUES: ${{ inputs.create_hardening_issues || 'false' }} DOWNSTREAM_FAIL_ON_GAP: ${{ inputs.fail_on_gap || 'false' }} @@ -54,6 +60,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 @@ -64,6 +72,35 @@ jobs: - name: Install dependencies run: npm ci + - name: Resolve pinned template dependency policy + id: template-policy + shell: bash + run: | + set -euo pipefail + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require('fs'); + const policy = JSON.parse(fs.readFileSync('tools/policy/template-dependency.json', 'utf8')); + console.log(`repository=${policy.templateRepositorySlug}`); + console.log(`ref=${policy.templateReleaseRef}`); + console.log(`cookiecutter_version=${policy.cookiecutterVersion}`); + console.log(`execution_plane=${policy.container.executionPlane}`); + NODE + + - name: Resolve immutable upstream source + id: source + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin '+refs/heads/develop:refs/remotes/upstream/develop' + resolved="$(git rev-parse upstream/develop)" + if [ "$resolved" != "$GITHUB_SHA" ]; then + echo "Workflow head does not match upstream/develop." + echo "resolved=$resolved" + echo "head=$GITHUB_SHA" + exit 1 + fi + echo "source_sha=$resolved" >> "$GITHUB_OUTPUT" + - name: Run downstream onboarding feedback harness id: feedback continue-on-error: true @@ -108,6 +145,12 @@ jobs: args+=(--started-at "${DOWNSTREAM_STARTED_AT}") fi if [ "${DOWNSTREAM_CREATE_ISSUES}" = "true" ]; then + issue_repo="${DOWNSTREAM_CONSUMER_ISSUE_REPO}" + if [ -z "$issue_repo" ]; then + echo "Consumer hardening issue target is not configured. Set workflow input consumer_issue_repo or repo variable DOWNSTREAM_CONSUMER_ISSUE_REPO." >&2 + exit 1 + fi + args+=(--issue-repo "$issue_repo") args+=(--create-hardening-issues) fi if [ "${DOWNSTREAM_FAIL_ON_GAP}" = "true" ]; then @@ -149,6 +192,29 @@ jobs: fi node tools/npm/run-script.mjs "${args[@]}" + - name: Generate downstream promotion manifest + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + node tools/priority/downstream-promotion-manifest.mjs \ + --source-sha '${{ steps.source.outputs.source_sha }}' \ + --comparevi-tools-release 'develop@${{ steps.source.outputs.source_sha }}' \ + --comparevi-history-release 'not-evaluated:onboarding-feedback' \ + --scenario-pack-id 'downstream-onboarding-feedback@v1' \ + --cookiecutter-template-id '${{ steps.template-policy.outputs.repository }}@${{ steps.template-policy.outputs.ref }}' \ + --proving-scorecard-ref tests/results/_agent/promotion/downstream-develop-promotion-scorecard.json \ + --actor '${{ github.actor }}' \ + --output tests/results/_agent/promotion/downstream-develop-promotion-manifest.json + + - name: Validate downstream promotion manifest schema + if: ${{ always() && hashFiles('tests/results/_agent/promotion/downstream-develop-promotion-manifest.json') != '' }} + run: | + set -euo pipefail + node tools/npm/run-script.mjs schema:validate -- \ + --schema docs/schemas/downstream-promotion-manifest-v1.schema.json \ + --data tests/results/_agent/promotion/downstream-develop-promotion-manifest.json + - name: Validate onboarding report schema if: ${{ always() && hashFiles('tests/results/_agent/onboarding/downstream-onboarding.json') != '' }} run: | @@ -182,12 +248,13 @@ jobs: --data tests/results/_agent/promotion/template-agent-verification-report.json - name: Build downstream promotion scorecard - if: ${{ always() && hashFiles('tests/results/_agent/onboarding/downstream-onboarding-success.json') != '' && hashFiles('tests/results/_agent/onboarding/downstream-onboarding-feedback.json') != '' }} + if: ${{ always() && hashFiles('tests/results/_agent/onboarding/downstream-onboarding-success.json') != '' && hashFiles('tests/results/_agent/onboarding/downstream-onboarding-feedback.json') != '' && hashFiles('tests/results/_agent/promotion/downstream-develop-promotion-manifest.json') != '' }} run: | set -euo pipefail node tools/priority/downstream-promotion-scorecard.mjs \ --success-report tests/results/_agent/onboarding/downstream-onboarding-success.json \ --feedback-report tests/results/_agent/onboarding/downstream-onboarding-feedback.json \ + --template-agent-verification-report tests/results/_agent/promotion/template-agent-verification-report.json \ --manifest-report tests/results/_agent/promotion/downstream-develop-promotion-manifest.json \ --output tests/results/_agent/promotion/downstream-develop-promotion-scorecard.json diff --git a/.github/workflows/downstream-promotion.yml b/.github/workflows/downstream-promotion.yml index 911da51d9..314bd08d4 100644 --- a/.github/workflows/downstream-promotion.yml +++ b/.github/workflows/downstream-promotion.yml @@ -81,6 +81,37 @@ jobs: - name: Install dependencies run: npm ci + - name: Resolve pinned template dependency policy + id: template-policy + shell: bash + run: | + set -euo pipefail + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require('fs'); + const policy = JSON.parse(fs.readFileSync('tools/policy/template-dependency.json', 'utf8')); + console.log(`repository=${policy.templateRepositorySlug}`); + console.log(`ref=${policy.templateReleaseRef}`); + console.log(`cookiecutter_version=${policy.cookiecutterVersion}`); + console.log(`execution_plane=${policy.container.executionPlane}`); + NODE + + - name: Record pinned template dependency + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + mkdir -p tests/results/_agent/promotion + cat > tests/results/_agent/promotion/template-dependency.json <&2 + exit 1 + fi + - name: Write release summary artifact shell: bash run: | @@ -161,4 +176,3 @@ jobs: tests/results/promotion-contract/publish-tools-ledger.json tests/results/_agent/slo/publish-tools-slo-metrics.json if-no-files-found: error - diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 5b7925840..3e1c6761f 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -14,6 +14,13 @@ Quick reference for building, testing, and releasing the LVCompare composite act - `./Invoke-PesterTests.ps1 -IntegrationMode include` - **Helpers** - `tools/Dev-Dashboard.ps1` + - `priority:lane:concurrency:plan`, `priority:lane:concurrency:apply`, + and `priority:lane:concurrency:status` form the additive planning loop for + concurrent hosted/manual orchestration. The status receipt carries the plan + provenance fields (`plan.path`, `plan.schema`, `plan.source`, + `plan.recommendedBundleId`, `plan.selectedBundleId`) so operators can trace + the recommendation that produced the applied bundle without leaving the + checked-in report chain. - **Icon editor scope boundary** - Icon editor development is no longer in scope for this repository. - The active icon editor codebase and runbooks live in @@ -189,7 +196,15 @@ Quick reference for building, testing, and releasing the LVCompare composite act during live triage when you need a machine-readable distinction between `policy-wrapper-rejected`, `artifact-not-found`, `artifact-expired`, and `auth-failed`. When `GITHUB_OUTPUT` or `GITHUB_STEP_SUMMARY` is available, the CLI also projects the same status, count, and failure-class surface into workflow-friendly outputs and - step-summary lines. + step-summary lines. The helper now also records `destinationRootPolicy` so downstream tooling can prove whether the + artifact root came from an explicit CLI path, `COMPAREVI_BURST_ARTIFACT_ROOT`, the checked-in preferred external + root, or the repo-local fallback. +- Burst-lane root policy lives in `tools/priority/delivery-agent.policy.json` under `storageRoots`. + The checked-in preferred roots are `E:\comparevi-lanes` for worktrees and `E:\comparevi-artifacts` for artifacts. + `COMPAREVI_BURST_WORKTREE_ROOT` and `COMPAREVI_BURST_ARTIFACT_ROOT` override those defaults when the host exposes + a different deterministic spill location. Runtime task packets now project + `evidence.lane.workerCheckoutRoot` and `workerCheckoutRootPolicy` so later helpers can recover the exact burst-lane + root choice instead of relying on operator memory. - `node tools/npm/run-script.mjs priority:github:metadata:apply -- --url ...` Applies canonical GitHub metadata directly on the issue or PR: issue type, milestone, assignees, requested reviewers, parent issue, and sub-issue linkage. The helper writes diff --git a/docs/DOWNSTREAM_DEVELOP_PROMOTION_CONTRACT.md b/docs/DOWNSTREAM_DEVELOP_PROMOTION_CONTRACT.md index 4aeeea6af..79bdfb137 100644 --- a/docs/DOWNSTREAM_DEVELOP_PROMOTION_CONTRACT.md +++ b/docs/DOWNSTREAM_DEVELOP_PROMOTION_CONTRACT.md @@ -35,6 +35,8 @@ At minimum that means: - `comparevi-history` release identity - scenario-pack or corpus identity - cookiecutter/template identity +- pinned `LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate@v0.1.0` release +- pinned `cookiecutter==2.7.1` runtime for hosted conveyor proofs - proving scorecard reference - actor and timestamp @@ -56,6 +58,20 @@ always-on count. - machine-readable report: - `tests/results/_agent/promotion/template-agent-verification-report.json` +The reserved lane renders the pinned template dependency through the hosted +tools-image container on Ubuntu, then verifies the same pinned release on +Windows as the mirrored consumer-proof plane. The released template +dependency is treated as a conveyor-belt input, not a floating branch: + +- template repository: `LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate` +- template ref: `v0.1.0` +- cookiecutter runtime: `2.7.1` +- Ubuntu execution plane: `ghcr.io/labview-community-ci-cd/comparevi-tools:latest` +- consumer render root: + - `tests/results/_agent/cookiecutter-bootstrap//pinned-template-render` +- dependency receipt: + - `tests/results/_agent/cookiecutter-bootstrap//pinned-template-dependency.json` + The goal is to keep a continuous template-consumer feedback loop alive without starving standing-priority implementation work. The reserved lane must emit iteration-level metrics, timing, provenance, and a follow-up recommendation so @@ -127,12 +143,16 @@ Then generate the downstream proving scorecard using that manifest report: node tools/npm/run-script.mjs priority:promote:downstream:scorecard -- ` --success-report tests/results/_agent/onboarding/downstream-onboarding-success.json ` --feedback-report tests/results/_agent/onboarding/downstream-onboarding-feedback.json ` + --template-agent-verification-report tests/results/_agent/promotion/template-agent-verification-report.json ` --manifest-report tests/results/_agent/promotion/downstream-develop-promotion-manifest.json ` --output tests/results/_agent/promotion/downstream-develop-promotion-scorecard.json ``` The command fails closed when the required immutable inputs are missing or when the local `upstream/develop` ref resolves to a different commit than the requested source SHA. +It also fails closed when the latest template-agent verification report is missing, +unreadable, non-pass, or drifted away from +`LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate` on `downstream/develop`. ## Hosted promotion workflow @@ -143,6 +163,7 @@ The workflow: - verifies that the requested `source_sha` still matches `upstream/develop` - runs downstream onboarding feedback against the requested consumer repository +- records the pinned template dependency receipt for the conveyor belt - emits `downstream-develop-promotion-manifest.json` - emits `downstream-develop-promotion-scorecard.json` - advances `downstream/develop` only when the downstream promotion scorecard passes diff --git a/docs/DOWNSTREAM_RELEASE_TRAIN_ONBOARDING.md b/docs/DOWNSTREAM_RELEASE_TRAIN_ONBOARDING.md index 3db1d24d3..ad2767d09 100644 --- a/docs/DOWNSTREAM_RELEASE_TRAIN_ONBOARDING.md +++ b/docs/DOWNSTREAM_RELEASE_TRAIN_ONBOARDING.md @@ -69,10 +69,17 @@ node tools/priority/downstream-onboarding.mjs \ --repo \ --parent-issue 715 \ --create-hardening-issues \ + --issue-repo LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate \ --issue-prefix "[onboarding]" \ --issue-labels program,enhancement ``` +The hosted feedback workflow now exposes `consumer_issue_repo` so hardening issues can be routed explicitly to the +consumer repository instead of falling back to the upstream action repository. The repo-level fallback variable is +`DOWNSTREAM_CONSUMER_ISSUE_REPO`. When `--create-hardening-issues` is enabled, the consumer issue target must resolve +to the intended downstream repo, for example `LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate`, or the workflow +fails closed. + ## Success report generation Aggregate one or more onboarding reports into a single success view: @@ -138,6 +145,8 @@ node tools/npm/run-script.mjs priority:onboard:feedback -- \ The hosted workflow exports `GH_TOKEN`, attempts to leave behind a valid onboarding report even on infrastructure failures, validates all report schemas that were produced, and uploads the resulting JSON artifacts for auditability. +When hardening issues are requested, it also forwards the explicit consumer issue target into the onboarding helper +surface so follow-up issues are opened in the consumer repository, not the upstream action repository. It now follows the shared hosted-signal contract in [`HOSTED_SIGNAL_REPORT_FIRST_CONTRACT.md`](HOSTED_SIGNAL_REPORT_FIRST_CONTRACT.md): - exports both `GH_TOKEN` and `GITHUB_TOKEN` diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 33b352974..2485919db 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -101,7 +101,11 @@ Notes: Agents can dispatch the hosted lane while manually running the Linux or Windows Docker Desktop/WSL2 lanes on this host. - Use `node tools/npm/run-script.mjs priority:lane:concurrency:plan` to turn the current host-plane, host-RAM, and Docker-runtime receipts into a recommended concurrent hosted/manual lane bundle before - dispatching work. + dispatching work. The plan now emits a `dockerRuntimeCutover` contract so you can tell whether a Linux-based + Docker daemon is already reusable (`pinned-wsl2-linux-daemon` or `desktop-linux-engine`) or still needs an + explicit engine cutover before you rely on it from unknown operator state. The same contract also records the + restore mode (`wsl-shutdown`, `desktop-engine-switch-to-windows`, `none`, or `manual`) so operators can see the + cleanup step alongside the cutover step. ### NI 2026 q1 host bootstrap preflight diff --git a/docs/SINGLE_HOST_LABVIEW_2026_PLANES.md b/docs/SINGLE_HOST_LABVIEW_2026_PLANES.md index d067b5311..1e7821dda 100644 --- a/docs/SINGLE_HOST_LABVIEW_2026_PLANES.md +++ b/docs/SINGLE_HOST_LABVIEW_2026_PLANES.md @@ -69,7 +69,11 @@ Use these commands as the checked-in operator surfaces: - `node tools/npm/run-script.mjs priority:lane:concurrency:status` - Use this after apply when you need the current hosted workflow status, merge-queue observation, and explicit deferred manual/shadow obligations - in one receipt. + in one receipt. The status receipt now also carries the plan provenance + fields (`plan.path`, `plan.schema`, `plan.source`, + `plan.recommendedBundleId`, `plan.selectedBundleId`) so agents can trace + the recommendation that led to the applied bundle without leaving the + checked-in report chain. 5. Fast Docker Desktop lane loops: - `pwsh -NoLogo -NoProfile -File tools/Test-DockerDesktopFastLoop.ps1 -LaneScope linux -StepTimeoutSeconds 600` - `pwsh -NoLogo -NoProfile -File tools/Test-DockerDesktopFastLoop.ps1 -LaneScope windows -StepTimeoutSeconds 600` @@ -188,7 +192,11 @@ trustworthy. 3. Do not infer the active Docker plane from filenames alone; rely on the readiness envelope and replay helper. 4. Do not infer the active native plane from a generic LabVIEW path; use the host-plane report. 5. Use `priority:lane:concurrency:plan` before dispatching hosted Windows/Linux plus manual lanes so the plan stays - explicit and replayable. + explicit and replayable. The resulting report now includes `dockerRuntimeCutover`, which tells you whether the + current Docker state is already reusable as a Linux daemon (`pinned-wsl2-linux-daemon` or `desktop-linux-engine`) + or whether the host still needs an explicit cutover before you can safely reuse it from unknown operator state. + The same contract also carries the restore mode (`wsl-shutdown`, `desktop-engine-switch-to-windows`, `none`, or + `manual`) so the cleanup path stays machine-readable. 6. Use `priority:lane:concurrency:apply` when you need the plan projected into a machine-readable execution receipt instead of leaving the launched hosted lanes and deferred manual/shadow lanes implicit. 7. Use `priority:lane:concurrency:status` when you need to answer whether the applied hosted lane is still active, diff --git a/docs/documentation-manifest.json b/docs/documentation-manifest.json index f8b0174d4..ec39baced 100644 --- a/docs/documentation-manifest.json +++ b/docs/documentation-manifest.json @@ -2,7 +2,7 @@ "$schema": "./schemas/documentation-manifest-v1.schema.json", "schema": "documentation-manifest-v1", "version": "1.0.0", - "updated": "2026-03-21T21:30:00Z", + "updated": "2026-03-22T04:16:35Z", "entries": [ { "name": "Root Entry Points", @@ -68,11 +68,13 @@ "docs/knowledgebase/GitHub-Wiki-Portal.md", "docs/knowledgebase/Headless-SampleVI-Corpus.md", "docs/knowledgebase/PrintToSingleFileHtml-Provenance.md", + "docs/knowledgebase/Security-Alert-Reconciliation-Register.md", "docs/knowledgebase/Offline-RealHistory-Corpus.md", "docs/knowledgebase/Unattended-Delivery-Daemon-Surfaces.md", "docs/knowledgebase/Unattended-Delivery-Daemon-Debt-Register.md", "docs/knowledgebase/Unattended-Delivery-Daemon-Capability-Expansion-Register.md", "docs/DOWNSTREAM_DEVELOP_PROMOTION_CONTRACT.md", + "docs/DOWNSTREAM_RELEASE_TRAIN_ONBOARDING.md", "docs/knowledgebase/DOCKER_TOOLS_PARITY.md", "docs/LABVIEW_GATING.md", "docs/LINT_BACKLOG_PLAN.md", @@ -271,14 +273,21 @@ "name": "Template Agent Verification Contracts", "category": "supporting", "status": "reference", - "description": "Reserved template-agent verification lane policy, machine-readable report contract, generator, and focused tests for the hosted-first LabviewGitHubCiTemplate feedback loop.", - "files": [ - "docs/schemas/template-agent-verification-report-v1.schema.json", - "tools/priority/delivery-agent.policy.json", - "tools/priority/template-agent-verification-report.mjs", - "tools/priority/__tests__/template-agent-verification-report.test.mjs", - "tools/priority/__tests__/template-agent-verification-report-schema.test.mjs" - ] + "description": "Reserved template-agent verification lane policy, machine-readable report contract, generator, and focused tests for the hosted-first LabviewGitHubCiTemplate feedback loop, including the pinned LabviewGitHubCiTemplate@v0.1.0 consumer dependency and hosted container-backed verification plane.", + "files": [ + "docs/schemas/template-agent-verification-report-v1.schema.json", + "docs/schemas/template-pivot-gate-policy-v1.schema.json", + "docs/schemas/template-pivot-gate-report-v1.schema.json", + "tools/policy/template-dependency.json", + "tools/policy/template-pivot-gate.json", + "tools/priority/delivery-agent.policy.json", + "tools/priority/template-agent-verification-report.mjs", + "tools/priority/template-pivot-gate.mjs", + "tools/priority/__tests__/template-agent-verification-report.test.mjs", + "tools/priority/__tests__/template-agent-verification-report-schema.test.mjs", + "tools/priority/__tests__/template-pivot-gate.test.mjs", + "tools/priority/__tests__/template-pivot-gate-schema.test.mjs" + ] }, { "name": "Headless Sample Corpus Contracts", @@ -304,21 +313,25 @@ "name": "Cookiecutter Scaffold Contracts", "category": "supporting", "status": "reference", - "description": "Pinned cookiecutter catalog, schemas, wrapper, focused tests, and template trees for generating reviewable scenario-pack and certification corpus scaffold outputs.", + "description": "Pinned cookiecutter catalog, schemas, wrapper, focused tests, and template trees for generating reviewable scenario-pack and certification corpus scaffold outputs against the released LabviewGitHubCiTemplate@v0.1.0 dependency with a hosted container-backed render conveyor.", "files": [ "docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md", ".github/workflows/cookiecutter-bootstrap.yml", "docs/schemas/comparevi-cookiecutter-bootstrap-proof-v1.schema.json", "docs/schemas/comparevi-cookiecutter-scaffold-v1.schema.json", "docs/schemas/comparevi-cookiecutter-template-catalog-v1.schema.json", + "docs/schemas/template-dependency-v1.schema.json", "tests/New-CompareVICookiecutterScaffold.Tests.ps1", "tests/Test-CompareVICookiecutterBootstrap.Tests.ps1", "tests/fixtures/cookiecutter/scenario-pack.context.json", "tests/fixtures/cookiecutter/corpus-seed.context.json", + "tests/fixtures/cookiecutter/template-context.json", "tools/New-CompareVICookiecutterScaffold.ps1", "tools/Test-CompareVICookiecutterBootstrap.ps1", "tools/cookiecutter/run-cookiecutter.py", "tools/policy/comparevi-cookiecutter-templates.json", + "tools/policy/template-dependency.json", + "tools/priority/template-cookiecutter-container.mjs", "tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs" ], "include": [ diff --git a/docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md b/docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md index 3a2fd1fbd..25f39ce14 100644 --- a/docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md +++ b/docs/knowledgebase/Cookiecutter-Certification-Scaffolds.md @@ -118,14 +118,42 @@ Hosted proof workflow: The hosted proof runs `tools/Test-CompareVICookiecutterBootstrap.ps1`, which: - exercises the shared scaffold wrapper on both hosted OS planes -- validates the pinned `cookiecutter==2.6.0` runtime through the wrapper +- validates the pinned `cookiecutter==2.7.1` runtime through the wrapper - emits a proof receipt at `tests/results/_agent/cookiecutter-bootstrap//comparevi-cookiecutter-bootstrap-proof.json` - uploads both proof receipts and generated scaffold outputs for review +The hosted conveyor now has a second, template-consumer proof lane: + +- pinned template dependency: + - `LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate@v0.1.0` +- checked-in deterministic context: + - `tests/fixtures/cookiecutter/template-context.json` +- hosted Ubuntu execution plane: + - `ghcr.io/labview-community-ci-cd/comparevi-tools:latest` +- helper entrypoint: + - `node tools/npm/run-script.mjs priority:template:render:container` +- deterministic render mode: + - `priority:template:render:container` reads `tools/policy/template-dependency.json` + - the helper uses the pinned template dependency, pinned `cookiecutter==2.7.1`, + and the checked-in deterministic template context +- generated consumer output root: + - `tests/results/_agent/cookiecutter-bootstrap//pinned-template-render` +- dependency receipt: + - `tests/results/_agent/cookiecutter-bootstrap//pinned-template-dependency.json` +- verification report: + - `tests/results/_agent/promotion/template-agent-verification-report.json` + +Hosted Ubuntu renders the pinned template dependency inside the tools image. +Hosted Windows verifies the same pinned release host-native so the conveyor belt +has both a container-backed render plane and a mirrored verification plane. A +follow-on hosted verification job then emits the machine-readable template-agent +verification report so downstream proving and the template pivot gate consume +the same pinned dependency provenance. + ## Natural Follow-Ons - mirror the template family into `svelderrainruiz/cookiecutter` -- carry the same scaffold surface into `LabviewGitHubCiTemplate` +- carry the same scaffold surface into `LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate@v0.1.0` - use the hosted bootstrap proof as the consumer-proving install contract for Linux and Windows lanes diff --git a/docs/knowledgebase/LabVIEWCLI-CustomOperation-Scaffold.md b/docs/knowledgebase/LabVIEWCLI-CustomOperation-Scaffold.md index d9a76f1e5..f3e6c71f7 100644 --- a/docs/knowledgebase/LabVIEWCLI-CustomOperation-Scaffold.md +++ b/docs/knowledgebase/LabVIEWCLI-CustomOperation-Scaffold.md @@ -29,12 +29,16 @@ This helper does not promote the NI example into the repository. `tools/New-PrintToSingleFileHtmlAuthoringWorkspace.ps1` - Native-authoring packet wrapper: `tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1` +- Payload finalization helper: + `tools/Finalize-OperationPayloadSourceBundle.ps1` - Receipt schema: `docs/schemas/labview-cli-custom-operation-scaffold-v1.schema.json` - Dedicated wrapper receipt schema: `docs/schemas/print-to-single-file-html-authoring-workspace-v1.schema.json` - Native-authoring packet receipt schema: `docs/schemas/print-to-single-file-html-authoring-packet-v1.schema.json` +- Payload finalization receipt schema: + `docs/schemas/operation-payload-authoring-finalization-v1.schema.json` - Focused test: `tests/New-LabVIEWCLICustomOperationWorkspace.Tests.ps1` - Dedicated wrapper test: @@ -107,6 +111,13 @@ x86 path, an authoring checklist, and a convenience launch script: node tools/npm/run-script.mjs history:custom-operation:authoring-packet:print-single-file ``` +Finalize the repo-owned payload metadata after native authoring has copied real +LabVIEW binary files into the payload bundle: + +```powershell +node tools/npm/run-script.mjs history:custom-operation:finalize:print-single-file +``` + ## Receipt Successful runs emit `labview-cli-custom-operation-scaffold@v1` with: @@ -134,5 +145,8 @@ tree. The dedicated `PrintToSingleFileHtml` wrappers from `#1621` build on that generic scaffold without changing the underlying bootstrap contract: the workspace wrapper bootstraps disposable files, and the native-authoring packet turns the remaining gap into an explicit LabVIEW authoring handoff instead of a -hidden assumption. That handoff is the concrete prerequisite for the standing -public proof lanes in `#1617`, `#1726`, and `#1467`. +hidden assumption. The finalization helper then closes the bookkeeping gap after +that native authoring step by updating the payload bundle metadata only when the +checked-in files inspect as real LabVIEW binaries. +That handoff remains the concrete prerequisite for the standing public proof +lanes in `#1617`, `#1726`, and `#1467`. diff --git a/docs/knowledgebase/PrintToSingleFileHtml-Provenance.md b/docs/knowledgebase/PrintToSingleFileHtml-Provenance.md index b227517c5..0ffebb6e8 100644 --- a/docs/knowledgebase/PrintToSingleFileHtml-Provenance.md +++ b/docs/knowledgebase/PrintToSingleFileHtml-Provenance.md @@ -23,14 +23,10 @@ The reason is provenance, not usefulness. MIT-licensed public consumer repository, and public CompareVI History diagnostics succeeded on the pinned PR head commit `91516373bf6c95e1d3cee2ee97452bc9d08f4ed7`. This is the best licensed - added-VI corpus seed we have, but the published receipts are still - history-surface artifacts rather than a standalone `PrintToSingleFileHtml` - proof against the replacement payload. - `91516373bf6c95e1d3cee2ee97452bc9d08f4ed7`. The public artifact for that run - is `comparevi-history-pr-diagnostics-23225926010`. This is the best licensed - added-VI corpus seed we have, but the published receipts are still - history-surface artifacts rather than a standalone `PrintToSingleFileHtml` - proof against the replacement payload. + added-VI corpus seed we have. The public artifact for that run is + `comparevi-history-pr-diagnostics-23225926010`, but the published receipts + are still history-surface artifacts rather than a standalone + `PrintToSingleFileHtml` proof against the replacement payload. This repository now carries a repo-owned BSD-3 licensed source bundle for the replacement payload under: @@ -60,12 +56,20 @@ As of March 21, 2026, the bundle is still explicitly `source-only`: sample-corpus print lane instead of relying on human interpretation; when the payload becomes runnable, the same wrapper now executes the Linux proof lane through `tools/Run-NILinuxContainerCustomOperation.ps1` +- When a finalization contract is available, the proof receipt now records the + referenced contract path so future payload finalization helpers can be tied to + the same deterministic proof lane without inventing a new receipt shape - `tools/New-PrintToSingleFileHtmlAuthoringWorkspace.ps1` now wraps the generic scaffold so this payload has a dedicated disposable authoring bootstrap - `tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1` now turns the remaining gap into an explicit native-authoring packet with the installed `Operations.lvproj`, `Toolkit-Operations.lvproj`, LabVIEW 2026 x86 path, an authoring checklist, and a launch helper +- `tools/Finalize-OperationPayloadSourceBundle.ps1` now gives the native + authoring handoff a deterministic completion step: once repo-owned LabVIEW + binary files are checked in, the helper updates `checkedInOperationFiles`, + flips the declared executable state to `runnable`, and leaves public proof as + the remaining blocker instead of requiring manual manifest editing ## Repo policy diff --git a/docs/knowledgebase/Security-Alert-Reconciliation-Register.md b/docs/knowledgebase/Security-Alert-Reconciliation-Register.md new file mode 100644 index 000000000..4902d56e4 --- /dev/null +++ b/docs/knowledgebase/Security-Alert-Reconciliation-Register.md @@ -0,0 +1,44 @@ + +# Security Alert Reconciliation Register + +This register tracks only security-alert reconciliation debt that still matters +for RC determinism on current `develop`. + +It is intentionally narrower than the general security intake surface. Use it +to keep the live Dependabot / dependency-graph lag explicit without inventing +new remediation work when the repository manifests are already fixed. + +## Current State + +- The live security intake report remains `platform-stale`. +- The repo manifests are already remediated locally to `js-yaml@4.1.1`. +- GitHub still reports open Dependabot alerts `#3` and `#4` for the same + package. +- The machine-readable report lives at + `tests/results/_agent/security/security-intake-report.json`. + +## RC-Relevant Finding + +### Dependabot / dependency-graph lag after npm remediation + +- Why it matters: RC reviewers should not infer an unremediated dependency from + a stale upstream alert if the repo manifests are already fixed. +- Current seam: `tools/priority/security-intake.mjs` correctly classifies the + current state as `platform-stale`, but the repository cannot force GitHub to + reconcile the alert state immediately. +- Evidence surfaces: + - code: `tools/priority/security-intake.mjs` + - receipt: `tests/results/_agent/security/security-intake-report.json` + - manifest proof: `package.json`, `package-lock.json` + - tests: `tools/priority/__tests__/security-intake.test.mjs`, + `tools/priority/__tests__/security-intake-schema.test.mjs` +- Follow-up issue: `#1426` + +## Exit Criteria + +Close or demote this register entry when GitHub Dependabot alerts `#3` and `#4` +either auto-close or stop reporting the repository as `platform-stale`. + +If `priority:security:intake` starts failing again because of an API-400 or +similar tooling regression, that is a separate follow-up issue and should not be +folded into the external platform-lag tracker. diff --git a/docs/knowledgebase/Unattended-Delivery-Daemon-Surfaces.md b/docs/knowledgebase/Unattended-Delivery-Daemon-Surfaces.md index 51c2257d6..049e4c3f2 100644 --- a/docs/knowledgebase/Unattended-Delivery-Daemon-Surfaces.md +++ b/docs/knowledgebase/Unattended-Delivery-Daemon-Surfaces.md @@ -13,14 +13,51 @@ Use these checked-in commands first instead of reaching for ad hoc - `node tools/npm/run-script.mjs priority:delivery:agent:ensure` - `node tools/npm/run-script.mjs priority:delivery:agent:status` - `node tools/npm/run-script.mjs priority:delivery:agent:stop` +- `node tools/npm/run-script.mjs priority:delivery:host:collect` +- `node tools/npm/run-script.mjs priority:delivery:host:isolate` +- `node tools/npm/run-script.mjs priority:delivery:host:restore` - `node tools/npm/run-script.mjs priority:runtime:daemon` - `node tools/npm/run-script.mjs priority:runtime:daemon:docker` - `node tools/npm/run-script.mjs priority:runtime:daemon:docker:status` +- `node tools/npm/run-script.mjs priority:jarvis:status` Prefer `priority:delivery:agent:status` as the first read. That surface already normalizes manager state, heartbeat fallback, and lane/runtime evidence into one bounded status payload. +Use the explicit host aliases instead of passing raw `--mode` flags when the +operator loop needs host-runtime coordination: + +- `priority:delivery:host:collect` refreshes `daemon-host-signal.json` and + `delivery-agent-host-isolation.json` +- `priority:delivery:host:isolate` preempts only the runner services that were + actually running at the start of the call +- `priority:delivery:host:restore` starts back only services previously + preempted by the isolate step + +Use `priority:jarvis:status` when Sagan needs a bounded live watch surface for +the Windows Docker specialty lane family. It emits +`tests/results/_agent/runtime/jarvis-session-observer.json`, summarizes any +active Jarvis sessions, and tails the daemon logs that matter for fast operator +triage. + +When `priority:jarvis:status` reports `daemonCutover.status = cutover-required`, +use the emitted `daemonCutover.requiredActions` as the operator loop: + +1. Read `tests/results/_agent/runtime/daemon-host-signal.json` first to confirm + whether the host still presents as `desktop-backed`. +2. Read `tests/results/_agent/runtime/jarvis-session-observer.json` next to see + the concrete cutover actions, including any `actions.runner.*` service + isolation guidance. +3. Stop or govern the listed `actions.runner.*` services if they are still + present on the host. +4. Run `priority:delivery:host:collect` to refresh the host receipts, or + `priority:delivery:host:isolate` / `priority:delivery:host:restore` when the + runner-service step needs to be enacted explicitly. +5. Rerun `priority:jarvis:status`. +6. Treat the plane as reusable only when the observer reports + `daemonCutover.status = ready`. + ## Canonical Receipt Read Order When a daemon lane needs diagnosis, read receipts in this order: @@ -28,10 +65,11 @@ When a daemon lane needs diagnosis, read receipts in this order: 1. `tests/results/_agent/runtime/delivery-agent-state.json` 2. `tests/results/_agent/runtime/delivery-agent-lanes/.json` 3. `tests/results/_agent/runtime/delivery-memory.json` -4. `tests/results/_agent/runtime/observer-heartbeat.json` -5. `tests/results/_agent/runtime/task-packet.json` -6. `tests/results/_agent/runtime/codex-state-hygiene.json` -7. `tests/results/_agent/marketplace/lane-marketplace-snapshot.json` +4. `tests/results/_agent/runtime/jarvis-session-observer.json` +5. `tests/results/_agent/runtime/observer-heartbeat.json` +6. `tests/results/_agent/runtime/task-packet.json` +7. `tests/results/_agent/runtime/codex-state-hygiene.json` +8. `tests/results/_agent/marketplace/lane-marketplace-snapshot.json` Secondary control-plane receipts worth checking only when the main runtime view looks stale: @@ -41,6 +79,13 @@ looks stale: - `tests/results/_agent/runtime/delivery-agent-manager-stop.json` - `tests/results/_agent/runtime/delivery-agent-wsl-daemon-pid.json` - `tests/results/_agent/runtime/daemon-host-signal.json` +- `tests/results/_agent/runtime/docker-daemon-engine.json` + +When delivery policy expects `dockerRuntime.provider = native-wsl`, +`daemon-host-signal.json` is the authority for whether the Linux daemon-first +plane is reusable. If it reports `desktop-backed`, WSL still resolves to Docker +Desktop and a distro-owned Linux daemon cutover is required before reusing the +daemon-first Linux plane. ## Audit Registers diff --git a/docs/schemas/concurrent-lane-plan-v1.schema.json b/docs/schemas/concurrent-lane-plan-v1.schema.json index 4d4de33fc..b9f5524c8 100644 --- a/docs/schemas/concurrent-lane-plan-v1.schema.json +++ b/docs/schemas/concurrent-lane-plan-v1.schema.json @@ -10,6 +10,7 @@ "repository", "inputs", "host", + "dockerRuntimeCutover", "lanes", "bundles", "recommendedBundle", @@ -60,6 +61,58 @@ "nativeParallelLabVIEWSupported": { "type": "boolean" } } }, + "dockerRuntimeCutover": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "runtimeProvider", + "expectedOsType", + "observedOsType", + "expectedDockerHost", + "observedDockerHost", + "observedContext", + "cutoverMode", + "canReuseLinuxDaemon", + "requiresHostMutation", + "requiresWslShutdown", + "restoreMode", + "restoreRequired", + "reason" + ], + "properties": { + "schema": { "const": "priority/docker-runtime-cutover-contract@v1" }, + "runtimeProvider": { "enum": ["desktop", "native-wsl", "unknown"] }, + "expectedOsType": { "enum": ["linux", "windows", "unknown"] }, + "observedOsType": { "enum": ["linux", "windows", "unknown"] }, + "expectedDockerHost": { "type": ["string", "null"] }, + "observedDockerHost": { "type": ["string", "null"] }, + "observedContext": { "type": ["string", "null"] }, + "cutoverMode": { + "enum": [ + "pinned-wsl2-linux-daemon", + "desktop-linux-engine", + "desktop-engine-switch-to-linux", + "desktop-windows-engine", + "unknown" + ] + }, + "canReuseLinuxDaemon": { "type": "boolean" }, + "requiresHostMutation": { "type": "boolean" }, + "requiresWslShutdown": { "type": "boolean" }, + "restoreMode": { + "enum": [ + "wsl-shutdown", + "desktop-engine-switch-to-windows", + "none", + "manual", + "unknown" + ] + }, + "restoreRequired": { "type": "boolean" }, + "reason": { "type": "string", "minLength": 1 } + } + }, "lanes": { "type": "array", "minItems": 1, @@ -87,6 +140,25 @@ "availableLaneCount": { "type": "integer", "minimum": 0 }, "hostedLaneCount": { "type": "integer", "minimum": 0 }, "localLaneCount": { "type": "integer", "minimum": 0 }, + "dockerCutoverMode": { + "enum": [ + "pinned-wsl2-linux-daemon", + "desktop-linux-engine", + "desktop-engine-switch-to-linux", + "desktop-windows-engine", + "unknown" + ] + }, + "dockerCutoverReusable": { "type": "boolean" }, + "dockerCutoverRestoreMode": { + "enum": [ + "wsl-shutdown", + "desktop-engine-switch-to-windows", + "none", + "manual", + "unknown" + ] + }, "recommendedBundleId": { "type": ["string", "null"] } } } diff --git a/docs/schemas/concurrent-lane-status-receipt-v1.schema.json b/docs/schemas/concurrent-lane-status-receipt-v1.schema.json index a50214f6a..57bd571fd 100644 --- a/docs/schemas/concurrent-lane-status-receipt-v1.schema.json +++ b/docs/schemas/concurrent-lane-status-receipt-v1.schema.json @@ -10,6 +10,7 @@ "repository", "status", "applyReceipt", + "plan", "hostedRun", "pullRequest", "laneStatuses", @@ -49,6 +50,28 @@ } } }, + "plan": { + "type": "object", + "additionalProperties": false, + "required": ["path", "schema", "source", "recommendedBundleId", "selectedBundleId"], + "properties": { + "path": { + "type": ["string", "null"] + }, + "schema": { + "type": ["string", "null"] + }, + "source": { + "type": ["string", "null"] + }, + "recommendedBundleId": { + "type": ["string", "null"] + }, + "selectedBundleId": { + "type": ["string", "null"] + } + } + }, "hostedRun": { "type": "object", "additionalProperties": false, diff --git a/docs/schemas/delivery-agent-policy-v1.schema.json b/docs/schemas/delivery-agent-policy-v1.schema.json index d6126aa24..43d8a3dc4 100644 --- a/docs/schemas/delivery-agent-policy-v1.schema.json +++ b/docs/schemas/delivery-agent-policy-v1.schema.json @@ -71,6 +71,18 @@ } } }, + "storageRoots": { + "type": "object", + "additionalProperties": true, + "properties": { + "worktrees": { + "$ref": "#/$defs/storageRootEntry" + }, + "artifacts": { + "$ref": "#/$defs/storageRootEntry" + } + } + }, "workerPool": { "type": "object", "additionalProperties": true, @@ -306,5 +318,18 @@ "type": "array", "items": { "type": "string" } } + }, + "$defs": { + "storageRootEntry": { + "type": "object", + "additionalProperties": true, + "properties": { + "envVar": { "type": "string", "minLength": 1 }, + "preferredRoots": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } } } diff --git a/docs/schemas/downstream-promotion-scorecard-v1.schema.json b/docs/schemas/downstream-promotion-scorecard-v1.schema.json index 5f5342895..374cd991e 100644 --- a/docs/schemas/downstream-promotion-scorecard-v1.schema.json +++ b/docs/schemas/downstream-promotion-scorecard-v1.schema.json @@ -19,20 +19,22 @@ "inputs": { "type": "object", "additionalProperties": false, - "required": ["successReport", "feedbackReport", "manifestReport"], + "required": ["successReport", "feedbackReport", "templateAgentVerificationReport", "manifestReport"], "properties": { "successReport": { "$ref": "#/$defs/inputRef" }, "feedbackReport": { "$ref": "#/$defs/inputRef" }, + "templateAgentVerificationReport": { "$ref": "#/$defs/inputRef" }, "manifestReport": { "$ref": "#/$defs/inputRefNullablePath" } } }, "gates": { "type": "object", "additionalProperties": false, - "required": ["successReport", "feedbackReport", "manifestReport"], + "required": ["successReport", "feedbackReport", "templateAgentVerificationReport", "manifestReport"], "properties": { "successReport": { "$ref": "#/$defs/successGate" }, "feedbackReport": { "$ref": "#/$defs/feedbackGate" }, + "templateAgentVerificationReport": { "$ref": "#/$defs/templateAgentVerificationGate" }, "manifestReport": { "$ref": "#/$defs/manifestGate" } } }, @@ -66,7 +68,11 @@ "compareviToolsRelease", "compareviHistoryRelease", "scenarioPackIdentity", - "cookiecutterTemplateIdentity" + "cookiecutterTemplateIdentity", + "templateVerificationRepository", + "templateVerificationVersion", + "templateVerificationRef", + "templateVerificationConsumerRailBranch" ], "properties": { "sourceRef": { "type": ["string", "null"] }, @@ -74,7 +80,11 @@ "compareviToolsRelease": { "type": ["string", "null"] }, "compareviHistoryRelease": { "type": ["string", "null"] }, "scenarioPackIdentity": { "type": ["string", "null"] }, - "cookiecutterTemplateIdentity": { "type": ["string", "null"] } + "cookiecutterTemplateIdentity": { "type": ["string", "null"] }, + "templateVerificationRepository": { "type": ["string", "null"] }, + "templateVerificationVersion": { "type": ["string", "null"] }, + "templateVerificationRef": { "type": ["string", "null"] }, + "templateVerificationConsumerRailBranch": { "type": ["string", "null"] } } } } @@ -136,6 +146,34 @@ "downstreamRepository": { "type": ["string", "null"] } } }, + "templateAgentVerificationGate": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "schema", + "summaryStatus", + "verificationStatus", + "targetRepository", + "consumerRailBranch", + "templateRepository", + "templateVersion", + "templateRef", + "cookiecutterVersion" + ], + "properties": { + "status": { "enum": ["pass", "fail", "missing"] }, + "schema": { "type": ["string", "null"] }, + "summaryStatus": { "type": ["string", "null"] }, + "verificationStatus": { "type": ["string", "null"] }, + "targetRepository": { "type": ["string", "null"] }, + "consumerRailBranch": { "type": ["string", "null"] }, + "templateRepository": { "type": ["string", "null"] }, + "templateVersion": { "type": ["string", "null"] }, + "templateRef": { "type": ["string", "null"] }, + "cookiecutterVersion": { "type": ["string", "null"] } + } + }, "manifestGate": { "type": "object", "additionalProperties": false, diff --git a/docs/schemas/headless-sample-vi-corpus-print-proof-v1.schema.json b/docs/schemas/headless-sample-vi-corpus-print-proof-v1.schema.json index 994ff43c7..cc36dd30e 100644 --- a/docs/schemas/headless-sample-vi-corpus-print-proof-v1.schema.json +++ b/docs/schemas/headless-sample-vi-corpus-print-proof-v1.schema.json @@ -18,6 +18,8 @@ "payloadInspectionMarkdownPath", "payloadDeclaredExecutableState", "payloadObservedExecutableState", + "payloadFinalizationContractPath", + "payloadFinalizationContractAvailable", "runnerPath", "targetRepositoryPath", "targetRepositoryMaterialized", @@ -85,6 +87,15 @@ "type": "string", "minLength": 1 }, + "payloadFinalizationContractPath": { + "type": [ + "string", + "null" + ] + }, + "payloadFinalizationContractAvailable": { + "type": "boolean" + }, "runnerPath": { "type": "string", "minLength": 1 diff --git a/docs/schemas/jarvis-session-observer-v1.schema.json b/docs/schemas/jarvis-session-observer-v1.schema.json new file mode 100644 index 000000000..6b75336d3 --- /dev/null +++ b/docs/schemas/jarvis-session-observer-v1.schema.json @@ -0,0 +1,502 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://labview-community-cicd.github.io/compare-vi-cli-action/schemas/jarvis-session-observer-v1.schema.json", + "title": "Jarvis Session Observer Report v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "generatedAt", + "repository", + "status", + "summary", + "jarvisPolicy", + "hostRuntime", + "daemon", + "sessions", + "warnings", + "artifacts" + ], + "properties": { + "schema": { + "const": "priority/jarvis-session-observer@v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "repository": { + "type": ["string", "null"] + }, + "status": { + "type": "string", + "enum": ["active", "idle", "blocked", "unknown"] + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "specialtyLaneId", + "primaryRecordedResponsibility", + "configuredSessionCapacity", + "effectiveSessionCapacity", + "activeSessionCount", + "queuedSessionCount", + "blockedSessionCount", + "deferredSessionCount", + "totalSessionCount", + "daemonCutoverStatus", + "readyForLinuxDaemon", + "requiresOperatorCutover" + ], + "properties": { + "specialtyLaneId": { "type": "string" }, + "primaryRecordedResponsibility": { "type": ["string", "null"] }, + "configuredSessionCapacity": { "type": "integer", "minimum": 1 }, + "effectiveSessionCapacity": { "type": "integer", "minimum": 1 }, + "activeSessionCount": { "type": "integer", "minimum": 0 }, + "queuedSessionCount": { "type": "integer", "minimum": 0 }, + "blockedSessionCount": { "type": "integer", "minimum": 0 }, + "deferredSessionCount": { "type": "integer", "minimum": 0 }, + "totalSessionCount": { "type": "integer", "minimum": 0 }, + "daemonCutoverStatus": { + "type": "string", + "enum": ["ready", "cutover-required", "drifted", "runner-conflict", "not-required", "unknown"] + }, + "readyForLinuxDaemon": { "type": "boolean" }, + "requiresOperatorCutover": { "type": "boolean" } + } + }, + "jarvisPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "specialtyLaneId", + "enabled", + "primaryRecordedResponsibility", + "purpose", + "allocationMode", + "preferredExecutionPlane", + "preferredContainerImage", + "configuredSessionCapacity", + "effectiveSessionCapacity", + "effectiveLogicalLaneCount", + "dockerRuntimePolicy" + ], + "properties": { + "specialtyLaneId": { "type": "string" }, + "enabled": { "type": "boolean" }, + "primaryRecordedResponsibility": { "type": ["string", "null"] }, + "purpose": { "type": ["string", "null"] }, + "allocationMode": { "type": ["string", "null"] }, + "preferredExecutionPlane": { "type": ["string", "null"] }, + "preferredContainerImage": { "type": ["string", "null"] }, + "configuredSessionCapacity": { "type": "integer", "minimum": 1 }, + "effectiveSessionCapacity": { "type": "integer", "minimum": 1 }, + "effectiveLogicalLaneCount": { "type": "integer", "minimum": 1 }, + "dockerRuntimePolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "expectedDockerHost", + "expectedOsType", + "expectedContext", + "manageDockerEngine", + "allowHostEngineMutation" + ], + "properties": { + "provider": { "type": ["string", "null"] }, + "expectedDockerHost": { "type": ["string", "null"] }, + "expectedOsType": { "type": ["string", "null"] }, + "expectedContext": { "type": ["string", "null"] }, + "manageDockerEngine": { "type": "boolean" }, + "allowHostEngineMutation": { "type": "boolean" } + } + } + } + }, + "hostRuntime": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "provider", + "source", + "reasons", + "daemonFingerprint", + "previousFingerprint", + "fingerprintChanged", + "windowsDocker", + "wslDocker", + "runnerServices", + "isolation", + "diagnostics" + ], + "properties": { + "status": { "type": "string" }, + "provider": { "type": ["string", "null"] }, + "source": { + "type": "string", + "enum": ["delivery-agent-manager-state", "daemon-host-signal", "unavailable"] + }, + "reasons": { "type": "array", "items": { "type": "string" } }, + "daemonFingerprint": { "type": ["string", "null"] }, + "previousFingerprint": { "type": ["string", "null"] }, + "fingerprintChanged": { "type": "boolean" }, + "windowsDocker": { + "$ref": "#/$defs/dockerSignal" + }, + "wslDocker": { + "$ref": "#/$defs/wslDockerSignal" + }, + "runnerServices": { + "type": "object", + "additionalProperties": false, + "required": ["running", "stopped"], + "properties": { + "running": { "type": "array", "items": { "type": "string" } }, + "stopped": { "type": "array", "items": { "type": "string" } } + } + }, + "isolation": { + "type": "object", + "additionalProperties": true, + "required": ["lastStatus", "lastAction", "preemptedServices", "counters"], + "properties": { + "lastStatus": { "type": ["string", "null"] }, + "lastAction": { "type": ["string", "null"] }, + "preemptedServices": { "type": "array", "items": { "type": "string" } }, + "counters": { "type": "object" } + } + }, + "diagnostics": { + "type": "object", + "additionalProperties": false, + "required": ["managerStateAvailable", "managerGeneratedAt", "hostSignalDiagnostics"], + "properties": { + "managerStateAvailable": { "type": "boolean" }, + "managerGeneratedAt": { "type": ["string", "null"], "format": "date-time" }, + "hostSignalDiagnostics": { + "type": "object", + "additionalProperties": false, + "required": [ + "usedHostSignal", + "reason", + "hostSignalGeneratedAt", + "managerStartedAt", + "hostSignalRepository" + ], + "properties": { + "usedHostSignal": { "type": "boolean" }, + "reason": { "type": ["string", "null"] }, + "hostSignalGeneratedAt": { "type": ["string", "null"], "format": "date-time" }, + "managerStartedAt": { "type": ["string", "null"], "format": "date-time" }, + "hostSignalRepository": { "type": ["string", "null"] } + } + } + } + } + } + }, + "daemon": { + "type": "object", + "additionalProperties": false, + "required": [ + "observerHeartbeat", + "wslDaemon", + "dockerDaemonEngine", + "daemonCutover", + "deliveryMemory", + "logs" + ], + "properties": { + "observerHeartbeat": { + "type": "object", + "additionalProperties": false, + "required": ["exists", "generatedAt", "outcome", "cyclesCompleted", "activeLaneId", "activeIssue", "stopRequested"], + "properties": { + "exists": { "type": "boolean" }, + "generatedAt": { "type": ["string", "null"], "format": "date-time" }, + "outcome": { "type": ["string", "null"] }, + "cyclesCompleted": { "type": "integer", "minimum": 0 }, + "activeLaneId": { "type": ["string", "null"] }, + "activeIssue": { "type": ["integer", "null"], "minimum": 1 }, + "stopRequested": { "type": "boolean" } + } + }, + "wslDaemon": { + "type": "object", + "additionalProperties": false, + "required": ["exists", "pid", "generatedAt", "running", "unitName", "distro"], + "properties": { + "exists": { "type": "boolean" }, + "pid": { "type": ["integer", "null"], "minimum": 1 }, + "generatedAt": { "type": ["string", "null"], "format": "date-time" }, + "running": { "type": "boolean" }, + "unitName": { "type": ["string", "null"] }, + "distro": { "type": ["string", "null"] } + } + }, + "dockerDaemonEngine": { + "type": "object", + "additionalProperties": false, + "required": [ + "exists", + "generatedAt", + "requiredOs", + "lockPath", + "lockAcquired", + "dockerCommand", + "observedOs", + "previousContext", + "activeContext", + "contextMode", + "contextSwitched" + ], + "properties": { + "exists": { "type": "boolean" }, + "generatedAt": { "type": ["string", "null"], "format": "date-time" }, + "requiredOs": { "type": ["string", "null"] }, + "lockPath": { "type": ["string", "null"] }, + "lockAcquired": { "type": "boolean" }, + "dockerCommand": { "type": ["string", "null"] }, + "observedOs": { "type": ["string", "null"] }, + "previousContext": { "type": ["string", "null"] }, + "activeContext": { "type": ["string", "null"] }, + "contextMode": { "type": ["string", "null"] }, + "contextSwitched": { "type": "boolean" } + } + }, + "daemonCutover": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "status", + "runtimeProvider", + "expectedDockerHost", + "observedDockerHost", + "expectedContext", + "observedContext", + "expectedOsType", + "observedOsType", + "canReuseLinuxDaemon", + "readyForLinuxDaemon", + "requiresOperatorCutover", + "requiredActions", + "reason" + ], + "properties": { + "schema": { "const": "priority/jarvis-daemon-cutover-assessment@v1" }, + "status": { + "type": "string", + "enum": ["ready", "cutover-required", "drifted", "runner-conflict", "not-required", "unknown"] + }, + "runtimeProvider": { "type": ["string", "null"] }, + "expectedDockerHost": { "type": ["string", "null"] }, + "observedDockerHost": { "type": ["string", "null"] }, + "expectedContext": { "type": ["string", "null"] }, + "observedContext": { "type": ["string", "null"] }, + "expectedOsType": { "type": ["string", "null"] }, + "observedOsType": { "type": ["string", "null"] }, + "canReuseLinuxDaemon": { "type": "boolean" }, + "readyForLinuxDaemon": { "type": "boolean" }, + "requiresOperatorCutover": { "type": "boolean" }, + "requiredActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "reason": { "type": "string" } + } + }, + "deliveryMemory": { + "type": "object", + "additionalProperties": false, + "required": ["exists", "generatedAt", "workerPoolTarget"], + "properties": { + "exists": { "type": "boolean" }, + "generatedAt": { "type": ["string", "null"], "format": "date-time" }, + "workerPoolTarget": { "type": ["integer", "null"], "minimum": 1 } + } + }, + "logs": { + "type": "object", + "additionalProperties": false, + "required": ["runtimeDaemonWsl", "dockerDaemon"], + "properties": { + "runtimeDaemonWsl": { "$ref": "#/$defs/logTail" }, + "dockerDaemon": { "$ref": "#/$defs/logTail" } + } + } + } + }, + "sessions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "sessionId", + "logicalLaneId", + "issue", + "phase", + "laneId", + "laneClass", + "executionPlane", + "resourceGroup", + "branchRef", + "dockerContext", + "dockerServerOs", + "preferredContainerImage", + "primaryRecordedResponsibility", + "reason" + ], + "properties": { + "source": { + "type": "string", + "enum": ["concurrent-lane-status", "runtime-state-active-lane"] + }, + "sessionId": { "type": "string" }, + "logicalLaneId": { "type": ["string", "null"] }, + "issue": { "type": ["integer", "null"], "minimum": 1 }, + "phase": { + "type": "string", + "enum": ["active", "queued", "blocked", "deferred", "idle", "unknown"] + }, + "laneId": { "type": ["string", "null"] }, + "laneClass": { "type": ["string", "null"] }, + "executionPlane": { "type": ["string", "null"] }, + "resourceGroup": { "type": ["string", "null"] }, + "branchRef": { "type": ["string", "null"] }, + "dockerContext": { "type": ["string", "null"] }, + "dockerServerOs": { "type": ["string", "null"] }, + "preferredContainerImage": { "type": ["string", "null"] }, + "primaryRecordedResponsibility": { "type": ["string", "null"] }, + "reason": { "type": ["string", "null"] } + } + } + }, + "warnings": { + "type": "array", + "items": { "type": "string" } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": [ + "receiptPath", + "watchPaths", + "policyPath", + "runtimeDir", + "deliveryStatePath", + "concurrentLaneStatusPath", + "managerStatePath", + "hostSignalPath", + "hostIsolationPath", + "observerHeartbeatPath", + "wslDaemonPidPath", + "dockerDaemonEnginePath" + ], + "properties": { + "receiptPath": { "type": "string" }, + "watchPaths": { "type": "array", "items": { "type": "string" } }, + "policyPath": { "type": "string" }, + "runtimeDir": { "type": "string" }, + "deliveryStatePath": { "type": "string" }, + "concurrentLaneStatusPath": { "type": "string" }, + "managerStatePath": { "type": "string" }, + "hostSignalPath": { "type": "string" }, + "hostIsolationPath": { "type": "string" }, + "observerHeartbeatPath": { "type": "string" }, + "wslDaemonPidPath": { "type": "string" }, + "dockerDaemonEnginePath": { "type": "string" } + } + } + }, + "$defs": { + "dockerSignal": { + "type": "object", + "additionalProperties": false, + "required": [ + "available", + "context", + "osType", + "operatingSystem", + "serverName", + "platformName", + "serverVersion", + "labels", + "error" + ], + "properties": { + "available": { "type": "boolean" }, + "context": { "type": ["string", "null"] }, + "osType": { "type": ["string", "null"] }, + "operatingSystem": { "type": ["string", "null"] }, + "serverName": { "type": ["string", "null"] }, + "platformName": { "type": ["string", "null"] }, + "serverVersion": { "type": ["string", "null"] }, + "labels": { "type": "array", "items": { "type": "string" } }, + "error": { "type": ["string", "null"] } + } + }, + "wslDockerSignal": { + "type": "object", + "additionalProperties": false, + "required": [ + "distro", + "dockerHost", + "available", + "socketPath", + "socketPresent", + "socketOwner", + "socketMode", + "systemdState", + "serviceState", + "context", + "osType", + "operatingSystem", + "serverName", + "platformName", + "serverVersion", + "labels", + "isDockerDesktop", + "error" + ], + "properties": { + "distro": { "type": ["string", "null"] }, + "dockerHost": { "type": ["string", "null"] }, + "available": { "type": "boolean" }, + "socketPath": { "type": ["string", "null"] }, + "socketPresent": { "type": "boolean" }, + "socketOwner": { "type": ["string", "null"] }, + "socketMode": { "type": ["string", "null"] }, + "systemdState": { "type": ["string", "null"] }, + "serviceState": { "type": ["string", "null"] }, + "context": { "type": ["string", "null"] }, + "osType": { "type": ["string", "null"] }, + "operatingSystem": { "type": ["string", "null"] }, + "serverName": { "type": ["string", "null"] }, + "platformName": { "type": ["string", "null"] }, + "serverVersion": { "type": ["string", "null"] }, + "labels": { "type": "array", "items": { "type": "string" } }, + "isDockerDesktop": { "type": "boolean" }, + "error": { "type": ["string", "null"] } + } + }, + "logTail": { + "type": "object", + "additionalProperties": false, + "required": ["path", "lineCount", "lines"], + "properties": { + "path": { "type": "string" }, + "lineCount": { "type": "integer", "minimum": 0 }, + "lines": { "type": "array", "items": { "type": "string" } } + } + } + } +} diff --git a/docs/schemas/operation-payload-authoring-finalization-v1.schema.json b/docs/schemas/operation-payload-authoring-finalization-v1.schema.json new file mode 100644 index 000000000..f4018dc2f --- /dev/null +++ b/docs/schemas/operation-payload-authoring-finalization-v1.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://labview-community-ci-cd.github.io/compare-vi-cli-action/schemas/operation-payload-authoring-finalization-v1.schema.json", + "title": "Operation payload authoring finalization", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "generatedAt", + "status", + "bundlePath", + "manifestPath", + "inspectionReportPath", + "inspectionMarkdownPath", + "manifestUpdated", + "beforeDeclaredExecutableState", + "observedExecutableState", + "afterDeclaredExecutableState", + "beforeCurrentState", + "afterCurrentState", + "checkedInOperationFilesBefore", + "checkedInOperationFilesAfter", + "promotionBlocked", + "blockingReasons", + "notes" + ], + "properties": { + "schema": { + "const": "comparevi/operation-payload-authoring-finalization@v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "blocked", + "succeeded" + ] + }, + "bundlePath": { + "type": "string", + "minLength": 1 + }, + "manifestPath": { + "type": "string", + "minLength": 1 + }, + "inspectionReportPath": { + "type": "string", + "minLength": 1 + }, + "inspectionMarkdownPath": { + "type": "string", + "minLength": 1 + }, + "manifestUpdated": { + "type": "boolean" + }, + "beforeDeclaredExecutableState": { + "type": "string", + "enum": [ + "source-only", + "runnable" + ] + }, + "observedExecutableState": { + "type": "string", + "enum": [ + "source-only", + "runnable" + ] + }, + "afterDeclaredExecutableState": { + "type": "string", + "enum": [ + "source-only", + "runnable" + ] + }, + "beforeCurrentState": { + "type": "string", + "minLength": 1 + }, + "afterCurrentState": { + "type": "string", + "minLength": 1 + }, + "checkedInOperationFilesBefore": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "checkedInOperationFilesAfter": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "promotionBlocked": { + "type": "boolean" + }, + "blockingReasons": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "notes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } +} diff --git a/docs/schemas/queue-refresh-receipt-v1.schema.json b/docs/schemas/queue-refresh-receipt-v1.schema.json index de3055023..a3ce2586a 100644 --- a/docs/schemas/queue-refresh-receipt-v1.schema.json +++ b/docs/schemas/queue-refresh-receipt-v1.schema.json @@ -6,6 +6,7 @@ "additionalProperties": false, "required": [ "schema", + "operation", "generatedAt", "repo", "pr", @@ -25,6 +26,9 @@ "schema": { "const": "priority/queue-refresh-receipt@v1" }, + "operation": { + "const": "dequeue-update-requeue" + }, "generatedAt": { "type": "string", "format": "date-time" diff --git a/docs/schemas/run-artifact-download-report-v1.schema.json b/docs/schemas/run-artifact-download-report-v1.schema.json index 74cc20e8a..a1f33eb20 100644 --- a/docs/schemas/run-artifact-download-report-v1.schema.json +++ b/docs/schemas/run-artifact-download-report-v1.schema.json @@ -12,6 +12,7 @@ "repository", "runId", "destinationRoot", + "destinationRootPolicy", "requestedArtifacts", "discovery", "downloads", @@ -44,6 +45,28 @@ "destinationRoot": { "type": "string" }, + "destinationRootPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "source", "baseRoot", "relativeRoot", "usesExternalRoot"], + "properties": { + "strategy": { + "enum": ["explicit", "environment", "policy-preferred-root", "repo-default"] + }, + "source": { + "type": "string" + }, + "baseRoot": { + "type": "string" + }, + "relativeRoot": { + "type": ["string", "null"] + }, + "usesExternalRoot": { + "type": "boolean" + } + } + }, "requestedArtifacts": { "type": "array", "items": { diff --git a/docs/schemas/runtime-delivery-task-packet-v1.schema.json b/docs/schemas/runtime-delivery-task-packet-v1.schema.json index 137710b9c..b5a9c3d18 100644 --- a/docs/schemas/runtime-delivery-task-packet-v1.schema.json +++ b/docs/schemas/runtime-delivery-task-packet-v1.schema.json @@ -37,6 +37,21 @@ "properties": { "workerSlotId": { "type": ["string", "null"] }, "workerProviderId": { "type": ["string", "null"] }, + "workerCheckoutRoot": { "type": ["string", "null"] }, + "workerCheckoutRootPolicy": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "strategy": { + "enum": ["explicit", "environment", "policy-preferred-root", "repo-default"] + }, + "source": { "type": "string" }, + "baseRoot": { "type": "string" }, + "relativeRoot": { "type": ["string", "null"] }, + "usesExternalRoot": { "type": "boolean" } + }, + "required": ["strategy", "source", "baseRoot", "relativeRoot", "usesExternalRoot"] + }, "workerCheckoutPath": { "type": ["string", "null"] } }, "allOf": [ diff --git a/docs/schemas/template-agent-verification-report-v1.schema.json b/docs/schemas/template-agent-verification-report-v1.schema.json index 93434ac28..cb9f5a286 100644 --- a/docs/schemas/template-agent-verification-report-v1.schema.json +++ b/docs/schemas/template-agent-verification-report-v1.schema.json @@ -4,7 +4,19 @@ "title": "Template Agent Verification Report v1", "type": "object", "additionalProperties": true, - "required": ["schema", "generatedAt", "repo", "summary", "iteration", "lane", "verification", "goals", "metrics", "blockers"], + "required": [ + "schema", + "generatedAt", + "repo", + "summary", + "iteration", + "lane", + "verification", + "provenance", + "goals", + "metrics", + "blockers" + ], "properties": { "schema": { "const": "priority/template-agent-verification-report@v1" }, "generatedAt": { "type": "string", "format": "date-time" }, @@ -60,6 +72,44 @@ "runUrl": { "type": ["string", "null"] } } }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["templateDependency", "execution"], + "properties": { + "templateDependency": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "version", "ref", "cookiecutterVersion"], + "properties": { + "repository": { "type": ["string", "null"] }, + "version": { "type": ["string", "null"] }, + "ref": { "type": ["string", "null"] }, + "cookiecutterVersion": { "type": ["string", "null"] } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "executionPlane", + "containerImage", + "generatedConsumerWorkspaceRoot", + "laneId", + "agentId", + "fundingWindowId" + ], + "properties": { + "executionPlane": { "type": ["string", "null"] }, + "containerImage": { "type": ["string", "null"] }, + "generatedConsumerWorkspaceRoot": { "type": ["string", "null"] }, + "laneId": { "type": ["string", "null"] }, + "agentId": { "type": ["string", "null"] }, + "fundingWindowId": { "type": ["string", "null"] } + } + } + } + }, "goals": { "type": "object", "additionalProperties": true, diff --git a/docs/schemas/template-dependency-v1.schema.json b/docs/schemas/template-dependency-v1.schema.json new file mode 100644 index 000000000..eaa60306c --- /dev/null +++ b/docs/schemas/template-dependency-v1.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://labview-community-ci-cd.github.io/compare-vi-cli-action/schemas/template-dependency-v1.schema.json", + "title": "Template Dependency Policy", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schemaVersion", + "templateRepositorySlug", + "templateRepositoryUrl", + "templateReleaseRef", + "templateDirectory", + "cookiecutterVersion", + "container", + "workspaceRoots", + "rendering" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema": { + "const": "priority/template-dependency@v1" + }, + "schemaVersion": { + "type": "string", + "const": "1.0.0" + }, + "templateRepositorySlug": { + "type": "string", + "const": "LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate" + }, + "templateRepositoryUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://github\\.com/LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate\\.git$" + }, + "templateReleaseRef": { + "type": "string", + "const": "v0.1.0" + }, + "templateDirectory": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "cookiecutterVersion": { + "type": "string", + "const": "2.7.1" + }, + "container": { + "type": "object", + "additionalProperties": false, + "required": [ + "runtime", + "image", + "executionPlane" + ], + "properties": { + "runtime": { + "type": "string", + "const": "docker" + }, + "image": { + "type": "string", + "minLength": 1 + }, + "executionPlane": { + "type": "string", + "const": "linux-tools-image" + } + } + }, + "workspaceRoots": { + "type": "object", + "additionalProperties": false, + "required": [ + "win32", + "posix" + ], + "properties": { + "win32": { + "type": "string", + "pattern": "^[A-Za-z]:\\\\.+" + }, + "posix": { + "type": "string", + "pattern": "^/.+" + } + } + }, + "rendering": { + "type": "object", + "additionalProperties": false, + "required": [ + "checkout", + "defaultContextPath", + "deterministicInput", + "noInput", + "uniqueContainerNamePrefix" + ], + "properties": { + "checkout": { + "type": "string", + "const": "v0.1.0" + }, + "defaultContextPath": { + "type": "string", + "const": "tests/fixtures/cookiecutter/template-context.json" + }, + "deterministicInput": { + "type": "boolean", + "const": true + }, + "noInput": { + "type": "boolean", + "const": true + }, + "uniqueContainerNamePrefix": { + "type": "string", + "const": "comparevi-template" + } + } + } + } +} diff --git a/docs/schemas/template-pivot-gate-policy-v1.schema.json b/docs/schemas/template-pivot-gate-policy-v1.schema.json index e04963e66..cc005d5aa 100644 --- a/docs/schemas/template-pivot-gate-policy-v1.schema.json +++ b/docs/schemas/template-pivot-gate-policy-v1.schema.json @@ -13,6 +13,7 @@ "queueEmpty", "releaseCandidate", "handoffEntrypoint", + "templateDependency", "decision", "artifacts" ], @@ -105,6 +106,34 @@ } } }, + "templateDependency": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "version", + "ref", + "cookiecutterVersion" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "cookiecutterVersion": { + "type": "string", + "minLength": 1 + } + } + }, "decision": { "type": "object", "additionalProperties": false, diff --git a/docs/schemas/template-pivot-gate-report-v1.schema.json b/docs/schemas/template-pivot-gate-report-v1.schema.json index b02da7d4e..19add5ddf 100644 --- a/docs/schemas/template-pivot-gate-report-v1.schema.json +++ b/docs/schemas/template-pivot-gate-report-v1.schema.json @@ -40,6 +40,7 @@ "futureAgentOnly", "operatorSteeringAllowed", "requirePreciseSessionFeedback", + "templateDependency", "releaseCandidateVersionPattern", "releaseCandidateVersionPatternDescription" ], @@ -61,6 +62,34 @@ "requirePreciseSessionFeedback": { "type": "boolean" }, + "templateDependency": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "version", + "ref", + "cookiecutterVersion" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "cookiecutterVersion": { + "type": "string", + "minLength": 1 + } + } + }, "releaseCandidateVersionPattern": { "type": "string", "minLength": 1 @@ -294,6 +323,8 @@ "verificationStatus", "targetRepository", "consumerRailBranch", + "templateDependency", + "execution", "ready" ], "properties": { @@ -319,6 +350,70 @@ "consumerRailBranch": { "type": ["string", "null"] }, + "templateDependency": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "version", + "ref", + "cookiecutterVersion", + "matchesPolicy" + ], + "properties": { + "repository": { + "type": ["string", "null"] + }, + "version": { + "type": ["string", "null"] + }, + "ref": { + "type": ["string", "null"] + }, + "cookiecutterVersion": { + "type": ["string", "null"] + }, + "matchesPolicy": { + "type": "boolean" + } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "executionPlane", + "containerImage", + "generatedConsumerWorkspaceRoot", + "laneId", + "agentId", + "fundingWindowId", + "complete" + ], + "properties": { + "executionPlane": { + "type": ["string", "null"] + }, + "containerImage": { + "type": ["string", "null"] + }, + "generatedConsumerWorkspaceRoot": { + "type": ["string", "null"] + }, + "laneId": { + "type": ["string", "null"] + }, + "agentId": { + "type": ["string", "null"] + }, + "fundingWindowId": { + "type": ["string", "null"] + }, + "complete": { + "type": "boolean" + } + } + }, "ready": { "type": "boolean" } diff --git a/fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml/README.md b/fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml/README.md index 54b352f0e..8ecb459bd 100644 --- a/fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml/README.md +++ b/fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml/README.md @@ -56,6 +56,10 @@ binary artifacts, not placeholder text files. Machine-readable provenance for this bundle lives in `payload-provenance.json`. Use `tools/Inspect-OperationPayloadSourceBundle.ps1` to project the current executable-state inspection receipt for this bundle. +Use `tools/Finalize-OperationPayloadSourceBundle.ps1 -BundlePath fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml` +after native authoring copies repo-owned LabVIEW binary files into this bundle; +that helper updates `checkedInOperationFiles`, flips `executableState` to +`runnable`, and keeps public proof as the remaining blocker. Use `tools/New-PrintToSingleFileHtmlAuthoringWorkspace.ps1` or `node tools/npm/run-script.mjs history:custom-operation:scaffold:print-single-file` to create the disposable authoring workspace from the preferred installed diff --git a/package.json b/package.json index aed3df306..33f0bff46 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "history:custom-operation:scaffold": "pwsh -NoLogo -NoProfile -File tools/New-LabVIEWCLICustomOperationWorkspace.ps1", "history:custom-operation:scaffold:print-single-file": "pwsh -NoLogo -NoProfile -File tools/New-PrintToSingleFileHtmlAuthoringWorkspace.ps1", "history:custom-operation:authoring-packet:print-single-file": "pwsh -NoLogo -NoProfile -File tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1", + "history:custom-operation:finalize:print-single-file": "pwsh -NoLogo -NoProfile -File tools/Finalize-OperationPayloadSourceBundle.ps1 -BundlePath fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml", "history:corpus:normalize": "pwsh -NoLogo -NoProfile -File tools/Normalize-OfflineRealHistoryCorpus.ps1", "history:diagnostics:show": "pwsh -NoLogo -NoProfile -File tools/Show-DockerFastLoopDiagnostics.ps1", "history:local:build-dev-image": "pwsh -NoLogo -NoProfile -File tools/Build-VIHistoryDevImage.ps1", @@ -91,6 +92,7 @@ "priority:lane:concurrency:plan": "node tools/priority/concurrent-lane-plan.mjs", "priority:lane:concurrency:apply": "node tools/priority/concurrent-lane-apply.mjs", "priority:lane:concurrency:status": "node tools/priority/concurrent-lane-status.mjs", + "priority:jarvis:status": "node tools/priority/jarvis-session-observer.mjs", "priority:human-go-no-go:latest": "node tools/priority/human-go-no-go-latest.mjs", "priority:queue:supervisor": "node tools/priority/queue-supervisor.mjs", "priority:queue:readiness": "node tools/priority/queue-readiness.mjs", @@ -102,6 +104,7 @@ "priority:validation:proof": "node tools/npm/run-script.mjs build && node tools/priority/validation-approval-proof.mjs", "priority:merge-sync": "node tools/priority/merge-sync-pr.mjs", "priority:queue:refresh": "node tools/priority/queue-refresh-pr.mjs", + "priority:queue:update": "node tools/priority/queue-refresh-pr.mjs", "priority:event:ingest": "node tools/priority/event-ingest.mjs", "priority:decision:ledger": "node tools/priority/decision-ledger.mjs", "priority:commit-integrity": "node tools/priority/commit-integrity.mjs", @@ -134,6 +137,8 @@ "priority:onboard:success": "node tools/priority/downstream-onboarding-success.mjs", "priority:pivot:template": "node tools/priority/template-pivot-gate.mjs", "priority:template:agent:verify": "node tools/priority/template-agent-verification-report.mjs", + "priority:template:render:container": "node tools/priority/template-cookiecutter-container.mjs", + "priority:template:verify": "node tools/priority/template-agent-verification-report.mjs", "priority:promote:downstream:manifest": "node tools/priority/downstream-promotion-manifest.mjs", "priority:promote:downstream:scorecard": "node tools/priority/downstream-promotion-scorecard.mjs", "priority:diagnostics:public-linux": "node tools/priority/public-linux-diagnostics-harness.mjs", @@ -177,6 +182,9 @@ "priority:delivery:broker": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/runtime-turn-broker.ts --fallback-dist dist/tools/priority/runtime-turn-broker.js", "priority:delivery:memory": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-memory.ts --fallback-dist dist/tools/priority/delivery-memory.js", "priority:delivery:host:signal": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js", + "priority:delivery:host:collect": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js -- --mode collect", + "priority:delivery:host:isolate": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js -- --mode isolate", + "priority:delivery:host:restore": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js -- --mode restore", "priority:delivery:agent:ensure": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-agent.ts --fallback-dist dist/tools/priority/delivery-agent.js -- ensure --sleep-mode", "priority:delivery:agent:status": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-agent.ts --fallback-dist dist/tools/priority/delivery-agent.js -- status", "priority:delivery:agent:stop": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-agent.ts --fallback-dist dist/tools/priority/delivery-agent.js -- stop", @@ -187,9 +195,9 @@ "priority:unattended:sleep:status": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-agent.ts --fallback-dist dist/tools/priority/delivery-agent.js -- status", "priority:unattended:sleep:stop": "node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-agent.ts --fallback-dist dist/tools/priority/delivery-agent.js -- stop", "runtime-harness:test": "node --test packages/runtime-harness/test/*.mjs", - "priority:sync": "node tools/priority/sync-standing-priority.mjs", - "priority:sync:strict": "node tools/priority/sync-standing-priority.mjs --fail-on-missing", - "priority:sync:lane": "node tools/priority/sync-standing-priority.mjs --fail-on-missing --fail-on-multiple --auto-select-next", + "priority:sync": "node tools/priority/run-sync-standing-priority.mjs", + "priority:sync:strict": "node tools/priority/run-sync-standing-priority.mjs --fail-on-missing", + "priority:sync:lane": "node tools/priority/run-sync-standing-priority.mjs --fail-on-missing --fail-on-multiple --auto-select-next", "priority:sync:docker": "pwsh -NoLogo -NoProfile -File tools/Run-NonLVChecksInDocker.ps1 -ToolsImageTag comparevi-tools:local -UseToolsImage -PrioritySync -SkipActionlint -SkipMarkdown -SkipDocs -SkipWorkflow -SkipDotnetCliBuild", "priority:test": "node --test tools/priority/__tests__/*.mjs", "priority:workspace-health": "node tools/priority/check-workspace-health.mjs", diff --git a/tests/Finalize-OperationPayloadSourceBundle.Tests.ps1 b/tests/Finalize-OperationPayloadSourceBundle.Tests.ps1 new file mode 100644 index 000000000..03ec4ba0e --- /dev/null +++ b/tests/Finalize-OperationPayloadSourceBundle.Tests.ps1 @@ -0,0 +1,148 @@ +#Requires -Version 7.0 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Describe 'Finalize-OperationPayloadSourceBundle.ps1' -Tag 'Unit' { + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + $script:FinalizePath = Join-Path $script:RepoRoot 'tools' 'Finalize-OperationPayloadSourceBundle.ps1' + if (-not (Test-Path -LiteralPath $script:FinalizePath -PathType Leaf)) { + throw "Finalize-OperationPayloadSourceBundle.ps1 not found at $script:FinalizePath" + } + + function Set-LabVIEWBinaryFixture { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][ValidateSet('LVIN', 'LVCC')][string]$Signature, + [string]$Payload = '' + ) + + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory -PathType Container)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $payloadBytes = if ([string]::IsNullOrWhiteSpace($Payload)) { + [byte[]]@() + } else { + [System.Text.Encoding]::UTF8.GetBytes($Payload) + } + + $minimumLength = 12 + $payloadBytes.Length + $bytes = New-Object byte[] ([Math]::Max(16, $minimumLength)) + [System.Text.Encoding]::ASCII.GetBytes($Signature).CopyTo($bytes, 8) + if ($payloadBytes.Length -gt 0) { + [Array]::Copy($payloadBytes, 0, $bytes, 12, $payloadBytes.Length) + } + + [System.IO.File]::WriteAllBytes($Path, $bytes) + } + } + + It 'updates the bundle manifest when runnable LabVIEW binary files are present' { + $bundleRoot = Join-Path $TestDrive 'synthetic-bundle' + New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null + Set-LabVIEWBinaryFixture -Path (Join-Path $bundleRoot 'GetHelp.vi') -Signature 'LVIN' -Payload 'help' + Set-LabVIEWBinaryFixture -Path (Join-Path $bundleRoot 'RunOperation.vi') -Signature 'LVIN' -Payload 'run' + @' +{ + "schema": "comparevi/operation-payload-source-bundle@v1", + "name": "SyntheticPrintPayload", + "payloadMode": "additional-operation-directory", + "sourceRepositorySlug": "LabVIEW-Community-CI-CD/compare-vi-cli-action", + "sourceRepositoryUrl": "https://github.com/LabVIEW-Community-CI-CD/compare-vi-cli-action", + "sourceLicenseSpdx": "BSD-3-Clause", + "ownership": "repo-owned", + "implementationBasis": "Synthetic", + "intendedCertificationSurface": "print-single-file", + "intendedChangeKinds": ["added"], + "expectedOperationFiles": ["GetHelp.vi", "RunOperation.vi"], + "checkedInOperationFiles": [], + "executableState": "source-only", + "currentState": "licensed-source-bundle", + "promotionTarget": "accepted", + "promotionBlocked": true, + "blockingReasons": [ + "Runnable LabVIEW operation files are not checked in yet.", + "No public workflow run has proven this repo-owned payload on an added or deleted VI." + ], + "notes": ["Synthetic bundle for tests."] +} +'@ | Set-Content -LiteralPath (Join-Path $bundleRoot 'payload-provenance.json') -Encoding utf8 + + $receiptPath = Join-Path $TestDrive 'finalization.json' + $output = & pwsh -NoLogo -NoProfile -File $script:FinalizePath ` + -BundlePath $bundleRoot ` + -ReceiptPath $receiptPath ` + -InspectionReportPath (Join-Path $TestDrive 'inspection.json') ` + -InspectionMarkdownPath (Join-Path $TestDrive 'inspection.md') ` + -SkipSchemaValidation *>&1 + $LASTEXITCODE | Should -Be 0 -Because ($output -join "`n") + + $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json -Depth 20 + $receipt.status | Should -Be 'succeeded' + $receipt.manifestUpdated | Should -BeTrue + $receipt.beforeDeclaredExecutableState | Should -Be 'source-only' + $receipt.afterDeclaredExecutableState | Should -Be 'runnable' + $receipt.afterCurrentState | Should -Be 'authoring-complete' + @($receipt.checkedInOperationFilesAfter) | Should -Be @('GetHelp.vi', 'RunOperation.vi') + + $manifest = Get-Content -LiteralPath (Join-Path $bundleRoot 'payload-provenance.json') -Raw | ConvertFrom-Json -Depth 20 + $manifest.executableState | Should -Be 'runnable' + $manifest.currentState | Should -Be 'authoring-complete' + @($manifest.checkedInOperationFiles) | Should -Be @('GetHelp.vi', 'RunOperation.vi') + @($manifest.blockingReasons) | Should -Not -Contain 'Runnable LabVIEW operation files are not checked in yet.' + @($manifest.blockingReasons) | Should -Contain 'No public workflow run has proven this repo-owned payload on an added or deleted VI.' + } + + It 'fails closed and leaves the manifest unchanged when runnable files are still missing' { + $bundleRoot = Join-Path $TestDrive 'blocked-bundle' + New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null + @' +{ + "schema": "comparevi/operation-payload-source-bundle@v1", + "name": "SyntheticPrintPayload", + "payloadMode": "additional-operation-directory", + "sourceRepositorySlug": "LabVIEW-Community-CI-CD/compare-vi-cli-action", + "sourceRepositoryUrl": "https://github.com/LabVIEW-Community-CI-CD/compare-vi-cli-action", + "sourceLicenseSpdx": "BSD-3-Clause", + "ownership": "repo-owned", + "implementationBasis": "Synthetic", + "intendedCertificationSurface": "print-single-file", + "intendedChangeKinds": ["added"], + "expectedOperationFiles": ["GetHelp.vi", "RunOperation.vi"], + "checkedInOperationFiles": [], + "executableState": "source-only", + "currentState": "licensed-source-bundle", + "promotionTarget": "accepted", + "promotionBlocked": true, + "blockingReasons": [ + "Runnable LabVIEW operation files are not checked in yet.", + "No public workflow run has proven this repo-owned payload on an added or deleted VI." + ], + "notes": ["Synthetic bundle for tests."] +} +'@ | Set-Content -LiteralPath (Join-Path $bundleRoot 'payload-provenance.json') -Encoding utf8 + + $receiptPath = Join-Path $TestDrive 'blocked-finalization.json' + $output = & pwsh -NoLogo -NoProfile -File $script:FinalizePath ` + -BundlePath $bundleRoot ` + -ReceiptPath $receiptPath ` + -InspectionReportPath (Join-Path $TestDrive 'blocked-inspection.json') ` + -InspectionMarkdownPath (Join-Path $TestDrive 'blocked-inspection.md') ` + -SkipSchemaValidation *>&1 + $LASTEXITCODE | Should -Be 0 -Because ($output -join "`n") + + $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json -Depth 20 + $receipt.status | Should -Be 'blocked' + $receipt.manifestUpdated | Should -BeFalse + $receipt.beforeDeclaredExecutableState | Should -Be 'source-only' + $receipt.afterDeclaredExecutableState | Should -Be 'source-only' + + $manifest = Get-Content -LiteralPath (Join-Path $bundleRoot 'payload-provenance.json') -Raw | ConvertFrom-Json -Depth 20 + $manifest.executableState | Should -Be 'source-only' + $manifest.currentState | Should -Be 'licensed-source-bundle' + @($manifest.checkedInOperationFiles) | Should -Be @() + } +} diff --git a/tests/Invoke-HeadlessSampleVICorpusEvaluation.Tests.ps1 b/tests/Invoke-HeadlessSampleVICorpusEvaluation.Tests.ps1 index 1948f452b..0bd0cd674 100644 --- a/tests/Invoke-HeadlessSampleVICorpusEvaluation.Tests.ps1 +++ b/tests/Invoke-HeadlessSampleVICorpusEvaluation.Tests.ps1 @@ -61,8 +61,8 @@ Describe 'Invoke-HeadlessSampleVICorpusEvaluation.ps1' -Tag 'Unit' { $licensedCandidate.checks.operationPayloadSourceValid | Should -BeTrue $licensedCandidate.checks.operationPayloadLicenseDeclared | Should -BeTrue $licensedCandidate.checks.operationPayloadPromotable | Should -BeFalse - @($licensedCandidate.publicEvidence | Where-Object { [string]$_.kind -eq 'pull-request' -and [string]$_.url -eq 'https://github.com/LabVIEW-Community-CI-CD/labview-icon-editor-demo/pull/29' } | Measure-Object).Count | Should -Be 1 - @($licensedCandidate.publicEvidence | Where-Object { [string]$_.kind -eq 'workflow-run' -and [string]$_.url -eq 'https://github.com/LabVIEW-Community-CI-CD/labview-icon-editor-demo/actions/runs/23225926010' } | Measure-Object).Count | Should -Be 1 + $licensedCandidate.publicEvidenceCount | Should -Be 2 + $licensedCandidate.successfulWorkflowEvidenceCount | Should -Be 1 (($licensedCandidate.notes | ForEach-Object { [string]$_ }) -join [Environment]::NewLine) | Should -Match 'Custom operation payload is not promotable for accepted certification use' $markdown = Get-Content -LiteralPath $markdownPath -Raw diff --git a/tests/Invoke-HeadlessSampleVICorpusPrintProof.Tests.ps1 b/tests/Invoke-HeadlessSampleVICorpusPrintProof.Tests.ps1 index c7bae0814..aafa1ba76 100644 --- a/tests/Invoke-HeadlessSampleVICorpusPrintProof.Tests.ps1 +++ b/tests/Invoke-HeadlessSampleVICorpusPrintProof.Tests.ps1 @@ -84,11 +84,21 @@ $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $ReportPath -Encodi '# Inspection summary' | Set-Content -LiteralPath $MarkdownPath -Encoding utf8 '@ | Set-Content -LiteralPath $inspectionStub -Encoding utf8 + $finalizationContractPath = Join-Path $TestDrive 'operation-payload-authoring-finalization.json' + @' +{ + "schema": "operation-payload-authoring-finalization@v1", + "status": "succeeded", + "notes": ["Synthetic finalization contract for proof-lane tests."] +} +'@ | Set-Content -LiteralPath $finalizationContractPath -Encoding utf8 + $resultsRoot = Join-Path $TestDrive 'results' $output = & pwsh -NoLogo -NoProfile -File $script:ProofScript ` -CatalogPath $catalogPath ` -TargetId 'print-target' ` -PayloadBundlePath 'fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml' ` + -PayloadFinalizationContractPath $finalizationContractPath ` -ResultsRoot $resultsRoot ` -InspectionScriptPath $inspectionStub ` -SkipSchemaValidation *>&1 @@ -101,6 +111,9 @@ $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $ReportPath -Encodi $report.blockingReason | Should -Be 'payload-source-only' $report.executionAttempted | Should -BeFalse $report.payloadObservedExecutableState | Should -Be 'source-only' + $report.payloadFinalizationContractAvailable | Should -BeTrue + $report.payloadFinalizationContractPath | Should -Match 'operation-payload-authoring-finalization\.json$' + (($report.notes | ForEach-Object { [string]$_ }) -join [Environment]::NewLine) | Should -Match 'Finalization contract reference available' } It 'executes the Linux custom-operation runner once the payload bundle is runnable' { @@ -237,6 +250,8 @@ $renderedOutputPath = Join-Path $resultsRootResolved 'print-output.html' $report.blockingReason | Should -BeNullOrEmpty $report.executionAttempted | Should -BeTrue $report.payloadObservedExecutableState | Should -Be 'runnable' + $report.payloadFinalizationContractAvailable | Should -BeTrue + $report.payloadFinalizationContractPath | Should -Match 'operation-payload-authoring-finalization-v1\.schema\.json$' $report.executionStatus | Should -Be 'succeeded' $report.executionExitCode | Should -Be 0 $report.executionCapturePath | Should -Match 'ni-linux-custom-operation-capture\.json$' diff --git a/tests/New-PrintToSingleFileHtmlAuthoringPacket.Tests.ps1 b/tests/New-PrintToSingleFileHtmlAuthoringPacket.Tests.ps1 index 3470e5a99..f20bfb4a0 100644 --- a/tests/New-PrintToSingleFileHtmlAuthoringPacket.Tests.ps1 +++ b/tests/New-PrintToSingleFileHtmlAuthoringPacket.Tests.ps1 @@ -118,6 +118,7 @@ $receipt | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ReceiptPath -Enc (Get-Content -LiteralPath $checklistPath -Raw) | Should -Match '#1617' (Get-Content -LiteralPath $checklistPath -Raw) | Should -Match '#1726' (Get-Content -LiteralPath $checklistPath -Raw) | Should -Match '#1467' + (Get-Content -LiteralPath $checklistPath -Raw) | Should -Match 'Finalize-OperationPayloadSourceBundle\.ps1' (Get-Content -LiteralPath $launchScriptPath -Raw) | Should -Match 'Start-Process -FilePath \$labviewPath' } } diff --git a/tests/Priority.BootstrapDecision.Tests.ps1 b/tests/Priority.BootstrapDecision.Tests.ps1 index b7811d1f5..18694543c 100644 --- a/tests/Priority.BootstrapDecision.Tests.ps1 +++ b/tests/Priority.BootstrapDecision.Tests.ps1 @@ -48,7 +48,7 @@ Describe 'Bootstrap develop checkout decision' -Tag 'Unit' { Get-BootstrapHelperRootDecision ` -CurrentBranch 'issue/1276-bootstrap-helper-root' ` -CurrentRepoRoot 'C:\repo\issue-1276' ` - -DevelopWorktreeRoots @('C:\repo\develop-root') + -DevelopWorktreeRoots @([pscustomobject]@{ Root = 'C:\repo\develop-root'; IsClean = $true }) } $decision.Action | Should -Be 'delegate-develop-worktree' @@ -60,7 +60,7 @@ Describe 'Bootstrap develop checkout decision' -Tag 'Unit' { Get-BootstrapHelperRootDecision ` -CurrentBranch 'develop' ` -CurrentRepoRoot 'C:\repo\develop-root' ` - -DevelopWorktreeRoots @('C:\repo\develop-root') + -DevelopWorktreeRoots @([pscustomobject]@{ Root = 'C:\repo\develop-root'; IsClean = $true }) } $decision.Action | Should -Be 'use-current-root' @@ -78,4 +78,51 @@ Describe 'Bootstrap develop checkout decision' -Tag 'Unit' { $decision.Action | Should -Be 'use-current-root' $decision.HelperRoot | Should -Be 'C:\repo\issue-1276' } + + It 'skips dirty develop helpers when a clean helper is available' { + $decision = & $script:DecisionModule { + Get-BootstrapHelperRootDecision ` + -CurrentBranch 'issue/1744-router-refresh' ` + -CurrentRepoRoot 'C:\repo\issue-1744' ` + -DevelopWorktreeRoots @( + [pscustomobject]@{ Root = 'C:\repo\develop-dirty'; IsClean = $false }, + [pscustomobject]@{ Root = 'C:\repo\develop-clean'; IsClean = $true } + ) + } + + $decision.Action | Should -Be 'delegate-develop-worktree' + $decision.HelperRoot | Should -Be 'C:\repo\develop-clean' + $decision.Message | Should -Match 'clean develop helper checkout' + } + + It 'falls back to the caller checkout when only dirty develop helpers exist' { + $decision = & $script:DecisionModule { + Get-BootstrapHelperRootDecision ` + -CurrentBranch 'issue/1744-router-refresh' ` + -CurrentRepoRoot 'C:\repo\issue-1744' ` + -DevelopWorktreeRoots @( + [pscustomobject]@{ Root = 'C:\repo\develop-dirty'; IsClean = $false } + ) + } + + $decision.Action | Should -Be 'use-current-root' + $decision.HelperRoot | Should -Be 'C:\repo\issue-1744' + $decision.Message | Should -Match 'only dirty develop helper' + } + + It 'delegates away from a dirty develop root when a clean helper exists' { + $decision = & $script:DecisionModule { + Get-BootstrapHelperRootDecision ` + -CurrentBranch 'develop' ` + -CurrentRepoRoot 'C:\repo\develop-dirty' ` + -DevelopWorktreeRoots @( + [pscustomobject]@{ Root = 'C:\repo\develop-dirty'; IsClean = $false }, + [pscustomobject]@{ Root = 'C:\repo\develop-clean'; IsClean = $true } + ) + } + + $decision.Action | Should -Be 'delegate-clean-develop-worktree' + $decision.HelperRoot | Should -Be 'C:\repo\develop-clean' + $decision.Message | Should -Match 'dirty; using clean develop helper' + } } diff --git a/tests/fixtures/cookiecutter/template-context.json b/tests/fixtures/cookiecutter/template-context.json new file mode 100644 index 000000000..e6ced105e --- /dev/null +++ b/tests/fixtures/cookiecutter/template-context.json @@ -0,0 +1,8 @@ +{ + "project_name": "CompareVI Template Consumer", + "repo_slug": "comparevi-template-consumer", + "github_owner": "LabVIEW-Community-CI-CD", + "default_branch": "develop", + "license_holder": "LabVIEW Community CI/CD", + "copyright_year": "2026" +} diff --git a/tests/results/_agent/promotion/template-agent-verification-report.json b/tests/results/_agent/promotion/template-agent-verification-report.json index 665246f7f..f6e561dd7 100644 --- a/tests/results/_agent/promotion/template-agent-verification-report.json +++ b/tests/results/_agent/promotion/template-agent-verification-report.json @@ -27,6 +27,22 @@ "durationSeconds": null, "runUrl": null }, + "provenance": { + "templateDependency": { + "repository": "LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate", + "version": null, + "ref": null, + "cookiecutterVersion": null + }, + "execution": { + "executionPlane": null, + "containerImage": null, + "generatedConsumerWorkspaceRoot": null, + "laneId": null, + "agentId": null, + "fundingWindowId": null + } + }, "goals": { "maxVerificationLagIterations": 1, "maxHostedDurationMinutes": 30, diff --git a/tools/Finalize-OperationPayloadSourceBundle.ps1 b/tools/Finalize-OperationPayloadSourceBundle.ps1 new file mode 100644 index 000000000..2e1762ada --- /dev/null +++ b/tools/Finalize-OperationPayloadSourceBundle.ps1 @@ -0,0 +1,240 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [string]$BundlePath = 'fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml', + [string]$InspectionReportPath = '', + [string]$InspectionMarkdownPath = '', + [string]$ReceiptPath = '', + [switch]$SkipSchemaValidation, + [switch]$PassThru +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-RepoRoot { + return [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +} + +function Resolve-AbsolutePath { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][string]$PathValue + ) + + if ([System.IO.Path]::IsPathRooted($PathValue)) { + return [System.IO.Path]::GetFullPath($PathValue) + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue)) +} + +function Ensure-Directory { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + } + + return (Resolve-Path -LiteralPath $Path).Path +} + +function Convert-ToRepoRelativePath { + param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$PathValue + ) + + $resolved = [System.IO.Path]::GetFullPath($PathValue) + $relative = [System.IO.Path]::GetRelativePath($RepoRoot, $resolved) + if ($relative -eq '.') { + return '.' + } + + if ($relative -eq '..' -or + $relative.StartsWith('..' + [System.IO.Path]::DirectorySeparatorChar) -or + $relative.StartsWith('..' + [System.IO.Path]::AltDirectorySeparatorChar)) { + return ($resolved -replace '\\', '/') + } + + return ($relative -replace '\\', '/') +} + +function Invoke-SchemaValidation { + param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$SchemaPath, + [Parameter(Mandatory)][string]$DataPath + ) + + $runner = Join-Path $RepoRoot 'tools' 'npm' 'run-script.mjs' + if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "Schema validation runner not found at '$runner'." + } + + $output = & node $runner 'schema:validate' '--' '--schema' $SchemaPath '--data' $DataPath 2>&1 + if ($LASTEXITCODE -ne 0) { + $message = ($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine + throw "Schema validation failed for '$DataPath': $message" + } +} + +function Get-FilteredBlockingReasons { + param([Parameter(Mandatory)][object[]]$Reasons) + + $filtered = New-Object System.Collections.Generic.List[string] + foreach ($reason in @($Reasons | ForEach-Object { [string]$_ })) { + if ([string]::IsNullOrWhiteSpace($reason)) { + continue + } + + if ($reason -match 'Runnable LabVIEW operation files are not checked in yet\.') { + continue + } + + $filtered.Add($reason) | Out-Null + } + + return @($filtered.ToArray()) +} + +$repoRoot = Resolve-RepoRoot +$bundleResolved = Resolve-AbsolutePath -BasePath $repoRoot -PathValue $BundlePath +if (-not (Test-Path -LiteralPath $bundleResolved -PathType Container)) { + throw "Operation payload bundle directory was not found at '$bundleResolved'." +} + +$manifestPath = Join-Path $bundleResolved 'payload-provenance.json' +if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Operation payload manifest was not found at '$manifestPath'." +} + +$resultsRoot = Ensure-Directory -Path (Join-Path $repoRoot 'tests' 'results' '_agent' 'operation-payload-bundles' ([System.IO.Path]::GetFileName($bundleResolved))) +$inspectionReportResolved = if ([string]::IsNullOrWhiteSpace($InspectionReportPath)) { + Join-Path $resultsRoot 'operation-payload-source-bundle-inspection.json' +} else { + Resolve-AbsolutePath -BasePath $repoRoot -PathValue $InspectionReportPath +} +$inspectionMarkdownResolved = if ([string]::IsNullOrWhiteSpace($InspectionMarkdownPath)) { + Join-Path $resultsRoot 'operation-payload-source-bundle-inspection.md' +} else { + Resolve-AbsolutePath -BasePath $repoRoot -PathValue $InspectionMarkdownPath +} +$receiptResolved = if ([string]::IsNullOrWhiteSpace($ReceiptPath)) { + Join-Path $resultsRoot 'operation-payload-authoring-finalization.json' +} else { + Resolve-AbsolutePath -BasePath $repoRoot -PathValue $ReceiptPath +} + +Ensure-Directory -Path (Split-Path -Parent $inspectionReportResolved) | Out-Null +Ensure-Directory -Path (Split-Path -Parent $inspectionMarkdownResolved) | Out-Null +Ensure-Directory -Path (Split-Path -Parent $receiptResolved) | Out-Null + +$inspectionScriptPath = Join-Path $repoRoot 'tools' 'Inspect-OperationPayloadSourceBundle.ps1' +if (-not (Test-Path -LiteralPath $inspectionScriptPath -PathType Leaf)) { + throw "Inspect-OperationPayloadSourceBundle.ps1 not found at '$inspectionScriptPath'." +} + +$inspectionInvocation = @( + '-NoLogo', + '-NoProfile', + '-File', $inspectionScriptPath, + '-BundlePath', $bundleResolved, + '-ReportPath', $inspectionReportResolved, + '-MarkdownPath', $inspectionMarkdownResolved +) +if ($SkipSchemaValidation.IsPresent) { + $inspectionInvocation += '-SkipSchemaValidation' +} + +$inspectionOutput = & pwsh @inspectionInvocation 2>&1 +if ($LASTEXITCODE -ne 0) { + $message = ($inspectionOutput | ForEach-Object { [string]$_ }) -join [Environment]::NewLine + throw "Failed to inspect operation payload bundle: $message" +} + +if (-not (Test-Path -LiteralPath $inspectionReportResolved -PathType Leaf)) { + throw "Operation payload inspection report was not written to '$inspectionReportResolved'." +} + +$inspection = Get-Content -LiteralPath $inspectionReportResolved -Raw | ConvertFrom-Json -Depth 20 +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -Depth 20 +$beforeDeclaredExecutableState = [string]$manifest.executableState +$beforeCurrentState = [string]$manifest.currentState +$beforeCheckedInOperationFiles = @($manifest.checkedInOperationFiles | ForEach-Object { [string]$_ }) +$manifestUpdated = $false +$notes = New-Object System.Collections.Generic.List[string] +$status = 'blocked' + +if ([string]$inspection.observedExecutableState -eq 'runnable') { + $manifest.checkedInOperationFiles = @($inspection.checkedInOperationFiles | ForEach-Object { [string]$_ }) + $manifest.executableState = 'runnable' + $manifest.currentState = 'authoring-complete' + $manifest.promotionBlocked = $true + + $updatedBlockingReasons = @(Get-FilteredBlockingReasons -Reasons @($manifest.blockingReasons)) + if (-not ($updatedBlockingReasons | Where-Object { $_ -match 'No public workflow run has proven this repo-owned payload on an added or deleted VI\.' })) { + $updatedBlockingReasons += 'No public workflow run has proven this repo-owned payload on an added or deleted VI.' + } + $manifest.blockingReasons = $updatedBlockingReasons + + $updatedNotes = New-Object System.Collections.Generic.List[string] + foreach ($note in @($manifest.notes | ForEach-Object { [string]$_ })) { + if (-not [string]::IsNullOrWhiteSpace($note)) { + $updatedNotes.Add($note) | Out-Null + } + } + if (-not ($updatedNotes | Where-Object { $_ -match 'Runnable repo-owned LabVIEW operation files are now checked in; public proof is still required before promotion\.' })) { + $updatedNotes.Add('Runnable repo-owned LabVIEW operation files are now checked in; public proof is still required before promotion.') | Out-Null + } + $manifest.notes = @($updatedNotes.ToArray()) + + $manifest | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $manifestPath -Encoding utf8 + if (-not $SkipSchemaValidation.IsPresent) { + $manifestSchemaPath = Join-Path $repoRoot 'docs' 'schemas' 'operation-payload-source-bundle-v1.schema.json' + Invoke-SchemaValidation -RepoRoot $repoRoot -SchemaPath $manifestSchemaPath -DataPath $manifestPath + } + + $manifestUpdated = $true + $status = 'succeeded' + $notes.Add('Updated payload provenance to declared executable state `runnable` based on observed LabVIEW binary files.') | Out-Null + $notes.Add('Public standalone proof remains a separate blocker even after authoring completion.') | Out-Null +} else { + $notes.Add('Bundle still inspects as `source-only`; payload provenance was left unchanged.') | Out-Null + $notes.Add('Author repo-owned LabVIEW binary files first, then rerun this helper to finalize the source bundle metadata.') | Out-Null +} + +$receipt = [ordered]@{ + schema = 'comparevi/operation-payload-authoring-finalization@v1' + generatedAt = ([DateTimeOffset]::UtcNow.ToString('o')) + status = $status + bundlePath = Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $bundleResolved + manifestPath = Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $manifestPath + inspectionReportPath = Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $inspectionReportResolved + inspectionMarkdownPath = Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $inspectionMarkdownResolved + manifestUpdated = $manifestUpdated + beforeDeclaredExecutableState = $beforeDeclaredExecutableState + observedExecutableState = [string]$inspection.observedExecutableState + afterDeclaredExecutableState = [string]((Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -Depth 20).executableState) + beforeCurrentState = $beforeCurrentState + afterCurrentState = [string]((Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -Depth 20).currentState) + checkedInOperationFilesBefore = @($beforeCheckedInOperationFiles) + checkedInOperationFilesAfter = @(((Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -Depth 20).checkedInOperationFiles | ForEach-Object { [string]$_ })) + promotionBlocked = [bool]((Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -Depth 20).promotionBlocked) + blockingReasons = @(((Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -Depth 20).blockingReasons | ForEach-Object { [string]$_ })) + notes = @($notes.ToArray()) +} + +$receipt | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $receiptResolved -Encoding utf8 + +if (-not $SkipSchemaValidation.IsPresent) { + $receiptSchemaPath = Join-Path $repoRoot 'docs' 'schemas' 'operation-payload-authoring-finalization-v1.schema.json' + Invoke-SchemaValidation -RepoRoot $repoRoot -SchemaPath $receiptSchemaPath -DataPath $receiptResolved +} + +Write-Host ("Operation payload authoring finalization receipt: {0}" -f (Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $receiptResolved)) + +if ($PassThru.IsPresent) { + [pscustomobject]$receipt +} diff --git a/tools/Invoke-HeadlessSampleVICorpusPrintProof.ps1 b/tools/Invoke-HeadlessSampleVICorpusPrintProof.ps1 index bf648fb44..8188bb7ab 100644 --- a/tools/Invoke-HeadlessSampleVICorpusPrintProof.ps1 +++ b/tools/Invoke-HeadlessSampleVICorpusPrintProof.ps1 @@ -10,6 +10,7 @@ param( [string]$MarkdownPath = '', [string]$InspectionScriptPath = '', [string]$RunnerScriptPath = '', + [string]$PayloadFinalizationContractPath = 'docs/schemas/operation-payload-authoring-finalization-v1.schema.json', [string]$TargetRepositoryPath = '', [switch]$SkipSchemaValidation, [switch]$PassThru @@ -242,6 +243,15 @@ if ($LASTEXITCODE -ne 0) { $inspectionReport = Get-Content -LiteralPath $inspectionReportPath -Raw | ConvertFrom-Json -Depth 20 $observedExecutableState = [string]$inspectionReport.observedExecutableState $declaredExecutableState = [string]$inspectionReport.declaredExecutableState +$payloadFinalizationContractResolved = $null +$payloadFinalizationContractAvailable = $false +if (-not [string]::IsNullOrWhiteSpace($PayloadFinalizationContractPath)) { + $payloadFinalizationContractCandidate = Resolve-AbsolutePath -BasePath $repoRoot -PathValue $PayloadFinalizationContractPath + if (Test-Path -LiteralPath $payloadFinalizationContractCandidate -PathType Leaf) { + $payloadFinalizationContractResolved = $payloadFinalizationContractCandidate + $payloadFinalizationContractAvailable = $true + } +} $runnerScriptResolved = Resolve-ScriptPath -RepoRoot $repoRoot -PathValue $RunnerScriptPath -DefaultRelativePath 'tools/Run-NILinuxContainerCustomOperation.ps1' $targetRepositoryResolved = $null @@ -260,6 +270,9 @@ $notes = New-Object System.Collections.Generic.List[string] if ($finalStatus -eq 'blocked') { $notes.Add('Proof execution was not attempted because the repo-owned payload bundle is still source-only.') | Out-Null $notes.Add('This is the intended fail-closed state until runnable repo-owned payload files land.') | Out-Null + if ($payloadFinalizationContractAvailable) { + $notes.Add(("Finalization contract reference available at {0} for the repo-owned payload handoff." -f (Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $payloadFinalizationContractResolved))) | Out-Null + } } else { if ([string]::IsNullOrWhiteSpace($TargetRepositoryPath)) { $targetRepositoryResolved = Materialize-PinnedRepository ` @@ -334,6 +347,8 @@ $report = [ordered]@{ payloadInspectionMarkdownPath = Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $inspectionMarkdownPath payloadDeclaredExecutableState = $declaredExecutableState payloadObservedExecutableState = $observedExecutableState + payloadFinalizationContractPath = if ($payloadFinalizationContractResolved) { Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $payloadFinalizationContractResolved } else { $null } + payloadFinalizationContractAvailable = [bool]$payloadFinalizationContractAvailable runnerPath = Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $runnerScriptResolved targetRepositoryPath = if ($targetRepositoryResolved) { Convert-ToRepoRelativePath -RepoRoot $repoRoot -PathValue $targetRepositoryResolved } else { $null } targetRepositoryMaterialized = [bool]$targetRepositoryMaterialized @@ -370,6 +385,9 @@ $markdownLines += ('- Runner: `{0}`' -f $report.runnerPath) if ($report.targetRepositoryPath) { $markdownLines += ('- Target Repository Path: `{0}`' -f $report.targetRepositoryPath) } +if ($report.payloadFinalizationContractPath) { + $markdownLines += ('- Payload Finalization Contract: `{0}`' -f $report.payloadFinalizationContractPath) +} if ($report.renderedOutputPath) { $markdownLines += ('- Rendered Output Path: `{0}`' -f $report.renderedOutputPath) } diff --git a/tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1 b/tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1 index aa5779e2f..0dfc3cad5 100644 --- a/tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1 +++ b/tools/New-PrintToSingleFileHtmlAuthoringPacket.ps1 @@ -210,8 +210,10 @@ $checklistContent = (@( ' installed NI files verbatim.', '5. Once the authored files are runnable, copy only the repo-owned payload files', ' into fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml/.', - '6. Rerun tools/Inspect-OperationPayloadSourceBundle.ps1 so the bundle no', - ' longer reports source-only.', + '6. Run tools/Finalize-OperationPayloadSourceBundle.ps1 -BundlePath', + ' fixtures/headless-corpus/operation-payloads/PrintToSingleFileHtml so the', + ' bundle flips from source-only to runnable only when real LabVIEW binaries', + ' are present.', '', 'This packet unblocks #1617, #1726, and #1467 by turning the installed CLI', 'operation trees into a repo-owned authoring handoff for the public proof lane.', diff --git a/tools/docker/Dockerfile.tools b/tools/docker/Dockerfile.tools index 139b53bf6..58a3622e5 100644 --- a/tools/docker/Dockerfile.tools +++ b/tools/docker/Dockerfile.tools @@ -89,10 +89,14 @@ RUN npm install -g markdownlint-cli2@0.21.0 markdownlint-cli@0.31.1 \ && ln -sf /usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin/markdownlint-cli2 /usr/local/bin/markdownlint-cli2 # Python retained for workflow-maintenance tooling and test surfaces +RUN python3 -m pip install --disable-pip-version-check --no-cache-dir --break-system-packages cookiecutter==2.7.1 \ + && cookiecutter --version + COPY tools/workflows/requirements.txt /tmp/tools-workflows-requirements.txt RUN python3 -m venv "${COMPAREVI_WORKFLOW_ENCLAVE_HOME}" \ && "${COMPAREVI_WORKFLOW_ENCLAVE_HOME}/bin/python" -m pip install --disable-pip-version-check --upgrade pip \ && "${COMPAREVI_WORKFLOW_ENCLAVE_HOME}/bin/python" -m pip install --disable-pip-version-check --requirement /tmp/tools-workflows-requirements.txt \ + && python3 -m pip install --break-system-packages --no-cache-dir cookiecutter==2.7.1 \ && sha256sum /tmp/tools-workflows-requirements.txt | cut -d' ' -f1 > "${COMPAREVI_WORKFLOW_ENCLAVE_HOME}/.requirements.sha256" # Install actionlint (pinned) diff --git a/tools/policy/template-dependency.json b/tools/policy/template-dependency.json new file mode 100644 index 000000000..08aa35a82 --- /dev/null +++ b/tools/policy/template-dependency.json @@ -0,0 +1,26 @@ +{ + "$schema": "../../docs/schemas/template-dependency-v1.schema.json", + "schema": "priority/template-dependency@v1", + "schemaVersion": "1.0.0", + "templateRepositorySlug": "LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate", + "templateRepositoryUrl": "https://github.com/LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate.git", + "templateReleaseRef": "v0.1.0", + "templateDirectory": null, + "cookiecutterVersion": "2.7.1", + "container": { + "runtime": "docker", + "image": "ghcr.io/labview-community-ci-cd/comparevi-tools:latest", + "executionPlane": "linux-tools-image" + }, + "workspaceRoots": { + "win32": "E:\\comparevi-template-consumers", + "posix": "/tmp/comparevi-template-consumers" + }, + "rendering": { + "checkout": "v0.1.0", + "defaultContextPath": "tests/fixtures/cookiecutter/template-context.json", + "deterministicInput": true, + "noInput": true, + "uniqueContainerNamePrefix": "comparevi-template" + } +} diff --git a/tools/policy/template-pivot-gate.json b/tools/policy/template-pivot-gate.json index 0544ecfb4..4512f90fa 100644 --- a/tools/policy/template-pivot-gate.json +++ b/tools/policy/template-pivot-gate.json @@ -4,6 +4,12 @@ "sourceRepository": "LabVIEW-Community-CI-CD/compare-vi-cli-action", "targetRepository": "LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate", "targetBranch": "develop", + "templateDependency": { + "repository": "LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate", + "version": "v0.1.0", + "ref": "v0.1.0", + "cookiecutterVersion": "2.7.1" + }, "queueEmpty": { "requiredSchema": "standing-priority/no-standing@v1", "requiredReason": "queue-empty", diff --git a/tools/priority/Manage-UnattendedDeliveryAgent.ps1 b/tools/priority/Manage-UnattendedDeliveryAgent.ps1 index ac1882df9..661dacf93 100644 --- a/tools/priority/Manage-UnattendedDeliveryAgent.ps1 +++ b/tools/priority/Manage-UnattendedDeliveryAgent.ps1 @@ -42,6 +42,63 @@ $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $distScript = Join-Path $repoRoot 'dist\tools\priority\delivery-agent.js' Initialize-DeliveryAgentDistScript -RepoRoot $repoRoot -DistScript $distScript -WrapperLabel 'delivery-agent wrapper' +function Get-ManagerStatusSummary { + param( + [Parameter(Mandatory = $true)] + [object]$Report + ) + + $hostSignal = $Report.hostSignal + $hostIsolation = $Report.hostIsolation + $hostSignalStatus = if ($hostSignal -and $hostSignal.status) { [string]$hostSignal.status } else { $null } + $hostSignalProvider = if ($hostSignal -and $hostSignal.provider) { [string]$hostSignal.provider } else { $null } + + $activeRunnerServices = @() + if ($hostSignal -and $hostSignal.runnerServices -and $hostSignal.runnerServices.running) { + $activeRunnerServices = @($hostSignal.runnerServices.running) | Where-Object { $_ -and $_ -like 'actions.runner.*' } + } + + $summaryStatus = 'unknown' + $summaryText = 'cutover readiness unknown: host signal is missing' + + if ($hostSignal) { + if ($activeRunnerServices.Count -gt 0) { + $summaryStatus = 'runner-conflict' + $summaryText = "runner conflict: $($activeRunnerServices.Count) actions.runner.* services still active" + if ($hostSignalStatus -eq 'desktop-backed' -or $hostSignalProvider -eq 'desktop') { + $summaryText += '; cutover required: host still desktop-backed' + } + } elseif ($hostSignalStatus -eq 'native-wsl' -or $hostSignalProvider -eq 'native-wsl') { + $summaryStatus = 'ready' + $summaryText = 'cutover ready: native-wsl host signal is clear and no runner-service conflict remains' + } elseif ($hostSignalStatus -eq 'desktop-backed' -or $hostSignalProvider -eq 'desktop') { + $summaryStatus = 'cutover-required' + $summaryText = 'cutover required: host still desktop-backed' + } else { + $summaryStatus = 'cutover-required' + $summaryText = "cutover required: host signal status=$hostSignalStatus provider=$hostSignalProvider" + } + } + + return [ordered]@{ + hostSignal = [ordered]@{ + status = $hostSignalStatus + provider = $hostSignalProvider + } + hostIsolation = [ordered]@{ + lastEvent = $hostIsolation.lastEvent + } + runnerServices = [ordered]@{ + activeCount = @($activeRunnerServices).Count + activeNames = @($activeRunnerServices) + } + cutoverReadiness = [ordered]@{ + status = $summaryStatus + summary = $summaryText + } + } +} + $command = if ($Ensure) { 'ensure' } elseif ($Stop) { 'stop' } elseif ($Status) { 'status' } else { throw 'Specify one of -Ensure, -Status, or -Stop.' } $args = @( $distScript, @@ -84,5 +141,18 @@ foreach ($flag in @( } } +if ($Status) { + $stdout = & node @args + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + exit $exitCode + } + + $report = [string]::Join("`n", @($stdout)) | ConvertFrom-Json -Depth 64 + $report | Add-Member -NotePropertyName managerStatusSummary -NotePropertyValue (Get-ManagerStatusSummary -Report $report) -Force + $report | ConvertTo-Json -Depth 64 + exit 0 +} + & node @args exit $LASTEXITCODE diff --git a/tools/priority/__tests__/bootstrap-helper-contract.test.mjs b/tools/priority/__tests__/bootstrap-helper-contract.test.mjs index b6e640b4e..7ed45a16f 100644 --- a/tools/priority/__tests__/bootstrap-helper-contract.test.mjs +++ b/tools/priority/__tests__/bootstrap-helper-contract.test.mjs @@ -20,7 +20,7 @@ test('bootstrap routes standing-priority helper scripts through the resolved hel assert.match(content, /function Test-SemVerRepoRootOverrideSupport/); assert.match( content, - /Invoke-NodeScriptFromRepoRoot[\s\S]*-ScriptRelativePath 'tools\/priority\/sync-standing-priority\.mjs'[\s\S]*-RequiredPackages @\('undici'\)[\s\S]*--fail-on-missing[\s\S]*--auto-select-next/ + /Invoke-NodeScriptFromRepoRoot[\s\S]*-ScriptRelativePath 'tools\/priority\/sync-standing-priority\.mjs'[\s\S]*-RequiredPackages @\('undici'\)[\s\S]*--fail-on-missing[\s\S]*--auto-select-next[\s\S]*--materialize-cache/ ); assert.match( content, diff --git a/tools/priority/__tests__/cache-update.test.mjs b/tools/priority/__tests__/cache-update.test.mjs index 67587b898..1bee9a3ce 100644 --- a/tools/priority/__tests__/cache-update.test.mjs +++ b/tools/priority/__tests__/cache-update.test.mjs @@ -63,3 +63,18 @@ test('writeJson writes when file is missing or changed', async (t) => { assert.equal(shouldWriteJsonFile(filePath, second), true); assert.equal(writeJson(filePath, second), true); }); + +test('writeJson force option refreshes router-style artifacts even when payload is unchanged', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'priority-json-force-write-')); + t.after(() => rm(root, { recursive: true, force: true })); + const filePath = path.join(root, 'router.json'); + const payload = { schema: 'agent/priority-router@v1', issue: 1743, actions: [] }; + + await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + + assert.equal(writeJson(filePath, payload), false); + assert.equal(writeJson(filePath, payload, { force: true }), true); + + const saved = await readFile(filePath, 'utf8'); + assert.equal(saved, `${JSON.stringify(payload, null, 2)}\n`); +}); diff --git a/tools/priority/__tests__/concurrent-lane-plan-schema.test.mjs b/tools/priority/__tests__/concurrent-lane-plan-schema.test.mjs index 168a119e2..5960c9f7b 100644 --- a/tools/priority/__tests__/concurrent-lane-plan-schema.test.mjs +++ b/tools/priority/__tests__/concurrent-lane-plan-schema.test.mjs @@ -70,10 +70,18 @@ test('concurrent lane plan schema validates the generated report', async () => { }, dockerRuntimeSnapshot: { schema: 'docker-runtime-determinism@v1', + expected: { + provider: 'desktop', + osType: 'linux', + context: 'desktop-linux', + dockerHost: 'unix:///var/run/docker.sock', + manageDockerEngine: true, + allowHostEngineMutation: false + }, observed: { - osType: 'windows', - context: 'desktop-windows', - dockerHost: 'npipe:////./pipe/docker_engine' + osType: 'linux', + context: 'desktop-linux', + dockerHost: 'unix:///var/run/docker.sock' }, result: { status: 'ok' @@ -85,4 +93,7 @@ test('concurrent lane plan schema validates the generated report', async () => { addFormats(ajv); const validate = ajv.compile(schema); assert.equal(validate(report), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(report.dockerRuntimeCutover.schema, 'priority/docker-runtime-cutover-contract@v1'); + assert.equal(report.summary.dockerCutoverMode, 'desktop-linux-engine'); + assert.equal(report.summary.dockerCutoverRestoreMode, 'none'); }); diff --git a/tools/priority/__tests__/concurrent-lane-plan.test.mjs b/tools/priority/__tests__/concurrent-lane-plan.test.mjs index 9b688418e..fb73f2eba 100644 --- a/tools/priority/__tests__/concurrent-lane-plan.test.mjs +++ b/tools/priority/__tests__/concurrent-lane-plan.test.mjs @@ -68,24 +68,52 @@ function createHostRamBudget(recommendedParallelism = 2) { }; } +function createDockerRuntimeSnapshot({ + provider = 'desktop', + expectedOsType = 'linux', + expectedContext = 'desktop-linux', + expectedDockerHost = 'unix:///var/run/docker.sock', + observedOsType = 'linux', + observedContext = 'desktop-linux', + observedDockerHost = 'unix:///var/run/docker.sock', + status = 'ok' +} = {}) { + return { + schema: 'docker-runtime-determinism@v1', + expected: { + provider, + osType: expectedOsType, + context: expectedContext, + dockerHost: expectedDockerHost, + manageDockerEngine: true, + allowHostEngineMutation: false + }, + observed: { + osType: observedOsType, + context: observedContext, + dockerHost: observedDockerHost + }, + result: { + status + } + }; +} + test('buildConcurrentLanePlan prefers hosted lanes plus the current local docker lane', () => { const report = buildConcurrentLanePlan({ hostPlaneReport: createHostPlaneReport(), hostRamBudget: createHostRamBudget(), - dockerRuntimeSnapshot: { - schema: 'docker-runtime-determinism@v1', - observed: { - osType: 'linux', - context: 'desktop-linux', - dockerHost: 'unix:///var/run/docker.sock' - }, - result: { - status: 'ok' - } - } + dockerRuntimeSnapshot: createDockerRuntimeSnapshot() }); assert.equal(report.host.dockerServerOs, 'linux'); + assert.equal(report.dockerRuntimeCutover.schema, 'priority/docker-runtime-cutover-contract@v1'); + assert.equal(report.dockerRuntimeCutover.cutoverMode, 'desktop-linux-engine'); + assert.equal(report.dockerRuntimeCutover.canReuseLinuxDaemon, true); + assert.equal(report.dockerRuntimeCutover.restoreMode, 'none'); + assert.equal(report.summary.dockerCutoverMode, 'desktop-linux-engine'); + assert.equal(report.summary.dockerCutoverReusable, true); + assert.equal(report.summary.dockerCutoverRestoreMode, 'none'); assert.equal(report.summary.recommendedBundleId, 'hosted-plus-manual-linux-docker'); assert.deepEqual(report.recommendedBundle.laneIds, [ 'hosted-linux-proof', @@ -102,6 +130,33 @@ test('buildConcurrentLanePlan prefers hosted lanes plus the current local docker ); }); +test('buildConcurrentLanePlan records the pinned WSL2 Linux cutover contract when the runtime snapshot is native-wsl', () => { + const report = buildConcurrentLanePlan({ + hostPlaneReport: createHostPlaneReport(), + hostRamBudget: createHostRamBudget(), + dockerRuntimeSnapshot: createDockerRuntimeSnapshot({ + provider: 'native-wsl', + expectedOsType: 'linux', + expectedContext: '', + expectedDockerHost: 'unix:///var/run/docker.sock', + observedOsType: 'linux', + observedContext: '', + observedDockerHost: 'unix:///var/run/docker.sock' + }) + }); + + assert.equal(report.dockerRuntimeCutover.cutoverMode, 'pinned-wsl2-linux-daemon'); + assert.equal(report.dockerRuntimeCutover.canReuseLinuxDaemon, true); + assert.equal(report.dockerRuntimeCutover.requiresHostMutation, false); + assert.equal(report.dockerRuntimeCutover.requiresWslShutdown, false); + assert.equal(report.dockerRuntimeCutover.restoreMode, 'wsl-shutdown'); + assert.equal(report.dockerRuntimeCutover.restoreRequired, true); + assert.equal(report.summary.dockerCutoverMode, 'pinned-wsl2-linux-daemon'); + assert.equal(report.summary.dockerCutoverReusable, true); + assert.equal(report.summary.dockerCutoverRestoreMode, 'wsl-shutdown'); + assert.equal(report.summary.recommendedBundleId, 'hosted-plus-manual-linux-docker'); +}); + test('buildConcurrentLanePlan falls back to hosted plus shadow when docker engine evidence is unavailable', () => { const report = buildConcurrentLanePlan({ hostPlaneReport: createHostPlaneReport(), diff --git a/tools/priority/__tests__/concurrent-lane-status-schema.test.mjs b/tools/priority/__tests__/concurrent-lane-status-schema.test.mjs index c9745eb7d..733ebcca8 100644 --- a/tools/priority/__tests__/concurrent-lane-status-schema.test.mjs +++ b/tools/priority/__tests__/concurrent-lane-status-schema.test.mjs @@ -107,4 +107,8 @@ test('concurrent lane status schema validates the generated receipt', async () = addFormats(ajv); const validate = ajv.compile(schema); assert.equal(validate(receipt), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(receipt.plan.schema, 'priority/concurrent-lane-plan@v1'); + assert.equal(receipt.plan.source, 'file'); + assert.equal(receipt.plan.recommendedBundleId, 'hosted-only-proof'); + assert.equal(receipt.plan.selectedBundleId, 'hosted-only-proof'); }); diff --git a/tools/priority/__tests__/concurrent-lane-status.test.mjs b/tools/priority/__tests__/concurrent-lane-status.test.mjs index 3f6bf934b..126ab8a4c 100644 --- a/tools/priority/__tests__/concurrent-lane-status.test.mjs +++ b/tools/priority/__tests__/concurrent-lane-status.test.mjs @@ -220,6 +220,11 @@ test('observeConcurrentLaneStatus projects active hosted lanes and queued PR mer ); assert.equal(receipt.status, 'active'); + assert.equal(receipt.plan.path, 'tests/results/_agent/runtime/concurrent-lane-plan.json'); + assert.equal(receipt.plan.schema, 'priority/concurrent-lane-plan@v1'); + assert.equal(receipt.plan.source, 'file'); + assert.equal(receipt.plan.recommendedBundleId, 'hosted-plus-manual-linux-docker'); + assert.equal(receipt.plan.selectedBundleId, 'hosted-plus-manual-linux-docker'); assert.equal(receipt.hostedRun.observationStatus, 'active'); assert.equal(receipt.pullRequest.observationStatus, 'queued'); assert.equal(receipt.summary.orchestratorDisposition, 'wait-hosted-run'); @@ -348,6 +353,11 @@ test('observeConcurrentLaneStatus settles completed hosted runs and keeps deferr ); assert.equal(receipt.status, 'settled'); + assert.equal(receipt.plan.path, 'tests/results/_agent/runtime/concurrent-lane-plan.json'); + assert.equal(receipt.plan.schema, 'priority/concurrent-lane-plan@v1'); + assert.equal(receipt.plan.source, 'file'); + assert.equal(receipt.plan.recommendedBundleId, 'hosted-plus-host-native-32-shadow'); + assert.equal(receipt.plan.selectedBundleId, 'hosted-plus-host-native-32-shadow'); assert.equal(receipt.hostedRun.observationStatus, 'completed'); assert.equal(receipt.pullRequest.observationStatus, 'not-requested'); assert.equal(receipt.summary.shadowLaneCount, 1); diff --git a/tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs b/tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs index cacaa5757..07ee26883 100644 --- a/tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs +++ b/tools/priority/__tests__/cookiecutter-bootstrap-workflow.test.mjs @@ -17,15 +17,45 @@ test('cookiecutter bootstrap workflow provisions hosted Linux and Windows proof assert.match(workflow, /name:\s*Cookiecutter Bootstrap/); assert.match(workflow, /runner:\s*ubuntu-latest/); assert.match(workflow, /runner:\s*windows-latest/); + assert.match(workflow, /docs\/documentation-manifest\.json/); + assert.match(workflow, /docs\/schemas\/template-\*\.json/); assert.match(workflow, /uses:\s*actions\/setup-node@v6/); assert.match(workflow, /node-version:\s*24/); assert.match(workflow, /run:\s*npm ci/); assert.match(workflow, /uses:\s*actions\/setup-python@v6/); assert.match(workflow, /python-version:\s*'3\.12'/); + assert.match(workflow, /Resolve pinned template dependency policy/); + assert.match(workflow, /Build local tools image for cookiecutter conveyor/); + assert.match(workflow, /docker build -f tools\/docker\/Dockerfile\.tools -t comparevi-tools:cookiecutter \./); + assert.match(workflow, /Render pinned template dependency in the tools container/); + assert.match(workflow, /Verify pinned template dependency on Windows/); + assert.match(workflow, /priority:template:render:container/); + assert.match(workflow, /tools\/docker\/Dockerfile\.tools/); + assert.match(workflow, /--container-image comparevi-tools:cookiecutter/); + assert.match(workflow, /template-cookiecutter-container\.json/); + assert.match(workflow, /hostProjectDir \|\| receipt\.result\.projectDir/); + assert.match(workflow, /templateRepositorySlug/); + assert.match(workflow, /tools\/policy\/template-dependency\.json/); + assert.match(workflow, /tools\/policy\/template-\*\.json/); + assert.match(workflow, /tools\/priority\/template-\*\.mjs/); + assert.match(workflow, /tools\/priority\/__tests__\/template-\*\.test\.mjs/); + assert.match(workflow, /tools\/priority\/__tests__\/template-\*-schema\.test\.mjs/); + assert.match(workflow, /container_image/); + assert.match(workflow, /cookiecutter_version/); + assert.match(workflow, /default_context_path/); + assert.match(workflow, /pinned-template-dependency\.json/); + assert.match(workflow, /container-workspaces/); assert.match(workflow, /Test-CompareVICookiecutterBootstrap\.ps1/); assert.match(workflow, /cookiecutter-bootstrap-\$\{\{\s*matrix\.proof_id\s*\}\}/); assert.match(workflow, /tests\/results\/_agent\/cookiecutter-bootstrap\/\$\{\{\s*matrix\.proof_id\s*\}\}/); assert.match(workflow, /tests\/results\/_agent\/cookiecutter-scaffolds\/bootstrap-proof\/\$\{\{\s*matrix\.proof_id\s*\}\}/); + assert.match(workflow, /Template Agent Verification \/ template-agent-verification/); + assert.match(workflow, /priority:template:agent:verify/); + assert.match(workflow, /template-agent-verification-report\.json/); + assert.match(workflow, /name:\s*cookiecutter-bootstrap-linux[\s\S]*path:\s*tests\/results\/_agent/); + assert.match(workflow, /name:\s*cookiecutter-bootstrap-windows[\s\S]*path:\s*tests\/results\/_agent/); + assert.match(workflow, /receipt\.run\.runToken/); + assert.match(workflow, /container-workspaces/); }); test('package and runbook surfaces advertise the cookiecutter bootstrap proof', () => { @@ -41,4 +71,24 @@ test('package and runbook surfaces advertise the cookiecutter bootstrap proof', assert.match(runbook, /actions\/setup-python@v6/); assert.match(runbook, /windows-latest/); assert.match(runbook, /ubuntu-latest/); + assert.match(runbook, /LabVIEW-Community-CI-CD\/LabviewGitHubCiTemplate@v0\.1\.0/); + assert.match(runbook, /cookiecutter==2\.7\.1/); + assert.match(runbook, /ghcr\.io\/labview-community-ci-cd\/comparevi-tools:latest/); + assert.match(runbook, /priority:template:render:container/); + assert.match(runbook, /template-agent-verification-report\.json/); +}); + +test('documentation manifest tracks the pinned template conveyor', () => { + const manifest = JSON.parse(read('docs/documentation-manifest.json')); + const scaffoldContracts = manifest.entries.find((entry) => entry.name === 'Cookiecutter Scaffold Contracts'); + const templateVerificationContracts = manifest.entries.find((entry) => entry.name === 'Template Agent Verification Contracts'); + + assert.ok(scaffoldContracts); + assert.ok(templateVerificationContracts); + assert.match(scaffoldContracts.description, /LabviewGitHubCiTemplate@v0\.1\.0/); + assert.match(scaffoldContracts.description, /hosted container-backed render conveyor/); + assert.match(templateVerificationContracts.description, /LabviewGitHubCiTemplate@v0\.1\.0/); + assert.match(templateVerificationContracts.description, /hosted container-backed verification plane/); + assert.ok(scaffoldContracts.files.includes('tests/fixtures/cookiecutter/template-context.json')); + assert.ok(templateVerificationContracts.files.includes('tools/policy/template-dependency.json')); }); diff --git a/tools/priority/__tests__/delivery-agent-manager-contract.test.mjs b/tools/priority/__tests__/delivery-agent-manager-contract.test.mjs index a589ea032..f3b7e755b 100644 --- a/tools/priority/__tests__/delivery-agent-manager-contract.test.mjs +++ b/tools/priority/__tests__/delivery-agent-manager-contract.test.mjs @@ -42,12 +42,20 @@ async function makeLinkedWorktree(prefix) { return { sandboxRoot, repoDir, worktreeDir }; } -async function writeFakeDeliveryAgentBuildScript(tempRoot) { +async function writeFakeDeliveryAgentBuildScript(tempRoot, statusPayload = null) { const scriptPath = path.join(tempRoot, 'tools', 'npm', 'run-script.mjs'); await mkdir(path.dirname(scriptPath), { recursive: true }); - await writeFile( - scriptPath, - `#!/usr/bin/env node + const payloadExpression = statusPayload === null + ? "({ schema: 'test/delivery-agent@v1', command, reportPath })" + : JSON.stringify(statusPayload); + const deliveryAgentSource = `#!/usr/bin/env node +const command = process.argv[2] || ''; +const reportPathIndex = process.argv.indexOf('--report-path'); +const reportPath = reportPathIndex >= 0 ? process.argv[reportPathIndex + 1] : null; +const payload = ${payloadExpression}; +process.stdout.write(JSON.stringify(payload, null, 2) + '\\n'); +`; + const runnerScriptSource = `#!/usr/bin/env node import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -59,14 +67,13 @@ const distDir = path.join(repoRoot, 'dist', 'tools', 'priority'); mkdirSync(distDir, { recursive: true }); writeFileSync( path.join(distDir, 'delivery-agent.js'), - "#!/usr/bin/env node\\n" + - "const command = process.argv[2] || '';\\n" + - "const reportPathIndex = process.argv.indexOf('--report-path');\\n" + - "const reportPath = reportPathIndex >= 0 ? process.argv[reportPathIndex + 1] : null;\\n" + - "process.stdout.write(JSON.stringify({ schema: 'test/delivery-agent@v1', command, reportPath }, null, 2) + '\\\\n');\\n", + ${JSON.stringify(deliveryAgentSource)}, 'utf8', ); -`, +`; + await writeFile( + scriptPath, + runnerScriptSource, 'utf8', ); } @@ -101,6 +108,18 @@ test('package scripts expose delivery-agent commands and keep unattended aliases packageJson.scripts['priority:delivery:host:signal'], 'node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js' ); + assert.equal( + packageJson.scripts['priority:delivery:host:collect'], + 'node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js -- --mode collect' + ); + assert.equal( + packageJson.scripts['priority:delivery:host:isolate'], + 'node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js -- --mode isolate' + ); + assert.equal( + packageJson.scripts['priority:delivery:host:restore'], + 'node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-host-signal.ts --fallback-dist dist/tools/priority/delivery-host-signal.js -- --mode restore' + ); assert.equal( packageJson.scripts['priority:delivery:agent:ensure'], 'node tools/npm/run-local-typescript.mjs --project tsconfig.json --entry tools/priority/delivery-agent.ts --fallback-dist dist/tools/priority/delivery-agent.js -- ensure --sleep-mode' @@ -267,6 +286,40 @@ test('delivery-agent manager status ignores stale heartbeat state from before th assert.match(traceText, /"eventType":"status"/); }); +test('delivery-agent manager status ignores stale host-signal artifacts from before the current manager start', async (t) => { + const runtimeDirPath = await mkdtemp(path.join(repoRoot, 'tests', 'results', '_agent', 'tmp-manager-status-host-signal-stale-')); + const relativeRuntimeDir = path.relative(repoRoot, runtimeDirPath); + t.after(async () => { + await rm(runtimeDirPath, { recursive: true, force: true }); + }); + + const now = Date.now(); + const hostSignalGeneratedAt = new Date(now - 60_000).toISOString(); + const managerStartedAt = new Date(now - 10_000).toISOString(); + + await writeJson(path.join(runtimeDirPath, 'daemon-host-signal.json'), { + schema: 'priority/delivery-agent-host-signal@v1', + generatedAt: hostSignalGeneratedAt, + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + status: 'native-wsl', + provider: 'native-wsl', + daemonFingerprint: 'stale-host-signal-fingerprint', + }); + await writeJson(path.join(runtimeDirPath, 'delivery-agent-manager-pid.json'), { + schema: 'priority/unattended-delivery-agent-manager-pid@v1', + startedAt: managerStartedAt, + pid: 0 + }); + + const status = await invokeManagerStatus(relativeRuntimeDir); + + assert.equal(status.hostSignal, null); + assert.equal(status.hostSignalDiagnostics.usedHostSignal, false); + assert.equal(status.hostSignalDiagnostics.reason, 'stale-before-current-manager'); + assert.equal(status.hostSignalDiagnostics.hostSignalGeneratedAt, hostSignalGeneratedAt); + assert.equal(status.hostSignalDiagnostics.managerStartedAt, managerStartedAt); +}); + test('Manage-UnattendedDeliveryAgent suppresses fallback build chatter before JSON status output', async (t) => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'delivery-agent-wrapper-status-')); t.after(async () => { @@ -299,7 +352,24 @@ test('Manage-UnattendedDeliveryAgent suppresses fallback build chatter before JS assert.deepEqual(JSON.parse(stdout), { schema: 'test/delivery-agent@v1', command: 'status', - reportPath: null + reportPath: null, + managerStatusSummary: { + hostSignal: { + status: null, + provider: null + }, + hostIsolation: { + lastEvent: null + }, + runnerServices: { + activeCount: 0, + activeNames: [] + }, + cutoverReadiness: { + status: 'unknown', + summary: 'cutover readiness unknown: host signal is missing' + } + } }); }); @@ -692,6 +762,84 @@ test('delivery-agent manager status emits bounded log-tail trace events for daem assert.match(traceText, /"reason":"status:status"/); }); +test('delivery-agent manager status surfaces daemon cutover readiness and runner-service isolation summary', async (t) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'delivery-agent-wrapper-cutover-summary-')); + t.after(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + await copyRepoFile('tools/priority/Manage-UnattendedDeliveryAgent.ps1', tempRoot); + await copyRepoFile('tools/priority/DeliveryAgentWrapper.Build.psm1', tempRoot); + await writeFakeDeliveryAgentBuildScript(tempRoot, { + schema: 'test/delivery-agent@v1', + command: 'status', + reportPath: null, + hostSignal: { + status: 'desktop-backed', + provider: 'desktop', + runnerServices: { + running: [ + 'actions.runner.compare-vi-cli-action.1', + 'actions.runner.compare-vi-cli-action.2' + ], + stopped: ['actions.runner.compare-vi-cli-action.old'] + } + }, + hostIsolation: { + lastEvent: { + schema: 'priority/delivery-agent-host-isolation-event@v1', + type: 'runner-conflict-isolated', + generatedAt: '2026-03-21T12:00:00.000Z' + } + } + }); + + const { stdout, stderr } = await execFile( + 'pwsh', + [ + '-NoLogo', + '-NoProfile', + '-File', + path.join(tempRoot, 'tools', 'priority', 'Manage-UnattendedDeliveryAgent.ps1'), + '-Status', + '-RuntimeDir', + 'tests/results/_agent/runtime' + ], + { + cwd: tempRoot, + windowsHide: true + } + ); + + assert.equal(stderr, ''); + const report = JSON.parse(stdout); + assert.deepEqual(report.hostSignal, { + status: 'desktop-backed', + provider: 'desktop', + runnerServices: { + running: [ + 'actions.runner.compare-vi-cli-action.1', + 'actions.runner.compare-vi-cli-action.2' + ], + stopped: ['actions.runner.compare-vi-cli-action.old'] + } + }); + assert.equal(report.hostIsolation.lastEvent.type, 'runner-conflict-isolated'); + assert.equal(report.managerStatusSummary.hostSignal.status, 'desktop-backed'); + assert.equal(report.managerStatusSummary.hostSignal.provider, 'desktop'); + assert.equal(report.managerStatusSummary.hostIsolation.lastEvent.type, 'runner-conflict-isolated'); + assert.equal(report.managerStatusSummary.runnerServices.activeCount, 2); + assert.deepEqual(report.managerStatusSummary.runnerServices.activeNames, [ + 'actions.runner.compare-vi-cli-action.1', + 'actions.runner.compare-vi-cli-action.2' + ]); + assert.equal(report.managerStatusSummary.cutoverReadiness.status, 'runner-conflict'); + assert.equal( + report.managerStatusSummary.cutoverReadiness.summary, + 'runner conflict: 2 actions.runner.* services still active; cutover required: host still desktop-backed' + ); +}); + test('Manage-UnattendedDeliveryAgent.ps1 remains a thin wrapper around the JS delivery-agent implementation', async () => { const manager = await readText('tools/priority/Manage-UnattendedDeliveryAgent.ps1'); diff --git a/tools/priority/__tests__/delivery-agent-schema.test.mjs b/tools/priority/__tests__/delivery-agent-schema.test.mjs index bf4bf0cf9..7fb5fe745 100644 --- a/tools/priority/__tests__/delivery-agent-schema.test.mjs +++ b/tools/priority/__tests__/delivery-agent-schema.test.mjs @@ -70,6 +70,16 @@ test('delivery-agent policy schema validates the checked-in policy contract', as } ] }); + assert.deepEqual(data.storageRoots, { + worktrees: { + envVar: 'COMPAREVI_BURST_WORKTREE_ROOT', + preferredRoots: ['E:\\comparevi-lanes'] + }, + artifacts: { + envVar: 'COMPAREVI_BURST_ARTIFACT_ROOT', + preferredRoots: ['E:\\comparevi-artifacts'] + } + }); assert.deepEqual(data.workerPool, { targetSlotCount: 20, prewarmSlotCount: 1, @@ -477,7 +487,15 @@ test('runtime delivery task packet schema validates canonical delivery packets', lane: { workerSlotId: 'worker-slot-2', workerProviderId: 'local-codex', - workerCheckoutPath: '.runtime-worktrees/LabVIEW-Community-CI-CD--compare-vi-cli-action/worker-slot-2' + workerCheckoutRoot: 'E:\\comparevi-lanes\\LabVIEW-Community-CI-CD--compare-vi-cli-action', + workerCheckoutRootPolicy: { + strategy: 'policy-preferred-root', + source: 'delivery-agent.policy.json#storageRoots.worktrees.preferredRoots[0]', + baseRoot: 'E:\\comparevi-lanes', + relativeRoot: 'LabVIEW-Community-CI-CD--compare-vi-cli-action', + usesExternalRoot: true + }, + workerCheckoutPath: 'E:\\comparevi-lanes\\LabVIEW-Community-CI-CD--compare-vi-cli-action\\worker-slot-2' }, delivery: { executionMode: 'canonical-delivery', @@ -721,6 +739,14 @@ test('delivery-agent runtime state schema validates persisted runtime state', as lane: { workerSlotId: 'worker-slot-2', workerProviderId: 'hosted-github-workflow', + workerCheckoutRoot: path.join(repoRoot, '.runtime-worktrees', 'LabVIEW-Community-CI-CD--compare-vi-cli-action'), + workerCheckoutRootPolicy: { + strategy: 'repo-default', + source: 'repo-default-runtime-worktree-root', + baseRoot: path.join(repoRoot, '.runtime-worktrees'), + relativeRoot: 'LabVIEW-Community-CI-CD--compare-vi-cli-action', + usesExternalRoot: false + }, workerCheckoutPath: '.runtime-worktrees/LabVIEW-Community-CI-CD--compare-vi-cli-action/worker-slot-2' }, delivery: { diff --git a/tools/priority/__tests__/develop-sync.test.mjs b/tools/priority/__tests__/develop-sync.test.mjs index c87051b17..c98484a8f 100644 --- a/tools/priority/__tests__/develop-sync.test.mjs +++ b/tools/priority/__tests__/develop-sync.test.mjs @@ -214,6 +214,11 @@ test('resolveDevelopSyncExecutionRoot delegates work-branch syncs to an existing stderr: '' }; } + if (args[0] === 'status' && args[1] === '--porcelain') { + if (options.cwd === helperRoot) { + return { status: 0, stdout: '', stderr: '' }; + } + } throw new Error(`Unexpected git args: ${args.join(' ')}`); } }); @@ -224,7 +229,70 @@ test('resolveDevelopSyncExecutionRoot delegates work-branch syncs to an existing assert.equal(plan.delegated, true); assert.deepEqual( calls.map((entry) => entry.args.join(' ')), - ['branch --show-current', 'worktree list --porcelain'] + ['branch --show-current', 'worktree list --porcelain', 'status --porcelain'] + ); +}); + +test('resolveDevelopSyncExecutionRoot degrades clean work-branch syncs to ref-refresh when the only attached develop helper is dirty', () => { + const repoRoot = path.join('C:', 'repo', 'issue-root'); + const helperRoot = path.join('C:', 'repo', 'develop-root'); + const calls = []; + const plan = resolveDevelopSyncExecutionRoot({ + repoRoot, + spawnSyncFn: (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (command !== 'git') { + throw new Error(`Unexpected command ${command}`); + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return { status: 0, stdout: 'issue/origin-1751-sync-helper\n', stderr: '' }; + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + status: 0, + stdout: [ + `worktree ${repoRoot}`, + 'HEAD 1111111111111111111111111111111111111111', + 'branch refs/heads/issue/origin-1751-sync-helper', + '', + `worktree ${helperRoot}`, + 'HEAD 2222222222222222222222222222222222222222', + 'branch refs/heads/develop', + '' + ].join('\n'), + stderr: '' + }; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + if (options.cwd === helperRoot) { + return { status: 0, stdout: ' M tests/results/_agent/promotion/template-agent-verification-report.json\n', stderr: '' }; + } + if (options.cwd === repoRoot) { + return { status: 0, stdout: '', stderr: '' }; + } + } + throw new Error(`Unexpected git args: ${args.join(' ')}`); + } + }); + + assert.equal(plan.executionRepoRoot, repoRoot); + assert.equal(plan.currentBranch, 'issue/origin-1751-sync-helper'); + assert.equal(plan.mode, 'ref-refresh'); + assert.equal(plan.reason, 'dirty-develop-helper'); + assert.equal(plan.dirtyWorktree, false); + assert.equal(plan.delegated, false); + assert.equal(plan.helperRoot, null); + assert.deepEqual( + calls.map((entry) => ({ + args: entry.args.join(' '), + cwd: entry.cwd + })), + [ + { args: 'branch --show-current', cwd: repoRoot }, + { args: 'worktree list --porcelain', cwd: repoRoot }, + { args: 'status --porcelain', cwd: helperRoot }, + { args: 'worktree list --porcelain', cwd: repoRoot } + ] ); }); @@ -269,7 +337,70 @@ test('resolveDevelopSyncExecutionRoot degrades dirty work-branch syncs to ref-re assert.equal(plan.helperRoot, null); assert.deepEqual( calls.map((entry) => entry.args.join(' ')), - ['branch --show-current', 'worktree list --porcelain', 'status --porcelain'] + ['branch --show-current', 'worktree list --porcelain', 'worktree list --porcelain', 'status --porcelain'] + ); +}); + +test('resolveDevelopSyncExecutionRoot delegates dirty develop roots to a clean helper develop worktree', () => { + const repoRoot = path.join('C:', 'repo', 'dirty-develop-root'); + const helperRoot = path.join('C:', 'repo', 'clean-develop-helper'); + const calls = []; + const plan = resolveDevelopSyncExecutionRoot({ + repoRoot, + spawnSyncFn: (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (command !== 'git') { + throw new Error(`Unexpected command ${command}`); + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return { status: 0, stdout: 'develop\n', stderr: '' }; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + if (options.cwd === repoRoot) { + return { status: 0, stdout: ' M tests/results/_agent/issue/router.json\n?? dirty-note.txt\n', stderr: '' }; + } + if (options.cwd === helperRoot) { + return { status: 0, stdout: '', stderr: '' }; + } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + status: 0, + stdout: [ + `worktree ${repoRoot}`, + 'HEAD 1111111111111111111111111111111111111111', + 'branch refs/heads/develop', + '', + `worktree ${helperRoot}`, + 'HEAD 2222222222222222222222222222222222222222', + 'branch refs/heads/develop', + '' + ].join('\n'), + stderr: '' + }; + } + throw new Error(`Unexpected git args: ${args.join(' ')}`); + } + }); + + assert.equal(plan.currentBranch, 'develop'); + assert.equal(plan.executionRepoRoot, helperRoot); + assert.equal(plan.mode, 'full-sync'); + assert.equal(plan.reason, 'dirty-develop-root-helper'); + assert.equal(plan.dirtyWorktree, true); + assert.equal(plan.delegated, true); + assert.equal(plan.helperRoot, helperRoot); + assert.deepEqual( + calls.map((entry) => ({ + args: entry.args.join(' '), + cwd: entry.cwd + })), + [ + { args: 'branch --show-current', cwd: repoRoot }, + { args: 'status --porcelain', cwd: repoRoot }, + { args: 'worktree list --porcelain', cwd: repoRoot }, + { args: 'status --porcelain', cwd: helperRoot } + ] ); }); @@ -423,6 +554,11 @@ test('runDevelopSync launches the sync script from the delegated develop helper stderr: '' }; } + if (args[0] === 'status' && args[1] === '--porcelain') { + if (options.cwd === helperRoot) { + return { status: 0, stdout: '', stderr: '' }; + } + } if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { return { status: 0, stdout: `${helperRoot}\n`, stderr: '' }; } diff --git a/tools/priority/__tests__/downstream-onboarding-contract.test.mjs b/tools/priority/__tests__/downstream-onboarding-contract.test.mjs index 34de6531b..385e378fe 100644 --- a/tools/priority/__tests__/downstream-onboarding-contract.test.mjs +++ b/tools/priority/__tests__/downstream-onboarding-contract.test.mjs @@ -24,24 +24,44 @@ test('workflow executes onboarding, success, feedback, and promotion scorecard c assert.match(workflow, /downstream_branch:/); assert.match(workflow, /GITHUB_TOKEN:\s*\$\{\{\s*github\.token\s*\}\}/); assert.match(workflow, /GH_TOKEN:\s*\$\{\{\s*github\.token\s*\}\}/); + assert.match(workflow, /consumer_issue_repo:/); + assert.match(workflow, /DOWNSTREAM_CONSUMER_ISSUE_REPO:/); + assert.match(workflow, /Resolve pinned template dependency policy/); + assert.match(workflow, /tools\/policy\/template-dependency\.json/); + assert.match(workflow, /Resolve immutable upstream source/); + assert.match(workflow, /git fetch --no-tags origin '\+refs\/heads\/develop:refs\/remotes\/upstream\/develop'/); assert.match(workflow, /downstream-onboarding-feedback\.mjs/); assert.match(workflow, /args\+=\(--branch "\$branch"\)/); + assert.match(workflow, /--issue-repo "\$issue_repo"/); assert.match(workflow, /downstream-onboarding-report-v1\.schema\.json/); assert.match(workflow, /downstream-onboarding-success-v1\.schema\.json/); assert.match(workflow, /downstream-onboarding-feedback-v1\.schema\.json/); assert.match(workflow, /Refresh template-agent verification report/); assert.match(workflow, /priority:template:agent:verify/); + assert.match(workflow, /Generate downstream promotion manifest/); + assert.match(workflow, /downstream-promotion-manifest\.mjs/); + assert.match(workflow, /--source-sha '\$\{\{ steps\.source\.outputs\.source_sha \}\}'/); + assert.match(workflow, /--comparevi-tools-release 'develop@\$\{\{ steps\.source\.outputs\.source_sha \}\}'/); + assert.match(workflow, /--comparevi-history-release 'not-evaluated:onboarding-feedback'/); + assert.match(workflow, /--scenario-pack-id 'downstream-onboarding-feedback@v1'/); + assert.match(workflow, /--cookiecutter-template-id '\$\{\{ steps\.template-policy\.outputs\.repository \}\}@\$\{\{ steps\.template-policy\.outputs\.ref \}\}'/); + assert.match(workflow, /downstream-promotion-manifest-v1\.schema\.json/); assert.match(workflow, /template-agent-verification-report-v1\.schema\.json/); assert.match(workflow, /tests\/results\/_agent\/promotion\/template-agent-verification-report\.json/); assert.match(workflow, /Build downstream promotion scorecard/); assert.match(workflow, /downstream-promotion-scorecard\.mjs/); + assert.match(workflow, /--template-agent-verification-report tests\/results\/_agent\/promotion\/template-agent-verification-report\.json/); assert.match(workflow, /--manifest-report tests\/results\/_agent\/promotion\/downstream-develop-promotion-manifest\.json/); assert.match(workflow, /downstream-promotion-scorecard-v1\.schema\.json/); assert.match(workflow, /tests\/results\/_agent\/promotion\/downstream-develop-promotion-scorecard\.json/); + assert.ok( + workflow.indexOf('Generate downstream promotion manifest') < workflow.indexOf('Build downstream promotion scorecard') + ); assert.match(workflow, /Append onboarding feedback summary/); assert.match(workflow, /execution status/); assert.match(workflow, /template-agent verification status/); assert.match(workflow, /hashFiles\('tests\/results\/_agent\/onboarding\/downstream-onboarding\.json'\)/); + assert.match(workflow, /hashFiles\('tests\/results\/_agent\/promotion\/downstream-develop-promotion-manifest\.json'\)/); assert.match(workflow, /hashFiles\('tests\/results\/_agent\/promotion\/downstream-develop-promotion-scorecard\.json'\)/); }); @@ -58,5 +78,15 @@ test('runbook and package scripts expose downstream onboarding and promotion com assert.match(runbook, /priority:onboard:success/); assert.match(runbook, /priority:promote:downstream:scorecard/); assert.match(runbook, /downstream-develop-promotion-scorecard\.json/); + assert.match(runbook, /--issue-repo LabVIEW-Community-CI-CD\/LabviewGitHubCiTemplate/); + assert.match(runbook, /consumer_issue_repo/); + assert.match(runbook, /fails closed/); assert.match(runbook, /HOSTED_SIGNAL_REPORT_FIRST_CONTRACT\.md/); }); + +test('documentation manifest includes the downstream onboarding runbook', () => { + const manifest = JSON.parse(read('docs/documentation-manifest.json')); + const docsEntry = manifest.entries.find((entry) => entry.name === 'Docs Tree'); + assert.ok(docsEntry); + assert.ok(docsEntry.files.includes('docs/DOWNSTREAM_RELEASE_TRAIN_ONBOARDING.md')); +}); diff --git a/tools/priority/__tests__/downstream-promotion-contract.test.mjs b/tools/priority/__tests__/downstream-promotion-contract.test.mjs index bb6c8b3dd..45670e205 100644 --- a/tools/priority/__tests__/downstream-promotion-contract.test.mjs +++ b/tools/priority/__tests__/downstream-promotion-contract.test.mjs @@ -44,6 +44,7 @@ test('package scripts and runbook expose downstream promotion manifest and score assert.match(runbook, /priority:promote:downstream:scorecard/); assert.match(runbook, /--manifest-report tests\/results\/_agent\/promotion\/downstream-develop-promotion-manifest\.json/); assert.match(runbook, /priority:template:agent:verify/); + assert.match(runbook, /--template-agent-verification-report tests\/results\/_agent\/promotion\/template-agent-verification-report\.json/); assert.match(runbook, /--iteration-head-sha/); assert.match(runbook, /cookiecutter/i); assert.match(runbook, /rollback/i); @@ -54,6 +55,9 @@ test('package scripts and runbook expose downstream promotion manifest and score assert.match(runbook, /reservedSlotCount = 1/); assert.match(runbook, /minimumImplementationSlots = 3/); assert.match(runbook, /hosted-first/); + assert.match(runbook, /LabVIEW-Community-CI-CD\/LabviewGitHubCiTemplate@v0\.1\.0/); + assert.match(runbook, /cookiecutter==2\.7\.1/); + assert.match(runbook, /ghcr\.io\/labview-community-ci-cd\/comparevi-tools:latest/); assert.match(runbook, /Release consumption/); assert.match(runbook, /downstream-promotion\.yml/); }); @@ -68,12 +72,20 @@ test('downstream promotion workflow turns the proving rail into checked-in autom assert.match(workflow, /comparevi_history_release:/); assert.match(workflow, /scenario_pack_id:/); assert.match(workflow, /cookiecutter_template_id:/); + assert.match(workflow, /Resolve pinned template dependency policy/); + assert.match(workflow, /tools\/policy\/template-dependency\.json/); + assert.match(workflow, /Record pinned template dependency/); + assert.match(workflow, /template-dependency\.json/); assert.match(workflow, /Run downstream onboarding feedback harness/); assert.match(workflow, /downstream-onboarding-feedback\.mjs/); + assert.match(workflow, /Refresh template-agent verification report/); + assert.match(workflow, /priority:template:agent:verify/); + assert.match(workflow, /template-agent-verification-report-v1\.schema\.json/); assert.match(workflow, /Generate downstream promotion manifest/); assert.match(workflow, /downstream-promotion-manifest\.mjs/); assert.match(workflow, /Build downstream promotion scorecard/); assert.match(workflow, /downstream-promotion-scorecard\.mjs/); + assert.match(workflow, /--template-agent-verification-report tests\/results\/_agent\/promotion\/template-agent-verification-report\.json/); assert.ok( workflow.indexOf('Generate downstream promotion manifest') < workflow.indexOf('Build downstream promotion scorecard') ); diff --git a/tools/priority/__tests__/downstream-promotion-scorecard-schema.test.mjs b/tools/priority/__tests__/downstream-promotion-scorecard-schema.test.mjs index 20ee3e219..4ebfbbd14 100644 --- a/tools/priority/__tests__/downstream-promotion-scorecard-schema.test.mjs +++ b/tools/priority/__tests__/downstream-promotion-scorecard-schema.test.mjs @@ -25,6 +25,7 @@ test('downstream promotion scorecard schema validates generated report payload', const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downstream-promotion-scorecard-schema-')); const successReportPath = path.join(tmpDir, 'downstream-onboarding-success.json'); const feedbackReportPath = path.join(tmpDir, 'downstream-onboarding-feedback.json'); + const templateAgentVerificationReportPath = path.join(tmpDir, 'template-agent-verification-report.json'); const manifestReportPath = path.join(tmpDir, 'downstream-develop-promotion-manifest.json'); const outputPath = path.join(tmpDir, 'scorecard.json'); @@ -48,6 +49,54 @@ test('downstream promotion scorecard schema validates generated report payload', successExitCode: 0 } }); + writeJson(templateAgentVerificationReportPath, { + schema: 'priority/template-agent-verification-report@v1', + summary: { + status: 'pass', + blockerCount: 0, + recommendation: 'continue-template-agent-loop' + }, + iteration: { + label: 'post-merge develop', + headSha: '1234567890abcdef1234567890abcdef12345678' + }, + lane: { + enabled: true, + reservedSlotCount: 1, + minimumImplementationSlots: 3, + implementationSlotsRemaining: 3, + targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + consumerRailBranch: 'downstream/develop' + }, + verification: { + provider: 'hosted-github-workflow', + status: 'pass' + }, + provenance: { + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + execution: { + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:latest', + generatedConsumerWorkspaceRoot: 'E:/comparevi-template-consumers/example', + laneId: 'logical-lane-template-verification', + agentId: 'darwin', + fundingWindowId: 'invoice-turn-2026-03-HQ1VJLMV-0027' + } + }, + goals: {}, + metrics: { + targetSlotCount: 8, + reservedSlotCount: 1, + implementationSlotsRemaining: 3, + recommendationPresent: true + }, + blockers: [] + }); writeJson(manifestReportPath, { schema: 'priority/downstream-promotion-manifest@v1', promotion: { @@ -72,6 +121,7 @@ test('downstream promotion scorecard schema validates generated report payload', repo: 'example/repo', successReportPath, feedbackReportPath, + templateAgentVerificationReportPath, manifestReportPath, outputPath }); @@ -85,5 +135,6 @@ test('downstream promotion scorecard schema validates generated report payload', assert.equal(result.report.summary.status, 'pass'); assert.equal(result.report.gates.feedbackReport.status, 'pass'); + assert.equal(result.report.gates.templateAgentVerificationReport.status, 'pass'); assert.equal(result.report.gates.manifestReport.status, 'pass'); }); diff --git a/tools/priority/__tests__/downstream-promotion-scorecard.test.mjs b/tools/priority/__tests__/downstream-promotion-scorecard.test.mjs index 427972fd0..7b170c6fd 100644 --- a/tools/priority/__tests__/downstream-promotion-scorecard.test.mjs +++ b/tools/priority/__tests__/downstream-promotion-scorecard.test.mjs @@ -20,6 +20,8 @@ test('parseArgs enforces required downstream promotion scorecard inputs', () => 'success.json', '--feedback-report', 'feedback.json', + '--template-agent-verification-report', + 'template-agent-verification-report.json', '--manifest-report', 'manifest.json', '--repo', @@ -29,6 +31,7 @@ test('parseArgs enforces required downstream promotion scorecard inputs', () => assert.equal(parsed.successReportPath, 'success.json'); assert.equal(parsed.feedbackReportPath, 'feedback.json'); + assert.equal(parsed.templateAgentVerificationReportPath, 'template-agent-verification-report.json'); assert.equal(parsed.manifestReportPath, 'manifest.json'); assert.equal(parsed.repo, 'example/repo'); assert.equal(parsed.failOnBlockers, false); @@ -38,9 +41,11 @@ test('evaluateDownstreamPromotionScorecard reports blockers deterministically', const pass = evaluateDownstreamPromotionScorecard({ successReport: { exists: true, error: null }, feedbackReport: { exists: true, error: null }, + templateAgentVerificationReport: { exists: true, error: null }, manifestReport: { exists: false, error: null }, successGate: { status: 'pass', totalBlockers: 0 }, feedbackGate: { status: 'pass', executionStatus: 'pass' }, + templateAgentVerificationGate: { status: 'pass', verificationStatus: 'pass' }, manifestGate: { status: 'missing' } }); assert.equal(pass.status, 'pass'); @@ -49,9 +54,11 @@ test('evaluateDownstreamPromotionScorecard reports blockers deterministically', const fail = evaluateDownstreamPromotionScorecard({ successReport: { exists: false, error: null }, feedbackReport: { exists: true, error: 'bad json' }, + templateAgentVerificationReport: { exists: false, error: null }, manifestReport: { exists: true, error: 'bad json' }, successGate: { status: 'fail', totalBlockers: 2 }, feedbackGate: { status: 'fail', executionStatus: 'fail' }, + templateAgentVerificationGate: { status: 'fail', verificationStatus: 'blocked' }, manifestGate: { status: 'fail' } }); @@ -60,6 +67,8 @@ test('evaluateDownstreamPromotionScorecard reports blockers deterministically', assert.ok(fail.blockers.some((entry) => entry.code === 'feedback-report-missing')); assert.ok(fail.blockers.some((entry) => entry.code === 'downstream-blockers')); assert.ok(fail.blockers.some((entry) => entry.code === 'feedback-execution')); + assert.ok(fail.blockers.some((entry) => entry.code === 'template-agent-verification-report-missing')); + assert.ok(fail.blockers.some((entry) => entry.code === 'template-agent-verification-contract')); assert.ok(fail.blockers.some((entry) => entry.code === 'manifest-report-unreadable')); assert.ok(fail.blockers.some((entry) => entry.code === 'manifest-contract')); }); @@ -68,6 +77,7 @@ test('runDownstreamPromotionScorecard projects manifest provenance when present' const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downstream-promotion-scorecard-')); const successReportPath = path.join(tmpDir, 'downstream-onboarding-success.json'); const feedbackReportPath = path.join(tmpDir, 'downstream-onboarding-feedback.json'); + const templateAgentVerificationReportPath = path.join(tmpDir, 'template-agent-verification-report.json'); const manifestReportPath = path.join(tmpDir, 'downstream-develop-promotion-manifest.json'); const outputPath = path.join(tmpDir, 'downstream-develop-promotion-scorecard.json'); @@ -91,6 +101,54 @@ test('runDownstreamPromotionScorecard projects manifest provenance when present' successExitCode: 0 } }); + writeJson(templateAgentVerificationReportPath, { + schema: 'priority/template-agent-verification-report@v1', + summary: { + status: 'pass', + blockerCount: 0, + recommendation: 'continue-template-agent-loop' + }, + iteration: { + label: 'post-merge develop', + headSha: '1234567890abcdef1234567890abcdef12345678' + }, + lane: { + enabled: true, + reservedSlotCount: 1, + minimumImplementationSlots: 3, + implementationSlotsRemaining: 3, + targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + consumerRailBranch: 'downstream/develop' + }, + verification: { + provider: 'hosted-github-workflow', + status: 'pass' + }, + provenance: { + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + execution: { + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:latest', + generatedConsumerWorkspaceRoot: 'E:/comparevi-template-consumers/example', + laneId: 'logical-lane-template-verification', + agentId: 'darwin', + fundingWindowId: 'invoice-turn-2026-03-HQ1VJLMV-0027' + } + }, + goals: {}, + metrics: { + targetSlotCount: 8, + reservedSlotCount: 1, + implementationSlotsRemaining: 3, + recommendationPresent: true + }, + blockers: [] + }); writeJson(manifestReportPath, { schema: 'priority/downstream-promotion-manifest@v1', promotion: { @@ -115,6 +173,7 @@ test('runDownstreamPromotionScorecard projects manifest provenance when present' repo: 'example/repo', successReportPath, feedbackReportPath, + templateAgentVerificationReportPath, manifestReportPath, outputPath }); @@ -122,16 +181,22 @@ test('runDownstreamPromotionScorecard projects manifest provenance when present' assert.equal(result.exitCode, 0); assert.equal(result.report.summary.status, 'pass'); assert.equal(result.report.gates.successReport.summaryStatus, 'warn'); + assert.equal(result.report.gates.templateAgentVerificationReport.status, 'pass'); assert.equal(result.report.gates.manifestReport.status, 'pass'); assert.equal(result.report.summary.metrics.totalWarnings, 3); assert.equal(result.report.summary.provenance.compareviToolsRelease, 'v0.6.3-tools.14'); assert.equal(result.report.summary.provenance.cookiecutterTemplateIdentity, 'LabviewGitHubCiTemplate@v0.1.0'); + assert.equal( + result.report.summary.provenance.templateVerificationRepository, + 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate' + ); }); test('runDownstreamPromotionScorecard fails closed when onboarding blockers remain', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downstream-promotion-scorecard-fail-')); const successReportPath = path.join(tmpDir, 'downstream-onboarding-success.json'); const feedbackReportPath = path.join(tmpDir, 'downstream-onboarding-feedback.json'); + const templateAgentVerificationReportPath = path.join(tmpDir, 'template-agent-verification-report.json'); writeJson(successReportPath, { schema: 'priority/downstream-onboarding-success@v1', @@ -153,14 +218,64 @@ test('runDownstreamPromotionScorecard fails closed when onboarding blockers rema successExitCode: 0 } }); + writeJson(templateAgentVerificationReportPath, { + schema: 'priority/template-agent-verification-report@v1', + summary: { + status: 'blocked', + blockerCount: 1, + recommendation: 'repair-template-lane' + }, + iteration: { + label: 'post-merge develop', + headSha: '1234567890abcdef1234567890abcdef12345678' + }, + lane: { + enabled: true, + reservedSlotCount: 1, + minimumImplementationSlots: 3, + implementationSlotsRemaining: 3, + targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + consumerRailBranch: 'downstream/develop' + }, + verification: { + provider: 'hosted-github-workflow', + status: 'blocked' + }, + provenance: { + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + execution: { + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:latest', + generatedConsumerWorkspaceRoot: 'E:/comparevi-template-consumers/example', + laneId: 'logical-lane-template-verification', + agentId: 'darwin', + fundingWindowId: 'invoice-turn-2026-03-HQ1VJLMV-0027' + } + }, + goals: {}, + metrics: { + targetSlotCount: 8, + reservedSlotCount: 1, + implementationSlotsRemaining: 3, + recommendationPresent: true + }, + blockers: [{ code: 'template-blocked', message: 'Hosted verification did not pass.' }] + }); const result = runDownstreamPromotionScorecard({ successReportPath, feedbackReportPath, + templateAgentVerificationReportPath, failOnBlockers: true }); assert.equal(result.exitCode, 1); assert.equal(result.report.summary.status, 'fail'); assert.ok(result.report.summary.blockers.some((entry) => entry.code === 'downstream-blockers')); + assert.ok(result.report.summary.blockers.some((entry) => entry.code === 'template-agent-verification-contract')); }); diff --git a/tools/priority/__tests__/jarvis-session-observer-schema.test.mjs b/tools/priority/__tests__/jarvis-session-observer-schema.test.mjs new file mode 100644 index 000000000..7e3b77f93 --- /dev/null +++ b/tools/priority/__tests__/jarvis-session-observer-schema.test.mjs @@ -0,0 +1,202 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +import { buildJarvisSessionObserverReport } from '../jarvis-session-observer.mjs'; + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'jarvis-session-observer-schema-')); +} + +function writeJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function writeText(filePath, text) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, text, 'utf8'); +} + +test('jarvis session observer report matches the checked-in schema', async () => { + const repoRoot = createTempDir(); + const runtimeDir = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime'); + writeJson(path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), { + schema: 'priority/delivery-agent-policy@v1', + capitalFabric: { + maxLogicalLaneCount: 8, + specialtyLanes: [ + { + id: 'jarvis', + enabled: true, + primaryRecordedResponsibility: 'Sagan', + maxInstanceCount: 2, + purpose: 'windows-docker-iterative-development', + preferredExecutionPlane: 'local-docker-windows', + preferredContainerImage: 'nationalinstruments/labview:2026q1-windows', + allocationMode: 'opportunistic' + } + ] + }, + dockerRuntime: { + provider: 'native-wsl', + dockerHost: 'unix:///var/run/docker.sock', + expectedOsType: 'linux', + expectedContext: '', + manageDockerEngine: false, + allowHostEngineMutation: false + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-state.json'), { + schema: 'priority/delivery-agent-runtime-state@v1', + generatedAt: '2026-03-21T01:00:00.000Z', + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + logicalLaneActivation: { + effectiveLogicalLaneCount: 2 + }, + activeLane: { + laneId: 'issue-origin-1736-jarvis-session-observer', + issue: 1736, + providerDispatch: { + executionPlane: 'local', + workerSlotId: 'worker-slot-1' + } + } + }); + writeJson(path.join(runtimeDir, 'concurrent-lane-status-receipt.json'), { + schema: 'priority/concurrent-lane-status-receipt@v1', + laneStatuses: [ + { + id: 'manual-windows-docker', + runtimeStatus: 'active', + executionPlane: 'local', + laneClass: 'manual-docker', + resourceGroup: 'docker-desktop-windows', + reasons: ['docker-engine-windows'], + metadata: { + dockerContext: 'desktop-windows', + dockerServerOs: 'windows' + } + } + ] + }); + writeJson(path.join(runtimeDir, 'daemon-host-signal.json'), { + schema: 'priority/delivery-agent-host-signal@v1', + generatedAt: '2026-03-21T01:10:00.000Z', + status: 'native-wsl', + provider: 'native-wsl', + daemonFingerprint: 'abc12345', + previousFingerprint: 'abc12345', + fingerprintChanged: false, + reasons: [], + windowsDocker: { + available: true, + context: 'desktop-windows', + osType: 'windows', + operatingSystem: 'Docker Desktop', + serverName: 'docker-desktop', + platformName: 'Docker Desktop', + serverVersion: '29.2.0', + labels: [], + error: null + }, + wslDocker: { + distro: 'Ubuntu', + dockerHost: 'unix:///var/run/docker.sock', + available: true, + socketPath: '/var/run/docker.sock', + socketPresent: true, + socketOwner: 'root:docker', + socketMode: '660', + systemdState: 'running', + serviceState: 'active', + context: 'default', + osType: 'linux', + operatingSystem: 'Ubuntu 24.04.2 LTS', + serverName: 'ubuntu-native', + platformName: 'Docker Engine - Community', + serverVersion: '28.1.1', + labels: [], + isDockerDesktop: false, + error: null + }, + runnerServices: { + running: [], + stopped: [] + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-host-isolation.json'), { + schema: 'priority/delivery-agent-host-isolation@v1', + lastStatus: 'native-wsl', + lastAction: 'collect', + preemptedServices: [], + counters: { + runnerPreemptionCount: 0 + } + }); + writeJson(path.join(runtimeDir, 'observer-heartbeat.json'), { + schema: 'priority/runtime-observer-heartbeat@v1', + generatedAt: '2026-03-21T01:12:00.000Z', + outcome: 'lane-tracked', + cyclesCompleted: 3, + stopRequested: false, + activeLane: { + laneId: 'issue-origin-1736-jarvis-session-observer', + issue: 1736 + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-wsl-daemon-pid.json'), { + schema: 'priority/unattended-delivery-agent-wsl-daemon-pid@v1', + generatedAt: '2026-03-21T01:13:00.000Z', + pid: 4242, + running: true, + unitName: 'comparevi-daemon', + distro: 'Ubuntu' + }); + writeJson(path.join(runtimeDir, 'docker-daemon-engine.json'), { + schema: 'priority/runtime-daemon-docker-engine@v1', + generatedAt: '2026-03-21T01:14:00.000Z', + requiredOs: 'linux', + lockPath: 'tests/results/_agent/runtime/docker-daemon-engine.lock', + lockAcquired: false, + docker: { + command: 'docker', + os: 'linux', + context: { + previous: 'desktop-windows', + active: 'desktop-linux', + mode: 'context-use', + switched: true + } + } + }); + writeJson(path.join(runtimeDir, 'delivery-memory.json'), { + schema: 'priority/delivery-memory@v1', + generatedAt: '2026-03-21T01:05:00.000Z', + summary: { + targetSlotCount: 4 + } + }); + writeText(path.join(runtimeDir, 'runtime-daemon-wsl.log'), 'daemon-line-1\ndaemon-line-2\n'); + writeText(path.join(runtimeDir, 'docker-daemon-logs.txt'), 'docker-line-1\ndocker-line-2\n'); + + const report = await buildJarvisSessionObserverReport({ + repoRoot, + runtimeDir, + policyPath: path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), + outputPath: path.join(runtimeDir, 'jarvis-session-observer.json'), + tailLines: 2 + }); + + const schema = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'docs', 'schemas', 'jarvis-session-observer-v1.schema.json'), 'utf8') + ); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + const validate = ajv.compile(schema); + assert.equal(validate(report), true, JSON.stringify(validate.errors, null, 2)); +}); diff --git a/tools/priority/__tests__/jarvis-session-observer.test.mjs b/tools/priority/__tests__/jarvis-session-observer.test.mjs new file mode 100644 index 000000000..e66f016ae --- /dev/null +++ b/tools/priority/__tests__/jarvis-session-observer.test.mjs @@ -0,0 +1,439 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + DEFAULT_OUTPUT_PATH, + DEFAULT_POLICY_PATH, + DEFAULT_RUNTIME_DIR, + DEFAULT_TAIL_LINES, + observeJarvisSessionObserver, + parseArgs +} from '../jarvis-session-observer.mjs'; + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'jarvis-session-observer-')); +} + +function writeJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function writeText(filePath, text) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, text, 'utf8'); +} + +test('parseArgs exposes deterministic defaults', () => { + const options = parseArgs(['node', 'jarvis-session-observer.mjs']); + assert.equal(options.runtimeDir, DEFAULT_RUNTIME_DIR); + assert.equal(options.outputPath, DEFAULT_OUTPUT_PATH); + assert.equal(options.policyPath, DEFAULT_POLICY_PATH); + assert.equal(options.tailLines, DEFAULT_TAIL_LINES); +}); + +test('observeJarvisSessionObserver projects an active manual Windows Docker session with daemon visibility', async () => { + const repoRoot = createTempDir(); + const runtimeDir = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime'); + writeJson(path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), { + schema: 'priority/delivery-agent-policy@v1', + capitalFabric: { + maxLogicalLaneCount: 8, + specialtyLanes: [ + { + id: 'jarvis', + enabled: true, + primaryRecordedResponsibility: 'Sagan', + maxInstanceCount: 2, + purpose: 'windows-docker-iterative-development', + preferredExecutionPlane: 'local-docker-windows', + preferredContainerImage: 'nationalinstruments/labview:2026q1-windows', + allocationMode: 'opportunistic' + } + ] + }, + dockerRuntime: { + provider: 'native-wsl', + dockerHost: 'unix:///var/run/docker.sock', + expectedOsType: 'linux', + expectedContext: '', + manageDockerEngine: false, + allowHostEngineMutation: false + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-state.json'), { + schema: 'priority/delivery-agent-runtime-state@v1', + generatedAt: '2026-03-21T01:00:00.000Z', + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + laneLifecycle: 'coding', + logicalLaneActivation: { + effectiveLogicalLaneCount: 2 + }, + activeLane: { + laneId: 'issue-origin-1736-jarvis-session-observer', + issue: 1736, + branch: 'issue/origin-1736-jarvis-session-observer', + providerDispatch: { + executionPlane: 'local', + workerSlotId: 'worker-slot-1' + } + } + }); + writeJson(path.join(runtimeDir, 'concurrent-lane-status-receipt.json'), { + schema: 'priority/concurrent-lane-status-receipt@v1', + laneStatuses: [ + { + id: 'manual-windows-docker', + runtimeStatus: 'active', + executionPlane: 'local', + laneClass: 'manual-docker', + resourceGroup: 'docker-desktop-windows', + reasons: ['docker-engine-windows'], + metadata: { + dockerContext: 'desktop-windows', + dockerServerOs: 'windows', + branchRef: 'issue/origin-1736-jarvis-session-observer' + } + } + ] + }); + writeJson(path.join(runtimeDir, 'delivery-memory.json'), { + schema: 'priority/delivery-memory@v1', + generatedAt: '2026-03-21T01:05:00.000Z', + summary: { + targetSlotCount: 4 + } + }); + writeJson(path.join(runtimeDir, 'daemon-host-signal.json'), { + schema: 'priority/delivery-agent-host-signal@v1', + generatedAt: '2026-03-21T01:10:00.000Z', + status: 'native-wsl', + provider: 'native-wsl', + daemonFingerprint: 'abc12345', + previousFingerprint: 'abc12345', + fingerprintChanged: false, + reasons: [], + windowsDocker: { + available: true, + context: 'desktop-windows', + osType: 'windows', + operatingSystem: 'Docker Desktop', + serverName: 'docker-desktop', + platformName: 'Docker Desktop', + serverVersion: '29.2.0', + labels: [], + error: null + }, + wslDocker: { + distro: 'Ubuntu', + dockerHost: 'unix:///var/run/docker.sock', + available: true, + socketPath: '/var/run/docker.sock', + socketPresent: true, + socketOwner: 'root:docker', + socketMode: '660', + systemdState: 'running', + serviceState: 'active', + context: 'default', + osType: 'linux', + operatingSystem: 'Ubuntu 24.04.2 LTS', + serverName: 'ubuntu-native', + platformName: 'Docker Engine - Community', + serverVersion: '28.1.1', + labels: [], + isDockerDesktop: false, + error: null + }, + runnerServices: { + running: ['actions.runner.compare-vi-cli-action.develop.1', 'actions.runner.compare-vi-cli-action.develop.2'], + stopped: [] + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-host-isolation.json'), { + schema: 'priority/delivery-agent-host-isolation@v1', + lastStatus: 'native-wsl', + lastAction: 'collect', + preemptedServices: [], + counters: { + runnerPreemptionCount: 0 + } + }); + writeJson(path.join(runtimeDir, 'observer-heartbeat.json'), { + schema: 'priority/runtime-observer-heartbeat@v1', + generatedAt: '2026-03-21T01:12:00.000Z', + outcome: 'lane-tracked', + cyclesCompleted: 3, + stopRequested: false, + activeLane: { + laneId: 'issue-origin-1736-jarvis-session-observer', + issue: 1736 + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-wsl-daemon-pid.json'), { + schema: 'priority/unattended-delivery-agent-wsl-daemon-pid@v1', + generatedAt: '2026-03-21T01:13:00.000Z', + pid: 4242, + running: true, + unitName: 'comparevi-daemon', + distro: 'Ubuntu' + }); + writeJson(path.join(runtimeDir, 'docker-daemon-engine.json'), { + schema: 'priority/runtime-daemon-docker-engine@v1', + generatedAt: '2026-03-21T01:14:00.000Z', + requiredOs: 'linux', + lockPath: 'tests/results/_agent/runtime/docker-daemon-engine.lock', + lockAcquired: false, + docker: { + command: 'docker', + os: 'linux', + context: { + previous: 'desktop-windows', + active: 'desktop-linux', + mode: 'context-use', + switched: true + } + } + }); + writeText(path.join(runtimeDir, 'runtime-daemon-wsl.log'), 'daemon-line-1\ndaemon-line-2\n'); + writeText(path.join(runtimeDir, 'docker-daemon-logs.txt'), 'docker-line-1\ndocker-line-2\n'); + + const { report, outputPath } = await observeJarvisSessionObserver({ + repoRoot, + runtimeDir, + policyPath: path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), + outputPath: path.join(runtimeDir, 'jarvis-session-observer.json'), + tailLines: 2 + }); + + assert.equal(report.status, 'active'); + assert.equal(report.summary.activeSessionCount, 1); + assert.equal(report.summary.totalSessionCount, 1); + assert.equal(report.hostRuntime.source, 'daemon-host-signal'); + assert.equal(report.hostRuntime.diagnostics.managerStateAvailable, false); + assert.equal(report.daemon.daemonCutover.status, 'ready'); + assert.equal(report.daemon.daemonCutover.readyForLinuxDaemon, true); + assert.deepEqual(report.daemon.daemonCutover.requiredActions, []); + assert.equal(report.sessions[0].source, 'concurrent-lane-status'); + assert.equal(report.sessions[0].dockerContext, 'desktop-windows'); + assert.equal(report.sessions[0].dockerServerOs, 'windows'); + assert.deepEqual(report.daemon.logs.runtimeDaemonWsl.lines, ['daemon-line-1', 'daemon-line-2']); + assert.deepEqual(report.daemon.logs.dockerDaemon.lines, ['docker-line-1', 'docker-line-2']); + assert.equal(fs.existsSync(outputPath), true); +}); + +test('observeJarvisSessionObserver blocks when native-wsl daemon cutover is still desktop-backed', async () => { + const repoRoot = createTempDir(); + const runtimeDir = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime'); + writeJson(path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), { + schema: 'priority/delivery-agent-policy@v1', + capitalFabric: { + specialtyLanes: [ + { + id: 'jarvis', + enabled: true, + primaryRecordedResponsibility: 'Sagan', + maxInstanceCount: 2, + preferredExecutionPlane: 'local-docker-windows', + preferredContainerImage: 'nationalinstruments/labview:2026q1-windows' + } + ] + }, + dockerRuntime: { + provider: 'native-wsl', + dockerHost: 'unix:///var/run/docker.sock', + expectedOsType: 'linux', + expectedContext: '', + manageDockerEngine: false, + allowHostEngineMutation: false + } + }); + writeJson(path.join(runtimeDir, 'daemon-host-signal.json'), { + schema: 'priority/delivery-agent-host-signal@v1', + generatedAt: '2026-03-21T01:10:00.000Z', + status: 'desktop-backed', + provider: 'desktop', + daemonFingerprint: 'abc12345', + previousFingerprint: 'abc12345', + fingerprintChanged: false, + reasons: ['WSL Docker server resolves to Docker Desktop instead of a native distro-owned daemon.'], + windowsDocker: { + available: true, + context: 'desktop-windows', + osType: 'windows', + operatingSystem: 'Docker Desktop', + serverName: 'docker-desktop', + platformName: 'Docker Desktop', + serverVersion: '29.2.0', + labels: [], + error: null + }, + wslDocker: { + distro: 'Ubuntu', + dockerHost: 'unix:///var/run/docker.sock', + available: true, + socketPath: '/var/run/docker.sock', + socketPresent: true, + socketOwner: 'root:docker', + socketMode: '660', + systemdState: 'running', + serviceState: 'active', + context: 'default', + osType: 'linux', + operatingSystem: 'Docker Desktop', + serverName: 'docker-desktop', + platformName: 'Docker Desktop', + serverVersion: '29.2.0', + labels: ['com.docker.desktop.address=unix:///var/run/docker-cli.sock'], + isDockerDesktop: true, + error: null + }, + runnerServices: { + running: ['actions.runner.compare-vi-cli-action.develop.1', 'actions.runner.compare-vi-cli-action.develop.2'], + stopped: [] + } + }); + + const { report } = await observeJarvisSessionObserver({ + repoRoot, + runtimeDir, + policyPath: path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), + outputPath: path.join(runtimeDir, 'jarvis-session-observer.json'), + tailLines: 2 + }); + + assert.equal(report.status, 'blocked'); + assert.equal(report.summary.activeSessionCount, 0); + assert.equal(report.daemon.daemonCutover.status, 'cutover-required'); + assert.equal(report.daemon.daemonCutover.requiresOperatorCutover, true); + assert.deepEqual(report.daemon.daemonCutover.requiredActions, [ + 'Stop or explicitly govern the 2 running actions.runner.* services on this host.', + 'Switch WSL Docker to a distro-owned Linux daemon before reusing the daemon-first Linux plane.', + 'Rerun priority:delivery:host:signal.', + 'Rerun priority:jarvis:status.' + ]); + assert.match(report.daemon.daemonCutover.reason, /cut over to a distro-owned Linux daemon/i); +}); + +test('observeJarvisSessionObserver honors manager state when stale host-signal evidence was rejected', async () => { + const repoRoot = createTempDir(); + const runtimeDir = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime'); + writeJson(path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), { + schema: 'priority/delivery-agent-policy@v1', + capitalFabric: { + specialtyLanes: [ + { + id: 'jarvis', + enabled: true, + primaryRecordedResponsibility: 'Sagan', + maxInstanceCount: 2, + preferredExecutionPlane: 'local-docker-windows', + preferredContainerImage: 'nationalinstruments/labview:2026q1-windows' + } + ] + }, + dockerRuntime: { + provider: 'native-wsl', + dockerHost: 'unix:///var/run/docker.sock', + expectedOsType: 'linux', + expectedContext: '', + manageDockerEngine: false, + allowHostEngineMutation: false + } + }); + writeJson(path.join(runtimeDir, 'delivery-agent-state.json'), { + schema: 'priority/delivery-agent-runtime-state@v1', + generatedAt: '2026-03-21T01:00:00.000Z', + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action' + }); + writeJson(path.join(runtimeDir, 'delivery-agent-manager-state.json'), { + schema: 'priority/unattended-delivery-agent-manager-report@v1', + generatedAt: '2026-03-21T01:20:00.000Z', + hostSignal: null, + hostSignalDiagnostics: { + usedHostSignal: false, + reason: 'stale-before-current-manager', + hostSignalGeneratedAt: '2026-03-21T01:00:00.000Z', + managerStartedAt: '2026-03-21T01:15:00.000Z', + hostSignalRepository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action' + }, + hostIsolation: { + lastStatus: 'native-wsl', + lastAction: 'collect', + preemptedServices: [], + counters: { + runnerPreemptionCount: 0 + } + } + }); + writeJson(path.join(runtimeDir, 'daemon-host-signal.json'), { + schema: 'priority/delivery-agent-host-signal@v1', + generatedAt: '2026-03-21T01:10:00.000Z', + status: 'native-wsl', + provider: 'native-wsl', + daemonFingerprint: 'stale-host-signal-fingerprint', + previousFingerprint: 'stale-host-signal-fingerprint', + fingerprintChanged: false, + reasons: [], + windowsDocker: { + available: true, + context: 'desktop-windows', + osType: 'windows', + operatingSystem: 'Docker Desktop', + serverName: 'docker-desktop', + platformName: 'Docker Desktop', + serverVersion: '29.2.0', + labels: [], + error: null + }, + wslDocker: { + distro: 'Ubuntu', + dockerHost: 'unix:///var/run/docker.sock', + available: true, + socketPath: '/var/run/docker.sock', + socketPresent: true, + socketOwner: 'root:docker', + socketMode: '660', + systemdState: 'running', + serviceState: 'active', + context: 'default', + osType: 'linux', + operatingSystem: 'Ubuntu 24.04.2 LTS', + serverName: 'ubuntu-native', + platformName: 'Docker Engine - Community', + serverVersion: '28.1.1', + labels: [], + isDockerDesktop: false, + error: null + }, + runnerServices: { + running: [], + stopped: [] + } + }); + + const { report } = await observeJarvisSessionObserver({ + repoRoot, + runtimeDir, + policyPath: path.join(repoRoot, 'tools', 'priority', 'delivery-agent.policy.json'), + outputPath: path.join(runtimeDir, 'jarvis-session-observer.json'), + tailLines: 2 + }); + + assert.equal(report.hostRuntime.source, 'delivery-agent-manager-state'); + assert.equal(report.hostRuntime.status, 'unknown'); + assert.equal(report.hostRuntime.provider, null); + assert.equal(report.hostRuntime.diagnostics.managerStateAvailable, true); + assert.equal(report.hostRuntime.diagnostics.hostSignalDiagnostics.usedHostSignal, false); + assert.equal(report.hostRuntime.diagnostics.hostSignalDiagnostics.reason, 'stale-before-current-manager'); + assert.equal(report.daemon.daemonCutover.status, 'unknown'); + assert.match(report.daemon.daemonCutover.reason, /could not determine whether the Linux daemon plane is reusable/i); + assert.ok( + report.warnings.includes( + 'delivery-agent manager rejected daemon-host-signal.json (stale-before-current-manager).' + ) + ); + assert.equal(report.artifacts.managerStatePath, path.join(runtimeDir, 'delivery-agent-manager-state.json')); +}); diff --git a/tools/priority/__tests__/labview-2026-host-plane-runbook.test.mjs b/tools/priority/__tests__/labview-2026-host-plane-runbook.test.mjs index 0b0a17fa7..cd30fce3d 100644 --- a/tools/priority/__tests__/labview-2026-host-plane-runbook.test.mjs +++ b/tools/priority/__tests__/labview-2026-host-plane-runbook.test.mjs @@ -43,6 +43,9 @@ test('single-host runbook points to the authoritative commands and artifacts', ( assert.match(runbook, /### Docker Fast Loop Summary/); assert.match(runbook, /Host Plane Summary Reason/); assert.match(runbook, /docker-fast-loop-proof-host-plane-summary-path/); + assert.match(runbook, /dockerRuntimeCutover/); + assert.match(runbook, /pinned-wsl2-linux-daemon|desktop-linux-engine/); + assert.match(runbook, /wsl-shutdown|desktop-engine-switch-to-windows/); assert.match(runbook, /\[host-plane-split\]\[summary\].*status=.*sha256=/); }); diff --git a/tools/priority/__tests__/queue-refresh-pr.test.mjs b/tools/priority/__tests__/queue-refresh-pr.test.mjs index f6a90d2c3..4ff275887 100644 --- a/tools/priority/__tests__/queue-refresh-pr.test.mjs +++ b/tools/priority/__tests__/queue-refresh-pr.test.mjs @@ -82,6 +82,80 @@ test('parseArgs accepts queue refresh receipt and merge-summary flags', () => { }); }); +test('queue refresh receipt advertises the dequeue-update-requeue operation', async () => { + let queueReads = 0; + + const { receipt } = await runQueueRefresh({ + repoRoot: process.cwd(), + args: { + pr: 1568, + repo: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + headRemote: null, + summaryPath: 'memory://queue-refresh-1568.json', + mergeSummaryPath: 'memory://merge-sync-1568.json', + dryRun: false + }, + ensureGhCliFn: () => {}, + readPolicyFn: async () => buildQueuePolicy(), + readPullRequestViewFn: async () => ({ + id: 'PR_test_1568', + number: 1568, + state: 'OPEN', + isDraft: false, + mergeStateStatus: 'CLEAN', + mergeable: 'MERGEABLE', + baseRefName: 'develop', + url: 'https://example.test/pr/1568', + headRefName: 'issue/origin-1568-queue-refresh-helper', + headRefOid: '1234567890abcdef1234567890abcdef12345678', + headRepository: { + name: 'compare-vi-cli-action-fork' + }, + headRepositoryOwner: { + login: 'LabVIEW-Community-CI-CD' + }, + isCrossRepository: true, + autoMergeRequest: null + }), + readPullRequestQueueStateFn: async () => { + queueReads += 1; + return { + state: 'OPEN', + mergeStateStatus: 'CLEAN', + isInMergeQueue: queueReads === 1, + mergedAt: null, + autoMergeRequest: null + }; + }, + dequeuePullRequestFn: async () => ({}), + runGitCommandFn: (_root, args) => { + if (args[0] === 'status') return { status: 0, stdout: '', stderr: '' }; + if (args[0] === 'fetch') return { status: 0, stdout: '', stderr: '' }; + if (args[0] === 'rebase' && args[1] === 'upstream/develop') return { status: 0, stdout: '', stderr: '' }; + if (args[0] === 'rev-parse' && args[1] === 'HEAD') { + return { status: 0, stdout: 'abcdefabcdefabcdefabcdefabcdefabcdefabcd\n', stderr: '' }; + } + if (args[0] === 'push') return { status: 0, stdout: '', stderr: '' }; + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }, + readCurrentBranchFn: () => 'issue/origin-1568-queue-refresh-helper', + readTrackingRemoteFn: () => 'origin', + resolveHeadRemoteNameFn: () => ({ remoteName: 'origin', source: 'test' }), + runMergeSyncFn: async () => ({ + promotion: { + status: 'queued', + materialized: true + }, + finalMode: 'auto', + finalReason: 'merge-state-blocked' + }), + sleepFn: async () => {}, + writeReceiptFn: async (receiptPath) => receiptPath + }); + + assert.equal(receipt.operation, 'dequeue-update-requeue'); +}); + test('runQueueRefresh skips non-queued PRs without dequeueing, rebasing, or requeueing', async () => { let dequeueCalled = false; let mergeSyncCalled = false; diff --git a/tools/priority/__tests__/queue-update-command-surface.test.mjs b/tools/priority/__tests__/queue-update-command-surface.test.mjs new file mode 100644 index 000000000..7ac2caf63 --- /dev/null +++ b/tools/priority/__tests__/queue-update-command-surface.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +test('package.json exposes queue:update as a first-class alias to queue-refresh-pr', async () => { + const packageJson = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + assert.equal(packageJson.scripts['priority:queue:refresh'], 'node tools/priority/queue-refresh-pr.mjs'); + assert.equal(packageJson.scripts['priority:queue:update'], 'node tools/priority/queue-refresh-pr.mjs'); +}); + diff --git a/tools/priority/__tests__/run-artifact-download.test.mjs b/tools/priority/__tests__/run-artifact-download.test.mjs index a09bd81aa..62bbadec7 100644 --- a/tools/priority/__tests__/run-artifact-download.test.mjs +++ b/tools/priority/__tests__/run-artifact-download.test.mjs @@ -62,6 +62,14 @@ test('downloadNamedArtifacts downloads requested artifacts and records relative }); assert.equal(result.report.status, 'pass'); + assert.equal(result.report.destinationRoot, path.join(tmpDir, 'artifacts')); + assert.deepEqual(result.report.destinationRootPolicy, { + strategy: 'explicit', + source: 'explicit-destination-root', + baseRoot: path.join(tmpDir, 'artifacts'), + relativeRoot: null, + usesExternalRoot: true, + }); assert.equal(result.report.summary.downloadedCount, 1); assert.equal(result.report.summary.failedCount, 0); assert.equal(result.report.downloads[0].status, 'downloaded'); @@ -69,6 +77,118 @@ test('downloadNamedArtifacts downloads requested artifacts and records relative assert.ok(fs.existsSync(reportPath)); }); +test('downloadNamedArtifacts resolves relative artifact roots through the deterministic external artifact root policy', async (t) => { + const { downloadNamedArtifacts } = await loadModule(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-artifact-download-external-root-')); + const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'comparevi-external-artifacts-')); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + t.after(() => fs.rmSync(externalRoot, { recursive: true, force: true })); + const result = downloadNamedArtifacts({ + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + runId: '22872590273', + artifactNames: ['copilot-review-signal-965'], + repoRoot: tmpDir, + destinationRoot: path.join('tests', 'results', '_agent', 'reviews', 'run-artifacts'), + reportPath: path.join(tmpDir, 'report.json'), + storageRootsPolicy: { + artifacts: { + envVar: 'COMPAREVI_BURST_ARTIFACT_ROOT', + preferredRoots: ['E:\\comparevi-artifacts'], + }, + }, + env: { + COMPAREVI_BURST_ARTIFACT_ROOT: externalRoot, + }, + runGhJsonFn() { + return { + artifacts: [ + { + id: 5837347732, + name: 'copilot-review-signal-965', + size_in_bytes: 735, + expired: false, + }, + ], + }; + }, + runProcessFn(_command, args) { + const destinationIndex = args.indexOf('-D'); + const destination = args[destinationIndex + 1]; + writeFile(path.join(destination, 'copilot-review-signal.json'), '{}\n'); + return { status: 0, stdout: '', stderr: '', error: null }; + }, + }); + + assert.equal(result.report.status, 'pass'); + assert.equal( + result.report.destinationRoot, + path.join(externalRoot, 'tests', 'results', '_agent', 'reviews', 'run-artifacts'), + ); + assert.deepEqual(result.report.destinationRootPolicy, { + strategy: 'environment', + source: 'COMPAREVI_BURST_ARTIFACT_ROOT', + baseRoot: externalRoot, + relativeRoot: path.join('tests', 'results', '_agent', 'reviews', 'run-artifacts'), + usesExternalRoot: true, + }); + assert.equal( + result.report.downloads[0].destination, + path.join(externalRoot, 'tests', 'results', '_agent', 'reviews', 'run-artifacts', 'copilot-review-signal-965'), + ); +}); + +test('downloadNamedArtifacts prefers the checked-in E: artifact root when no override is present', async (t) => { + const { downloadNamedArtifacts } = await loadModule(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-artifact-download-policy-root-')); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + const result = downloadNamedArtifacts({ + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + runId: '22872590273', + artifactNames: ['copilot-review-signal-965'], + repoRoot: tmpDir, + destinationRoot: path.join('tests', 'results', '_agent', 'reviews', 'run-artifacts'), + reportPath: path.join(tmpDir, 'report.json'), + storageRootsPolicy: { + artifacts: { + envVar: 'COMPAREVI_BURST_ARTIFACT_ROOT', + preferredRoots: ['E:\\comparevi-artifacts'], + }, + }, + env: {}, + runGhJsonFn() { + return { + artifacts: [ + { + id: 5837347732, + name: 'copilot-review-signal-965', + size_in_bytes: 735, + expired: false, + }, + ], + }; + }, + runProcessFn(_command, args) { + const destinationIndex = args.indexOf('-D'); + const destination = args[destinationIndex + 1]; + writeFile(path.join(destination, 'copilot-review-signal.json'), '{}\n'); + return { status: 0, stdout: '', stderr: '', error: null }; + }, + }); + + assert.equal(result.report.status, 'pass'); + assert.equal( + result.report.destinationRoot, + path.join('E:\\comparevi-artifacts', 'tests', 'results', '_agent', 'reviews', 'run-artifacts'), + ); + assert.deepEqual(result.report.destinationRootPolicy, { + strategy: 'policy-preferred-root', + source: 'delivery-agent.policy.json#storageRoots.artifacts.preferredRoots[0]', + baseRoot: 'E:\\comparevi-artifacts', + relativeRoot: path.join('tests', 'results', '_agent', 'reviews', 'run-artifacts'), + usesExternalRoot: true, + }); +}); + test('downloadNamedArtifacts classifies policy wrapper rejections explicitly', async (t) => { const { downloadNamedArtifacts } = await loadModule(); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-artifact-download-policy-')); diff --git a/tools/priority/__tests__/run-sync-standing-priority.test.mjs b/tools/priority/__tests__/run-sync-standing-priority.test.mjs new file mode 100644 index 000000000..41485bee8 --- /dev/null +++ b/tools/priority/__tests__/run-sync-standing-priority.test.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { resolvePrioritySyncExecutionRoot, runPrioritySync } from '../run-sync-standing-priority.mjs'; + +function createGitSpawnSync({ currentBranch, worktreeText, dirtyRoots = new Map(), nodeStatus = 0, nodeStdout = '', nodeStderr = '' }) { + const calls = []; + const spawnSyncFn = (command, args, options = {}) => { + calls.push({ + command, + args: [...args], + cwd: options.cwd + }); + + if (command === 'git' && args[0] === 'branch' && args[1] === '--show-current') { + return { status: 0, stdout: `${currentBranch}\n`, stderr: '' }; + } + + if (command === 'git' && args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') { + return { status: 0, stdout: worktreeText, stderr: '' }; + } + + if (command === 'git' && args[0] === 'status' && args[1] === '--porcelain') { + return { status: 0, stdout: dirtyRoots.get(path.resolve(options.cwd)) ?? '', stderr: '' }; + } + + if (command === process.execPath) { + return { status: nodeStatus, stdout: nodeStdout, stderr: nodeStderr }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }; + + return { spawnSyncFn, calls }; +} + +function buildWorktreeList(repoRoot, helperRoots = []) { + const entries = [ + `worktree ${repoRoot}`, + 'HEAD 1111111', + 'branch refs/heads/issue/origin-1744-router-sync', + '' + ]; + + for (const helperRoot of helperRoots) { + entries.push( + `worktree ${helperRoot}`, + 'HEAD 2222222', + 'branch refs/heads/develop', + '' + ); + } + + return `${entries.join('\n')}\n`; +} + +test('resolvePrioritySyncExecutionRoot delegates work branches to a clean develop helper', () => { + const repoRoot = path.resolve('C:/repo/issue-1744'); + const helperRoot = path.resolve('C:/repo/develop-clean'); + const { spawnSyncFn } = createGitSpawnSync({ + currentBranch: 'issue/origin-1744-router-sync', + worktreeText: buildWorktreeList(repoRoot, [helperRoot]), + dirtyRoots: new Map([[helperRoot, '']]) + }); + + const plan = resolvePrioritySyncExecutionRoot({ + repoRoot, + spawnSyncFn + }); + + assert.equal(plan.executionRepoRoot, helperRoot); + assert.equal(plan.delegated, true); + assert.equal(plan.reason, 'clean-develop-helper'); +}); + +test('resolvePrioritySyncExecutionRoot falls back when only dirty develop helpers exist', () => { + const repoRoot = path.resolve('C:/repo/issue-1744'); + const helperRoot = path.resolve('C:/repo/develop-dirty'); + const { spawnSyncFn } = createGitSpawnSync({ + currentBranch: 'issue/origin-1744-router-sync', + worktreeText: buildWorktreeList(repoRoot, [helperRoot]), + dirtyRoots: new Map([[helperRoot, ' M tests/results/_agent/issue/router.json\n']]) + }); + + const plan = resolvePrioritySyncExecutionRoot({ + repoRoot, + spawnSyncFn + }); + + assert.equal(plan.executionRepoRoot, repoRoot); + assert.equal(plan.delegated, false); + assert.equal(plan.reason, 'dirty-develop-helper'); +}); + +test('runPrioritySync executes the helper script from a clean develop checkout while writing into the caller repo', () => { + const repoRoot = path.resolve('C:/repo/issue-1744'); + const helperRoot = path.resolve('C:/repo/develop-clean'); + const stdoutChunks = []; + const stderrChunks = []; + const { spawnSyncFn, calls } = createGitSpawnSync({ + currentBranch: 'issue/origin-1744-router-sync', + worktreeText: buildWorktreeList(repoRoot, [helperRoot]), + dirtyRoots: new Map([[helperRoot, '']]), + nodeStatus: 0, + nodeStdout: '[priority] Standing issue: #1744\n', + nodeStderr: '' + }); + + const result = runPrioritySync({ + argv: ['node', 'run-sync-standing-priority.mjs', '--fail-on-missing'], + repoRoot, + spawnSyncFn, + stdout: { write: (text) => stdoutChunks.push(text) }, + stderr: { write: (text) => stderrChunks.push(text) } + }); + + const nodeInvocation = calls.find((entry) => entry.command === process.execPath); + assert.ok(nodeInvocation, 'expected node helper invocation'); + assert.equal(nodeInvocation.cwd, repoRoot); + assert.equal(nodeInvocation.args[0], path.join(helperRoot, 'tools', 'priority', 'sync-standing-priority.mjs')); + assert.equal(result.status, 0); + assert.match(stdoutChunks.join(''), /delegated to clean develop helper/i); + assert.match(stdoutChunks.join(''), /Standing issue: #1744/); + assert.equal(stderrChunks.join(''), ''); +}); diff --git a/tools/priority/__tests__/runtime-daemon.test.mjs b/tools/priority/__tests__/runtime-daemon.test.mjs index 29ed4e502..f37f0bac1 100644 --- a/tools/priority/__tests__/runtime-daemon.test.mjs +++ b/tools/priority/__tests__/runtime-daemon.test.mjs @@ -12,6 +12,26 @@ async function readJson(filePath) { return JSON.parse(await readFile(filePath, 'utf8')); } +const REPO_LOCAL_STORAGE_ROOTS_POLICY = Object.freeze({ + worktrees: { + envVar: 'COMPAREVI_BURST_WORKTREE_ROOT', + preferredRoots: ['.runtime-worktrees'] + } +}); + +function makeRepoLocalDeliveryPolicyFn() { + return ({ defaultPolicy }) => ({ + ...defaultPolicy, + storageRoots: { + ...defaultPolicy.storageRoots, + worktrees: { + ...defaultPolicy.storageRoots.worktrees, + preferredRoots: ['.runtime-worktrees'] + } + } + }); +} + function makeLeaseDeps() { const calls = []; return { @@ -165,6 +185,7 @@ test('runtime-daemon wrapper defaults to the comparevi adapter', async () => { }, { platform: 'linux', + loadDeliveryAgentPolicyFn: makeRepoLocalDeliveryPolicyFn(), resolveRepoRootFn: () => repoRoot, loadBranchClassContractFn: () => makeRuntimeBranchContract(), nowFactory: () => new Date(Date.UTC(2026, 2, 10, 17, 0, tick++)), @@ -237,6 +258,7 @@ test('runtime-daemon wrapper schedules from the comparevi standing-priority cach }, { platform: 'linux', + loadDeliveryAgentPolicyFn: makeRepoLocalDeliveryPolicyFn(), resolveRepoRootFn: () => repoRoot, loadBranchClassContractFn: () => makeRuntimeBranchContract(), resolveStandingPriorityForRepoFn: async () => ({ @@ -283,7 +305,8 @@ test('comparevi worker checkout allocator refreshes and reuses an existing lane const { checkoutPath } = compareviRuntimeTest.resolveCompareviWorkerCheckoutPath({ repoRoot, repository: 'example/repo', - laneId: 'personal-995' + laneId: 'personal-995', + storageRootsPolicy: REPO_LOCAL_STORAGE_ROOTS_POLICY }); const worktreeAdminDir = path.join(repoRoot, '.git', 'worktrees', 'personal-995'); await mkdir(checkoutPath, { recursive: true }); @@ -302,6 +325,7 @@ test('comparevi worker checkout allocator refreshes and reuses an existing lane stepOptions: {} }, deps: { + loadDeliveryAgentPolicyFn: makeRepoLocalDeliveryPolicyFn(), platform: 'linux', execFileFn: async (command, args, options) => { calls.push({ command, args, options }); @@ -687,7 +711,8 @@ test('comparevi worker checkout allocator reuses runtime worktrees from a clean const { checkoutPath } = compareviRuntimeTest.resolveCompareviWorkerCheckoutPath({ repoRoot, repository: 'example/repo', - laneId + laneId, + storageRootsPolicy: REPO_LOCAL_STORAGE_ROOTS_POLICY }); const worktreeAdminDir = path.join(commonRepoRoot, '.git', 'worktrees', laneId); @@ -706,6 +731,7 @@ test('comparevi worker checkout allocator reuses runtime worktrees from a clean stepOptions: {} }, deps: { + loadDeliveryAgentPolicyFn: makeRepoLocalDeliveryPolicyFn(), platform: 'linux', execFileFn: async (command, args) => { if (command !== 'git') { @@ -750,13 +776,69 @@ test('comparevi worker checkout path sanitizes traversal-only segments and keeps const { checkoutRoot, checkoutPath } = compareviRuntimeTest.resolveCompareviWorkerCheckoutPath({ repoRoot, repository: '', - laneId: '..' + laneId: '..', + storageRootsPolicy: REPO_LOCAL_STORAGE_ROOTS_POLICY }); assert.equal(checkoutRoot, path.join(repoRoot, '.runtime-worktrees', path.basename(repoRoot))); assert.equal(checkoutPath, path.join(checkoutRoot, 'runtime')); }); +test('comparevi worker checkout location honors deterministic external burst roots from environment and policy', async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), 'runtime-daemon-worker-external-root-')); + const externalRoot = path.join(os.tmpdir(), 'comparevi-external-lanes'); + const { checkoutRoot, checkoutPath, checkoutRootPolicy } = compareviRuntimeTest.resolveCompareviWorkerCheckoutLocation({ + repoRoot, + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + laneId: 'origin-1727', + storageRootsPolicy: { + worktrees: { + envVar: 'COMPAREVI_BURST_WORKTREE_ROOT', + preferredRoots: ['E:\\comparevi-lanes'] + } + }, + env: { + COMPAREVI_BURST_WORKTREE_ROOT: externalRoot + } + }); + + assert.equal(checkoutRoot, path.join(externalRoot, 'LabVIEW-Community-CI-CD--compare-vi-cli-action')); + assert.equal(checkoutPath, path.join(checkoutRoot, 'origin-1727')); + assert.deepEqual(checkoutRootPolicy, { + strategy: 'environment', + source: 'COMPAREVI_BURST_WORKTREE_ROOT', + baseRoot: externalRoot, + relativeRoot: 'LabVIEW-Community-CI-CD--compare-vi-cli-action', + usesExternalRoot: true + }); +}); + +test('comparevi worker checkout location prefers the checked-in E: burst root when no override is present', async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), 'runtime-daemon-worker-policy-root-')); + const { checkoutRoot, checkoutPath, checkoutRootPolicy } = compareviRuntimeTest.resolveCompareviWorkerCheckoutLocation({ + repoRoot, + repository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + laneId: 'origin-1727', + storageRootsPolicy: { + worktrees: { + envVar: 'COMPAREVI_BURST_WORKTREE_ROOT', + preferredRoots: ['E:\\comparevi-lanes'] + } + }, + env: {} + }); + + assert.equal(checkoutRoot, path.join('E:\\comparevi-lanes', 'LabVIEW-Community-CI-CD--compare-vi-cli-action')); + assert.equal(checkoutPath, path.join(checkoutRoot, 'origin-1727')); + assert.deepEqual(checkoutRootPolicy, { + strategy: 'policy-preferred-root', + source: 'delivery-agent.policy.json#storageRoots.worktrees.preferredRoots[0]', + baseRoot: 'E:\\comparevi-lanes', + relativeRoot: 'LabVIEW-Community-CI-CD--compare-vi-cli-action', + usesExternalRoot: true + }); +}); + test('comparevi worker path containment helper treats the root itself as within scope', () => { const runtimeRoot = path.join('C:', 'repo', '.runtime-worktrees'); assert.equal(compareviRuntimeTest.isPathWithin(runtimeRoot, runtimeRoot), true); diff --git a/tools/priority/__tests__/runtime-supervisor.test.mjs b/tools/priority/__tests__/runtime-supervisor.test.mjs index 613855e7c..f84615168 100644 --- a/tools/priority/__tests__/runtime-supervisor.test.mjs +++ b/tools/priority/__tests__/runtime-supervisor.test.mjs @@ -591,6 +591,14 @@ test('buildCompareviTaskPacket projects concurrent lane status receipts from the } }, preparedWorker: { + checkoutRoot: path.join('E:', 'comparevi-lanes', 'LabVIEW-Community-CI-CD--compare-vi-cli-action'), + checkoutRootPolicy: { + strategy: 'policy-preferred-root', + source: 'delivery-agent.policy.json#storageRoots.worktrees.preferredRoots[0]', + baseRoot: path.join('E:', 'comparevi-lanes'), + relativeRoot: 'LabVIEW-Community-CI-CD--compare-vi-cli-action', + usesExternalRoot: true + }, checkoutPath, slotId: 'worker-slot-2' }, @@ -717,6 +725,17 @@ test('buildCompareviTaskPacket projects concurrent lane status receipts from the assert.equal(packet.evidence.delivery.concurrentLaneStatus.summary.orchestratorDisposition, 'wait-hosted-run'); assert.equal(packet.evidence.delivery.concurrentLaneStatus.summary.deferredLaneCount, 1); assert.equal(packet.evidence.delivery.workerProviderSelection.selectedAssignmentMode, 'async-validation'); + assert.equal( + packet.evidence.lane.workerCheckoutRoot, + path.join('E:', 'comparevi-lanes', 'LabVIEW-Community-CI-CD--compare-vi-cli-action') + ); + assert.deepEqual(packet.evidence.lane.workerCheckoutRootPolicy, { + strategy: 'policy-preferred-root', + source: 'delivery-agent.policy.json#storageRoots.worktrees.preferredRoots[0]', + baseRoot: path.join('E:', 'comparevi-lanes'), + relativeRoot: 'LabVIEW-Community-CI-CD--compare-vi-cli-action', + usesExternalRoot: true + }); }); test('buildCompareviTaskPacket fails closed when the branch class contract has no matching plane transition', async () => { diff --git a/tools/priority/__tests__/security-alert-reconciliation-register.test.mjs b/tools/priority/__tests__/security-alert-reconciliation-register.test.mjs new file mode 100644 index 000000000..48f479dab --- /dev/null +++ b/tools/priority/__tests__/security-alert-reconciliation-register.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +function readText(relativePath) { + return readFileSync(path.join(repoRoot, relativePath), 'utf8'); +} + +test('security alert reconciliation register is checked in and anchored to the live intake receipt', () => { + const manifest = JSON.parse(readText('docs/documentation-manifest.json')); + const docsEntry = manifest.entries.find((entry) => entry.name === 'Docs Tree'); + const register = readText('docs/knowledgebase/Security-Alert-Reconciliation-Register.md'); + const report = JSON.parse(readText('tests/results/_agent/security/security-intake-report.json')); + + assert.ok(docsEntry); + assert.ok(docsEntry.files.includes('docs/knowledgebase/Security-Alert-Reconciliation-Register.md')); + assert.match(register, /security-intake-report\.json/); + assert.match(register, /platform-stale/); + assert.match(register, /#1426/); + assert.match(register, /security-intake\.mjs/); + assert.match(register, /security-intake\.test\.mjs/); + assert.match(register, /security-intake-schema\.test\.mjs/); + assert.equal(report.status, 'platform-stale'); + assert.equal(report.verification.platformStale, true); + assert.deepEqual(report.verification.verifiedAlertNumbers, [3, 4]); +}); diff --git a/tools/priority/__tests__/template-agent-verification-report-schema.test.mjs b/tools/priority/__tests__/template-agent-verification-report-schema.test.mjs index 8c8b114a7..12d43d595 100644 --- a/tools/priority/__tests__/template-agent-verification-report-schema.test.mjs +++ b/tools/priority/__tests__/template-agent-verification-report-schema.test.mjs @@ -50,7 +50,16 @@ test('template-agent verification report matches the checked-in schema', () => { durationSeconds: 240, provider: 'hosted-github-workflow', runUrl: 'https://github.com/example/run/1', - templateRepo: null, + templateRepo: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + templateVersion: 'v0.1.0', + templateRef: 'v0.1.0', + cookiecutterVersion: '2.7.1', + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:v0.1.0', + generatedConsumerWorkspaceRoot: 'E:\\comparevi-template-consumers\\run-1', + laneId: 'lane-template-verify', + agentId: 'darwin', + fundingWindowId: 'HQ1VJLMV-0027', failOnBlockers: true }, { @@ -65,6 +74,8 @@ test('template-agent verification report matches the checked-in schema', () => { addFormats(ajv); const validate = ajv.compile(schema); assert.equal(validate(report), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(report.provenance.templateDependency.version, 'v0.1.0'); + assert.equal(report.provenance.execution.executionPlane, 'linux-tools-image'); }); test('checked-in template-agent verification report stays as the machine-readable pending seed', () => { @@ -84,4 +95,7 @@ test('checked-in template-agent verification report stays as the machine-readabl assert.equal(report.summary.status, 'pending'); assert.equal(report.verification.status, 'pending'); assert.equal(report.summary.recommendation, 'wait-for-template-verification'); + assert.equal(report.provenance.templateDependency.repository, 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate'); + assert.equal(report.provenance.templateDependency.version, null); + assert.equal(report.provenance.execution.agentId, null); }); diff --git a/tools/priority/__tests__/template-agent-verification-report.test.mjs b/tools/priority/__tests__/template-agent-verification-report.test.mjs index f4d5b373b..3065d0e7f 100644 --- a/tools/priority/__tests__/template-agent-verification-report.test.mjs +++ b/tools/priority/__tests__/template-agent-verification-report.test.mjs @@ -27,7 +27,27 @@ test('parseArgs captures template-agent verification report inputs', () => { '--duration-seconds', '240', '--run-url', - 'https://github.com/example/run/1' + 'https://github.com/example/run/1', + '--template-repo', + 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + '--template-version', + 'v0.1.0', + '--template-ref', + 'v0.1.0', + '--cookiecutter-version', + '2.7.1', + '--execution-plane', + 'linux-tools-image', + '--container-image', + 'ghcr.io/labview-community-ci-cd/comparevi-tools:v0.1.0', + '--generated-consumer-workspace-root', + 'E:\\comparevi-template-consumers\\run-1', + '--lane-id', + 'lane-template-verify', + '--agent-id', + 'darwin', + '--funding-window-id', + 'HQ1VJLMV-0027' ]); assert.equal(options.iterationLabel, 'post-merge #1635'); @@ -35,6 +55,10 @@ test('parseArgs captures template-agent verification report inputs', () => { assert.equal(options.verificationStatus, 'pass'); assert.equal(options.durationSeconds, 240); assert.equal(options.runUrl, 'https://github.com/example/run/1'); + assert.match(options.templatePolicyPath, /tools[\\/]policy[\\/]template-dependency\.json$/); + assert.equal(options.templateVersion, 'v0.1.0'); + assert.equal(options.cookiecutterVersion, '2.7.1'); + assert.equal(options.executionPlane, 'linux-tools-image'); assert.equal(options.outputPath, DEFAULT_OUTPUT_PATH); }); @@ -82,7 +106,16 @@ test('evaluateTemplateAgentVerificationReport passes when the reserved hosted la durationSeconds: 240, provider: 'hosted-github-workflow', runUrl: 'https://github.com/example/run/1', - templateRepo: null + templateRepo: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + templateVersion: 'v0.1.0', + templateRef: 'v0.1.0', + cookiecutterVersion: '2.7.1', + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:v0.1.0', + generatedConsumerWorkspaceRoot: 'E:\\comparevi-template-consumers\\run-1', + laneId: 'lane-template-verify', + agentId: 'darwin', + fundingWindowId: 'HQ1VJLMV-0027' }); assert.equal(report.summary.status, 'pass'); @@ -91,6 +124,10 @@ test('evaluateTemplateAgentVerificationReport passes when the reserved hosted la assert.equal(report.lane.implementationSlotsRemaining, 3); assert.equal(report.metrics.durationWithinGoal, true); assert.equal(report.blockers.length, 0); + assert.equal(report.provenance.templateDependency.repository, 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate'); + assert.equal(report.provenance.templateDependency.version, 'v0.1.0'); + assert.equal(report.provenance.templateDependency.cookiecutterVersion, '2.7.1'); + assert.equal(report.provenance.execution.agentId, 'darwin'); }); test('evaluateTemplateAgentVerificationReport blocks when the landed iteration head SHA is missing', () => { @@ -122,7 +159,7 @@ test('evaluateTemplateAgentVerificationReport blocks when the landed iteration h durationSeconds: 240, provider: 'hosted-github-workflow', runUrl: 'https://github.com/example/run/1', - templateRepo: null + templateRepo: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate' }); assert.equal(report.summary.status, 'blocked'); @@ -182,6 +219,63 @@ test('runTemplateAgentVerificationReport blocks when reserved capacity is missin assert.equal(fs.existsSync(outputPath), true); }); +test('runTemplateAgentVerificationReport defaults pinned template provenance from the checked-in policy', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'template-agent-verification-provenance-')); + const policyPath = path.join(tempDir, 'delivery-agent.policy.json'); + const outputPath = path.join(tempDir, 'template-agent-verification-report.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ + schema: 'priority/delivery-agent-policy@v1', + workerPool: { + targetSlotCount: 8 + }, + templateAgentVerificationLane: { + enabled: true, + reservedSlotCount: 1, + minimumImplementationSlots: 3, + executionMode: 'hosted-first', + targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + consumerRailBranch: 'downstream/develop', + metrics: { + maxVerificationLagIterations: 1, + maxHostedDurationMinutes: 30, + requireMachineReadableRecommendation: true + } + } + }), + 'utf8' + ); + + const { report } = runTemplateAgentVerificationReport( + { + policyPath, + outputPath, + repo: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + iterationLabel: 'post-merge #1635', + iterationRef: 'issue/origin-1632-template-agent-verification-lane', + iterationHeadSha: 'abc123', + verificationStatus: 'pending', + durationSeconds: null, + provider: 'hosted-github-workflow', + runUrl: null, + templateRepo: null, + failOnBlockers: false + }, + { + resolveRepoSlugFn: (explicitRepo) => explicitRepo + } + ); + + assert.equal(report.provenance.templateDependency.repository, 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate'); + assert.equal(report.provenance.templateDependency.version, 'v0.1.0'); + assert.equal(report.provenance.templateDependency.ref, 'v0.1.0'); + assert.equal(report.provenance.templateDependency.cookiecutterVersion, '2.7.1'); + assert.equal(report.provenance.execution.executionPlane, 'linux-tools-image'); + assert.equal(report.provenance.execution.containerImage, 'ghcr.io/labview-community-ci-cd/comparevi-tools:latest'); + assert.equal(report.provenance.execution.generatedConsumerWorkspaceRoot, null); +}); + test('CLI entrypoint writes the template-agent verification report on Windows path resolution', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'template-agent-verification-cli-')); const outputPath = path.join(tempDir, 'template-agent-verification-report.json'); diff --git a/tools/priority/__tests__/template-conveyor-command-contract.test.mjs b/tools/priority/__tests__/template-conveyor-command-contract.test.mjs new file mode 100644 index 000000000..a3bccec88 --- /dev/null +++ b/tools/priority/__tests__/template-conveyor-command-contract.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(process.cwd()); + +function readJson(relativePath) { + return JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); +} + +test('package scripts expose the pinned template conveyor entrypoints', () => { + const packageJson = readJson('package.json'); + assert.equal( + packageJson.scripts['priority:template:render:container'], + 'node tools/priority/template-cookiecutter-container.mjs' + ); + assert.equal( + packageJson.scripts['priority:template:verify'], + 'node tools/priority/template-agent-verification-report.mjs' + ); + assert.equal( + packageJson.scripts['priority:pivot:template'], + 'node tools/priority/template-pivot-gate.mjs' + ); +}); diff --git a/tools/priority/__tests__/template-cookiecutter-container.test.mjs b/tools/priority/__tests__/template-cookiecutter-container.test.mjs new file mode 100644 index 000000000..a3791a4ad --- /dev/null +++ b/tools/priority/__tests__/template-cookiecutter-container.test.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + buildContainerName, + buildCookiecutterPythonScript, + buildTemplateCookiecutterContainerPlan, + parseArgs, + resolveContainerUser, + resolveHostProjectDir, + runTemplateCookiecutterContainer, + slugifySegment +} from '../template-cookiecutter-container.mjs'; + +test('parseArgs captures helper overrides and explicit dry-run flags', () => { + const parsed = parseArgs([ + 'node', + 'template-cookiecutter-container.mjs', + '--policy-path', + 'tools/policy/template-dependency.json', + '--output', + 'tests/results/_agent/template-cookiecutter/template-cookiecutter-container.json', + '--workspace-root', + 'E:\\comparevi-template-consumers', + '--lane-id', + 'issue/origin-1743-template-cookiecutter-conveyor', + '--run-id', + 'run-1743', + '--context-file', + 'tests/fixtures/cookiecutter/template-context.json', + '--container-image', + 'comparevi-tools:cookiecutter', + '--dry-run', + '--no-fail-on-error' + ]); + + assert.equal(parsed.policyPath, 'tools/policy/template-dependency.json'); + assert.equal(parsed.outputPath, 'tests/results/_agent/template-cookiecutter/template-cookiecutter-container.json'); + assert.equal(parsed.workspaceRoot, 'E:\\comparevi-template-consumers'); + assert.equal(parsed.laneId, 'issue/origin-1743-template-cookiecutter-conveyor'); + assert.equal(parsed.runId, 'run-1743'); + assert.equal(parsed.contextFilePath, 'tests/fixtures/cookiecutter/template-context.json'); + assert.equal(parsed.containerImage, 'comparevi-tools:cookiecutter'); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.failOnError, false); +}); + +test('build helpers produce deterministic container and workspace identities', () => { + assert.equal(slugifySegment('Issue/Origin 1743: Template Cookiecutter!'), 'issue-origin-1743-template-cookiecutter'); + assert.equal(slugifySegment('', 'fallback-name'), 'fallback-name'); + + const policy = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), 'utf8')); + assert.equal(buildContainerName(policy, 'issue/origin-1743-template-cookiecutter-conveyor', 'run-1743'), 'comparevi-template-issue-origin-1743-template-cookiecutter-conveyor-run-1743'); + const planA = buildTemplateCookiecutterContainerPlan( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + workspaceRoot: 'C:\\comparevi-template-consumers', + laneId: 'issue/origin-1743-template-cookiecutter-conveyor', + runId: 'run-1743', + context: { + template_name: 'LabviewGitHubCiTemplate', + version: 'v0.1.0' + } + }, + { + now: new Date('2026-03-21T19:00:00.000Z'), + uniqueSuffixFn: () => 'abc123', + platform: 'win32' + } + ); + const planB = buildTemplateCookiecutterContainerPlan( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + workspaceRoot: 'C:\\comparevi-template-consumers', + laneId: 'issue/origin-1743-template-cookiecutter-conveyor', + runId: 'run-1743b', + context: { + template_name: 'LabviewGitHubCiTemplate', + version: 'v0.1.0' + } + }, + { + now: new Date('2026-03-21T19:00:00.000Z'), + uniqueSuffixFn: () => 'def456', + platform: 'win32' + } + ); + + assert.equal(planA.policy.schema, 'priority/template-dependency@v1'); + assert.equal(planA.checkout, 'v0.1.0'); + assert.equal(planA.containerImage, policy.container.image); + assert.equal(planA.policy.effectiveContainerImage, undefined); + assert.match(planA.containerName, /^comparevi-template-issue-origin-1743-template-cookiecutter-conveyor-run-1743$/); + assert.match(planA.hostRunRoot, /issue-origin-1743-template-cookiecutter-conveyor[\\/]+run-1743$/); + assert.match(planA.hostHomeRoot, /issue-origin-1743-template-cookiecutter-conveyor[\\/]+run-1743[\\/]+\.home$/); + assert.match(planA.containerWorkspaceRoot, /\/workspace\/issue-origin-1743-template-cookiecutter-conveyor\/run-1743$/); + assert.match(planA.containerHomeRoot, /\/workspace\/issue-origin-1743-template-cookiecutter-conveyor\/run-1743\/\.home$/); + assert.match(planA.dockerArgs.join(' '), /from cookiecutter\.main import cookiecutter/); + assert.match(planA.dockerArgs.join(' '), /checkout = "v0\.1\.0"/); + assert.match(planA.dockerArgs.join(' '), /HOME=\/workspace\/issue-origin-1743-template-cookiecutter-conveyor\/run-1743\/\.home/); + assert.equal(planA.containerUser, null); + assert.notEqual(planA.containerName, planB.containerName); + assert.notEqual(planA.hostRunRoot, planB.hostRunRoot); +}); + +test('build helpers default to the checked-in deterministic template context', () => { + const plan = buildTemplateCookiecutterContainerPlan( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + workspaceRoot: 'C:\\comparevi-template-consumers', + laneId: 'logical-lane-03', + runId: 'template-proof' + }, + { + now: new Date('2026-03-21T19:00:00.000Z'), + uniqueSuffixFn: () => 'abc123', + platform: 'win32' + } + ); + + assert.equal(plan.policy.rendering.defaultContextPath, 'tests/fixtures/cookiecutter/template-context.json'); + assert.equal(plan.context.repo_slug, 'comparevi-template-consumer'); + assert.equal(plan.context.github_owner, 'LabVIEW-Community-CI-CD'); +}); + +test('resolveContainerUser maps POSIX hosts and skips Windows hosts', () => { + assert.equal(resolveContainerUser('win32', { getuid: () => 1001, getgid: () => 121 }), null); + assert.equal(resolveContainerUser('linux', { getuid: () => 1001, getgid: () => 121 }), '1001:121'); + assert.equal(resolveContainerUser('linux', {}), null); +}); + +test('build helpers project host uid/gid onto POSIX docker runs', () => { + const plan = buildTemplateCookiecutterContainerPlan( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + workspaceRoot: '/tmp/comparevi-template-consumers', + laneId: 'cookiecutter-bootstrap-linux', + runId: 'run-1743', + context: { + template_name: 'LabviewGitHubCiTemplate', + version: 'v0.1.0' + } + }, + { + now: new Date('2026-03-21T19:00:00.000Z'), + uniqueSuffixFn: () => 'abc123', + platform: 'linux', + currentProcess: { + getuid: () => 1001, + getgid: () => 121 + } + } + ); + + assert.equal(plan.containerUser, '1001:121'); + assert.match(plan.dockerArgs.join(' '), /--user 1001:121/); + assert.match(plan.dockerArgs.join(' '), /HOME=\/workspace\/cookiecutter-bootstrap-linux\/run-1743\/\.home/); +}); + +test('buildCookiecutterPythonScript wires the pinned checkout and deterministic context', () => { + const script = buildCookiecutterPythonScript({ + templateRepositoryUrl: 'https://github.com/LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate.git', + templateDirectory: null, + checkout: 'v0.1.0', + outputDir: '/workspace/template-cookiecutter/run-1/output', + context: { + pack_slug: 'template-cookiecutter', + pack_id: 'template-cookiecutter-v1' + } + }); + + assert.match(script.script, /from cookiecutter\.main import cookiecutter/); + assert.match(script.script, /checkout = "v0\.1\.0"/); + assert.match(script.script, /"overwrite_if_exists": True/); + assert.match(script.script, /COMPAREVI_TEMPLATE_EXTRA_CONTEXT_JSON/); + assert.equal(script.contextJson.includes('template-cookiecutter-v1'), true); +}); + +test('resolveHostProjectDir maps container project paths back onto the host output root', () => { + const plan = buildTemplateCookiecutterContainerPlan( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + workspaceRoot: '/tmp/comparevi-template-consumers', + laneId: 'cookiecutter-bootstrap-linux', + runId: 'run-1743' + }, + { + now: new Date('2026-03-21T19:00:00.000Z'), + uniqueSuffixFn: () => 'abc123', + platform: 'linux', + currentProcess: { + getuid: () => 1001, + getgid: () => 121 + } + } + ); + + assert.equal( + resolveHostProjectDir( + plan, + '/workspace/cookiecutter-bootstrap-linux/run-1743/output/comparevi-template-consumer' + ), + '/tmp/comparevi-template-consumers/cookiecutter-bootstrap-linux/run-1743/output/comparevi-template-consumer' + ); +}); + +test('runTemplateCookiecutterContainer writes a receipt and captures the spawned docker command', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'template-cookiecutter-container-')); + const outputPath = path.join(tempRoot, 'receipt.json'); + const contextFilePath = path.join(tempRoot, 'context.json'); + fs.writeFileSync( + contextFilePath, + `${JSON.stringify({ + pack_slug: 'template-cookiecutter', + pack_id: 'template-cookiecutter-v1', + template_name: 'LabviewGitHubCiTemplate' + }, null, 2)}\n`, + 'utf8' + ); + + const calls = []; + const { plan, receipt } = runTemplateCookiecutterContainer( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + outputPath, + workspaceRoot: 'C:\\comparevi-template-consumers', + laneId: 'issue/origin-1743-template-cookiecutter-conveyor', + runId: 'run-1743', + contextFilePath, + containerImage: 'comparevi-tools:cookiecutter', + failOnError: true + }, + { + now: new Date('2026-03-21T19:10:00.000Z'), + uniqueSuffixFn: () => 'abc123', + platform: 'win32', + spawnSyncFn: (command, args, options) => { + calls.push({ command, args, options }); + return { + status: 0, + stdout: `${JSON.stringify({ + schema: 'comparevi-cookiecutter-run@v1', + project_dir: '/workspace/issue-origin-1743-template-cookiecutter-conveyor/run-1743/output/LabviewGitHubCiTemplate' + })}\n`, + stderr: '' + }; + } + } + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].command, 'docker'); + assert.match(calls[0].args.join(' '), /--name comparevi-template-issue-origin-1743-template-cookiecutter-conveyor-run-1743/); + assert.match(calls[0].args.join(' '), /comparevi-tools:cookiecutter/); + assert.match(calls[0].args.join(' '), /COMPAREVI_TEMPLATE_CHECKOUT=v0\.1\.0/); + assert.match(calls[0].args.join(' '), /COMPAREVI_TEMPLATE_EXTRA_CONTEXT_JSON=/); + assert.match(calls[0].args.join(' '), /python3/); + assert.match(calls[0].args.join(' '), /from cookiecutter\.main import cookiecutter/); + assert.equal(receipt.status, 'pass'); + assert.equal(receipt.policy.effectiveContainerImage, 'comparevi-tools:cookiecutter'); + assert.equal(receipt.result.projectDir, '/workspace/issue-origin-1743-template-cookiecutter-conveyor/run-1743/output/LabviewGitHubCiTemplate'); + assert.equal( + receipt.result.hostProjectDir, + 'C:\\comparevi-template-consumers\\issue-origin-1743-template-cookiecutter-conveyor\\run-1743\\output\\LabviewGitHubCiTemplate' + ); + assert.equal(receipt.run.containerUser, null); + assert.equal(receipt.run.containerHomeRoot, '/workspace/issue-origin-1743-template-cookiecutter-conveyor/run-1743/.home'); + assert.equal(fs.existsSync(outputPath), true); + const persisted = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + assert.equal(persisted.run.uniqueWorkspaceRoot, true); + assert.equal(persisted.run.uniqueContainerName, true); + assert.equal(persisted.run.contextSource, 'file'); + assert.equal(persisted.run.contextFilePath, contextFilePath); + assert.equal(plan.checkout, 'v0.1.0'); +}); + +test('dry-run mode writes a receipt without invoking docker', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'template-cookiecutter-dry-run-')); + const outputPath = path.join(tempRoot, 'receipt.json'); + let spawnCalled = false; + + const { receipt } = runTemplateCookiecutterContainer( + { + policyPath: path.join(process.cwd(), 'tools', 'policy', 'template-dependency.json'), + outputPath, + workspaceRoot: 'C:\\comparevi-template-consumers', + laneId: 'issue/origin-1743-template-cookiecutter-conveyor', + runId: 'run-1743', + dryRun: true + }, + { + now: new Date('2026-03-21T19:10:00.000Z'), + uniqueSuffixFn: () => 'abc123', + platform: 'win32', + spawnSyncFn: () => { + spawnCalled = true; + return { + status: 0, + stdout: '', + stderr: '' + }; + } + } + ); + + assert.equal(spawnCalled, false); + assert.equal(receipt.status, 'dry-run'); + assert.equal(receipt.run.contextSource, 'policy-default-file'); + assert.equal(fs.existsSync(outputPath), true); +}); diff --git a/tools/priority/__tests__/template-dependency-policy.test.mjs b/tools/priority/__tests__/template-dependency-policy.test.mjs new file mode 100644 index 000000000..45310d927 --- /dev/null +++ b/tools/priority/__tests__/template-dependency-policy.test.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + DEFAULT_POLICY_PATH, + loadTemplateDependencyPolicy, + resolveWorkspaceRoot +} from '../template-cookiecutter-container.mjs'; + +const repoRoot = path.resolve(process.cwd()); + +test('template dependency policy pins the template repository release and cookiecutter runtime', () => { + const policy = loadTemplateDependencyPolicy(DEFAULT_POLICY_PATH); + + assert.equal(policy.schema, 'priority/template-dependency@v1'); + assert.equal(policy.schemaVersion, '1.0.0'); + assert.equal(policy.templateRepositorySlug, 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate'); + assert.equal(policy.templateReleaseRef, 'v0.1.0'); + assert.equal(policy.cookiecutterVersion, '2.7.1'); + assert.equal(policy.container.runtime, 'docker'); + assert.equal(policy.container.image, 'ghcr.io/labview-community-ci-cd/comparevi-tools:latest'); + assert.equal(policy.container.executionPlane, 'linux-tools-image'); + assert.equal(policy.rendering.checkout, 'v0.1.0'); + assert.equal(policy.rendering.defaultContextPath, 'tests/fixtures/cookiecutter/template-context.json'); + assert.equal(policy.rendering.deterministicInput, true); + assert.equal(policy.rendering.noInput, true); + assert.equal(policy.workspaceRoots.win32, 'E:\\comparevi-template-consumers'); + assert.equal(policy.workspaceRoots.posix, '/tmp/comparevi-template-consumers'); +}); + +test('template dependency policy validates against the checked-in schema', () => { + const schema = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'docs', 'schemas', 'template-dependency-v1.schema.json'), 'utf8') + ); + + assert.equal(schema.schema, undefined); + assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema'); + assert.equal(schema.$id, 'https://labview-community-ci-cd.github.io/compare-vi-cli-action/schemas/template-dependency-v1.schema.json'); + assert.equal(schema.properties.schema.const, 'priority/template-dependency@v1'); + assert.equal(schema.properties.templateRepositorySlug.const, 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate'); + assert.equal(schema.properties.templateReleaseRef.const, 'v0.1.0'); + assert.equal(schema.properties.cookiecutterVersion.const, '2.7.1'); + assert.equal(schema.properties.container.properties.runtime.const, 'docker'); + assert.equal(schema.properties.rendering.properties.checkout.const, 'v0.1.0'); + assert.equal( + schema.properties.rendering.properties.defaultContextPath.const, + 'tests/fixtures/cookiecutter/template-context.json' + ); + assert.equal(schema.additionalProperties, false); +}); + +test('workspace roots resolve by platform without mutating the pinned policy', () => { + const policy = JSON.parse(fs.readFileSync(DEFAULT_POLICY_PATH, 'utf8')); + + assert.equal(resolveWorkspaceRoot(policy, 'win32'), 'E:\\comparevi-template-consumers'); + assert.equal(resolveWorkspaceRoot(policy, 'linux'), '/tmp/comparevi-template-consumers'); + assert.equal( + resolveWorkspaceRoot(policy, 'win32', 'C:\\override-root'), + 'C:\\override-root' + ); +}); + +test('docker tools image policy stays pinned in the tools Dockerfile', () => { + const dockerfile = fs.readFileSync(path.join(repoRoot, 'tools', 'docker', 'Dockerfile.tools'), 'utf8'); + + assert.match(dockerfile, /cookiecutter==2\.7\.1/); + assert.match(dockerfile, /--break-system-packages/); + assert.match(dockerfile, /cookiecutter --version/); +}); diff --git a/tools/priority/__tests__/template-pivot-gate-schema.test.mjs b/tools/priority/__tests__/template-pivot-gate-schema.test.mjs index 74aceb1ed..7e257bca8 100644 --- a/tools/priority/__tests__/template-pivot-gate-schema.test.mjs +++ b/tools/priority/__tests__/template-pivot-gate-schema.test.mjs @@ -32,6 +32,12 @@ test('template pivot gate report matches the checked-in schema', async () => { sourceRepository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', targetBranch: 'develop', + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, queueEmpty: { requiredSchema: 'standing-priority/no-standing@v1', requiredReason: 'queue-empty', @@ -139,6 +145,22 @@ test('template pivot gate report matches the checked-in schema', async () => { durationSeconds: 240, runUrl: 'https://github.com/example/run/2' }, + provenance: { + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + execution: { + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:v0.1.0', + generatedConsumerWorkspaceRoot: 'E:\\comparevi-template-consumers\\run-1', + laneId: 'lane-template-verify', + agentId: 'darwin', + fundingWindowId: 'HQ1VJLMV-0027' + } + }, goals: { maxVerificationLagIterations: 1, maxHostedDurationMinutes: 30, diff --git a/tools/priority/__tests__/template-pivot-gate.test.mjs b/tools/priority/__tests__/template-pivot-gate.test.mjs index 1fc6dff4b..64dee81fb 100644 --- a/tools/priority/__tests__/template-pivot-gate.test.mjs +++ b/tools/priority/__tests__/template-pivot-gate.test.mjs @@ -46,6 +46,12 @@ test('runTemplatePivotGate reports ready only when queue-empty, rc release, and sourceRepository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', targetBranch: 'develop', + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, queueEmpty: { requiredSchema: 'standing-priority/no-standing@v1', requiredReason: 'queue-empty', @@ -152,6 +158,22 @@ test('runTemplatePivotGate reports ready only when queue-empty, rc release, and durationSeconds: 240, runUrl: 'https://github.com/example/run/2' }, + provenance: { + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + execution: { + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:v0.1.0', + generatedConsumerWorkspaceRoot: 'E:\\comparevi-template-consumers\\run-1', + laneId: 'lane-template-verify', + agentId: 'darwin', + fundingWindowId: 'HQ1VJLMV-0027' + } + }, goals: { maxVerificationLagIterations: 1, maxHostedDurationMinutes: 30, @@ -189,6 +211,8 @@ test('runTemplatePivotGate reports ready only when queue-empty, rc release, and assert.equal(report.summary.blockerCount, 0); assert.equal(report.evidence.releaseCandidate.matchesVersionPattern, true); assert.equal(report.evidence.templateAgentVerification.ready, true); + assert.equal(report.evidence.templateAgentVerification.templateDependency.matchesPolicy, true); + assert.equal(report.evidence.templateAgentVerification.execution.complete, true); assert.equal(report.policy.operatorSteeringAllowed, false); assert.equal(fs.existsSync(outputPath), true); }); @@ -207,6 +231,12 @@ test('runTemplatePivotGate fails closed when the queue is not proven empty or re sourceRepository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', targetBranch: 'develop', + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, queueEmpty: { requiredSchema: 'standing-priority/no-standing@v1', requiredReason: 'queue-empty', @@ -467,3 +497,183 @@ test('runTemplatePivotGate blocks when template-agent verification is present bu assert.equal(report.evidence.templateAgentVerification.summaryStatus, 'blocked'); assert.equal(report.evidence.templateAgentVerification.ready, false); }); + +test('runTemplatePivotGate fails closed when the verification report does not match the pinned template dependency version', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'template-pivot-gate-template-version-mismatch-')); + const policyPath = path.join(tmpDir, 'template-pivot-gate.json'); + const queueEmptyReportPath = path.join(tmpDir, 'no-standing-priority.json'); + const releaseSummaryPath = path.join(tmpDir, 'release-summary.json'); + const handoffEntrypointStatusPath = path.join(tmpDir, 'entrypoint-status.json'); + const templateAgentVerificationReportPath = path.join(tmpDir, 'template-agent-verification-report.json'); + const outputPath = path.join(tmpDir, 'template-pivot-gate-report.json'); + + writeJson(policyPath, { + schema: 'priority/template-pivot-gate-policy@v1', + schemaVersion: '1.0.0', + sourceRepository: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + targetBranch: 'develop', + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.0', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + queueEmpty: { + requiredSchema: 'standing-priority/no-standing@v1', + requiredReason: 'queue-empty', + requiredOpenIssueCount: 0 + }, + releaseCandidate: { + requiredSchema: 'agent-handoff/release-v1', + requireValid: true, + versionPattern: '^\\d+\\.\\d+\\.\\d+-rc\\.\\d+$', + versionPatternDescription: 'X.Y.Z-rc.N' + }, + handoffEntrypoint: { + requiredSchema: 'agent-handoff/entrypoint-status-v1', + requiredStatus: 'pass' + }, + decision: { + futureAgentOnly: true, + operatorSteeringAllowed: false, + requirePreciseSessionFeedback: true + }, + artifacts: { + policySchema: 'docs/schemas/template-pivot-gate-policy-v1.schema.json', + reportSchema: 'docs/schemas/template-pivot-gate-report-v1.schema.json', + defaultOutputPath: outputPath, + queueEmptyReportPath, + releaseSummaryPath, + handoffEntrypointStatusPath, + templateAgentVerificationReportPath + } + }); + writeJson(queueEmptyReportPath, { + schema: 'standing-priority/no-standing@v1', + message: 'queue empty', + reason: 'queue-empty', + openIssueCount: 0 + }); + writeJson(releaseSummaryPath, { + schema: 'agent-handoff/release-v1', + version: '0.6.3-rc.1', + valid: true, + issues: [], + checkedAt: '2026-03-21T18:00:00.000Z' + }); + writeJson(handoffEntrypointStatusPath, { + schema: 'agent-handoff/entrypoint-status-v1', + generatedAt: '2026-03-21T18:01:00.000Z', + handoffPath: 'AGENT_HANDOFF.txt', + maxLines: 80, + actualLineCount: 42, + status: 'pass', + checks: { + primaryHeading: true, + lineBudget: true, + requiredHeadings: true, + liveArtifactGuidance: true, + stableEntrypointGuidance: true, + noStatusLogGuidance: true, + machineGeneratedArtifactGuidance: true, + noDatedHistorySections: true + }, + commands: { + bootstrap: 'pwsh -NoLogo -NoProfile -File tools/priority/bootstrap.ps1', + standingPriority: 'pwsh -NoLogo -NoProfile -File tools/Get-StandingPriority.ps1 -Plain', + printHandoff: 'pwsh -NoLogo -NoProfile -File tools/Print-AgentHandoff.ps1 -ApplyToggles', + projectPortfolio: 'node tools/npm/run-script.mjs priority:project:portfolio:check', + developSync: 'node tools/npm/run-script.mjs priority:develop:sync' + }, + artifacts: { + priorityCache: '.agent_priority_cache.json', + router: 'tests/results/_agent/issue/router.json', + noStandingPriority: 'tests/results/_agent/issue/no-standing-priority.json', + dockerReviewLoopSummary: 'tests/results/_agent/verification/docker-review-loop-summary.json', + entrypointStatus: 'tests/results/_agent/handoff/entrypoint-status.json', + handoffGlob: 'tests/results/_agent/handoff/*.json', + sessionGlob: 'tests/results/_agent/sessions/*.json' + }, + violations: [] + }); + writeJson(templateAgentVerificationReportPath, { + schema: 'priority/template-agent-verification-report@v1', + generatedAt: '2026-03-21T18:02:00.000Z', + repo: 'LabVIEW-Community-CI-CD/compare-vi-cli-action', + summary: { + status: 'pass', + blockerCount: 0, + recommendation: 'continue-template-agent-loop' + }, + iteration: { + label: 'post-merge #1632', + ref: 'issue/origin-1632-template-agent-verification-lane', + headSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + }, + lane: { + enabled: true, + reservedSlotCount: 1, + minimumImplementationSlots: 3, + implementationSlotsRemaining: 3, + executionMode: 'hosted-first', + targetRepository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + consumerRailBranch: 'downstream/develop' + }, + verification: { + provider: 'hosted-github-workflow', + status: 'pass', + durationSeconds: 240, + runUrl: 'https://github.com/example/run/2' + }, + provenance: { + templateDependency: { + repository: 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate', + version: 'v0.1.1', + ref: 'v0.1.0', + cookiecutterVersion: '2.7.1' + }, + execution: { + executionPlane: 'linux-tools-image', + containerImage: 'ghcr.io/labview-community-ci-cd/comparevi-tools:v0.1.0', + generatedConsumerWorkspaceRoot: 'E:\\comparevi-template-consumers\\run-1', + laneId: 'lane-template-verify', + agentId: 'darwin', + fundingWindowId: 'HQ1VJLMV-0027' + } + }, + goals: { + maxVerificationLagIterations: 1, + maxHostedDurationMinutes: 30, + requireMachineReadableRecommendation: true + }, + metrics: { + targetSlotCount: 4, + reservedSlotCount: 1, + implementationSlotsRemaining: 3, + durationWithinGoal: true, + recommendationPresent: true + }, + blockers: [] + }); + + const { report } = await runTemplatePivotGate( + { + policyPath, + queueEmptyReportPath, + releaseSummaryPath, + handoffEntrypointStatusPath, + templateAgentVerificationReportPath, + outputPath, + repo: 'LabVIEW-Community-CI-CD/compare-vi-cli-action' + }, + { + resolveRepoSlugFn: (value) => value + } + ); + + assert.equal(report.summary.status, 'blocked'); + assert.ok(report.summary.blockers.some((entry) => entry.code === 'template-dependency-version-mismatch')); + assert.equal(report.evidence.templateAgentVerification.templateDependency.matchesPolicy, false); + assert.equal(report.evidence.templateAgentVerification.ready, false); +}); diff --git a/tools/priority/__tests__/unattended-delivery-daemon-contract.test.mjs b/tools/priority/__tests__/unattended-delivery-daemon-contract.test.mjs index cdcae95fe..af44f12a0 100644 --- a/tools/priority/__tests__/unattended-delivery-daemon-contract.test.mjs +++ b/tools/priority/__tests__/unattended-delivery-daemon-contract.test.mjs @@ -28,13 +28,18 @@ test('unattended delivery daemon knowledgebase is checked in and points to the b assert.match(guide, /priority:delivery:agent:stop/); assert.match(guide, /priority:runtime:daemon/); assert.match(guide, /priority:runtime:daemon:docker/); + assert.match(guide, /priority:jarvis:status/); assert.match(guide, /delivery-agent-state\.json/); assert.match(guide, /delivery-agent-lanes\/\.json/); assert.match(guide, /delivery-memory\.json/); + assert.match(guide, /jarvis-session-observer\.json/); assert.match(guide, /observer-heartbeat\.json/); assert.match(guide, /task-packet\.json/); assert.match(guide, /codex-state-hygiene\.json/); assert.match(guide, /lane-marketplace-snapshot\.json/); + assert.match(guide, /daemon-host-signal\.json/); + assert.match(guide, /docker-daemon-engine\.json/); + assert.match(guide, /native-wsl/); assert.match(guide, /Unattended-Delivery-Daemon-Debt-Register\.md/); assert.match(guide, /Unattended-Delivery-Daemon-Capability-Expansion-Register\.md/); assert.match(guide, /runtime-daemon\.mjs/); diff --git a/tools/priority/__tests__/workflow-pwsh-continuation-contract.test.mjs b/tools/priority/__tests__/workflow-pwsh-continuation-contract.test.mjs index 47f34971d..53446fbd4 100644 --- a/tools/priority/__tests__/workflow-pwsh-continuation-contract.test.mjs +++ b/tools/priority/__tests__/workflow-pwsh-continuation-contract.test.mjs @@ -57,6 +57,16 @@ test('publish-tools-image workflow resolves context through the dedicated helper assert.match(workflow, /node tools\/priority\/resolve-tools-image-publish-context\.mjs/); assert.match(workflow, /steps\.context\.outputs\.stable_family_version/); assert.match(workflow, /steps\.context\.outputs\.is_tools_tag/); + assert.match(workflow, /docker pull "\$image"/); + assert.match(workflow, /cookiecutter --version/); + assert.match(workflow, /COOKIECUTTER_VERSION:\s*'2\.7\.1'/); +}); + +test('publish-tools-image Dockerfile pins cookiecutter 2.7.1 for the conveyor surface', () => { + const dockerfilePath = path.join(repoRoot, 'tools', 'docker', 'Dockerfile.tools'); + const dockerfile = readFileSync(dockerfilePath, 'utf8'); + + assert.match(dockerfile, /cookiecutter==2\.7\.1/); }); test('release workflow explicitly dispatches publish-tools-image with actions write permission', () => { diff --git a/tools/priority/bootstrap-decision.psm1 b/tools/priority/bootstrap-decision.psm1 index 0f9e51652..3625cedc2 100644 --- a/tools/priority/bootstrap-decision.psm1 +++ b/tools/priority/bootstrap-decision.psm1 @@ -95,23 +95,75 @@ function Normalize-BootstrapDecisionPath { } } +function Normalize-BootstrapHelperCandidate { + param( + $Candidate + ) + + if ($null -eq $Candidate) { + return $null + } + + if ($Candidate -is [string]) { + $normalizedRoot = Normalize-BootstrapDecisionPath -Path $Candidate + if ([string]::IsNullOrWhiteSpace($normalizedRoot)) { + return $null + } + + return [pscustomobject]@{ + Root = $normalizedRoot + IsClean = $true + } + } + + $candidateRoot = $null + foreach ($propertyName in @('Root', 'Path', 'WorktreeRoot')) { + if ($Candidate.PSObject.Properties[$propertyName]) { + $candidateRoot = $Candidate.$propertyName + break + } + } + + $normalizedCandidateRoot = Normalize-BootstrapDecisionPath -Path $candidateRoot + if ([string]::IsNullOrWhiteSpace($normalizedCandidateRoot)) { + return $null + } + + $isClean = $true + foreach ($propertyName in @('IsClean', 'Clean')) { + if ($Candidate.PSObject.Properties[$propertyName]) { + $isClean = [bool]$Candidate.$propertyName + break + } + } + + return [pscustomobject]@{ + Root = $normalizedCandidateRoot + IsClean = $isClean + } +} + function Get-BootstrapHelperRootDecision { [CmdletBinding()] param( [AllowNull()][string]$CurrentBranch, [AllowNull()][string]$CurrentRepoRoot, - [AllowNull()][string[]]$DevelopWorktreeRoots + [AllowNull()][object[]]$DevelopWorktreeRoots ) $normalizedCurrentRepoRoot = Normalize-BootstrapDecisionPath -Path $CurrentRepoRoot - $normalizedDevelopRoots = @( + $normalizedDevelopCandidates = @( foreach ($root in @($DevelopWorktreeRoots)) { - $normalizedRoot = Normalize-BootstrapDecisionPath -Path $root - if (-not [string]::IsNullOrWhiteSpace($normalizedRoot)) { - $normalizedRoot + $normalizedCandidate = Normalize-BootstrapHelperCandidate -Candidate $root + if ($null -ne $normalizedCandidate) { + $normalizedCandidate } } - ) | Select-Object -Unique + ) + $normalizedDevelopRoots = @( + $normalizedDevelopCandidates | + Select-Object -ExpandProperty Root -Unique + ) if ([string]::IsNullOrWhiteSpace($normalizedCurrentRepoRoot)) { return [pscustomobject]@{ @@ -121,10 +173,35 @@ function Get-BootstrapHelperRootDecision { } } + $currentRootCandidate = @( + $normalizedDevelopCandidates | + Where-Object { $_.Root -eq $normalizedCurrentRepoRoot } | + Select-Object -First 1 + ) + $cleanAlternateRoot = @( + $normalizedDevelopCandidates | + Where-Object { $_.Root -ne $normalizedCurrentRepoRoot -and $_.IsClean } | + Select-Object -First 1 + ) + if ( $CurrentBranch -eq 'develop' -or $normalizedDevelopRoots -contains $normalizedCurrentRepoRoot ) { + if ( + $CurrentBranch -eq 'develop' -and + $currentRootCandidate.Count -gt 0 -and + -not $currentRootCandidate[0].IsClean -and + $cleanAlternateRoot.Count -gt 0 -and + -not [string]::IsNullOrWhiteSpace($cleanAlternateRoot[0].Root) + ) { + return [pscustomobject]@{ + Action = 'delegate-clean-develop-worktree' + HelperRoot = $cleanAlternateRoot[0].Root + Message = "[bootstrap] Current develop checkout '$normalizedCurrentRepoRoot' is dirty; using clean develop helper checkout '$($cleanAlternateRoot[0].Root)' for priority helpers." + } + } + return [pscustomobject]@{ Action = 'use-current-root' HelperRoot = $normalizedCurrentRepoRoot @@ -133,12 +210,19 @@ function Get-BootstrapHelperRootDecision { } if ($CurrentBranch -match '^(issue/|feature/|release/|hotfix/|bugfix/)') { - $delegateRoot = @($normalizedDevelopRoots | Where-Object { $_ -ne $normalizedCurrentRepoRoot } | Select-Object -First 1) - if ($delegateRoot.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($delegateRoot[0])) { + if ($cleanAlternateRoot.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($cleanAlternateRoot[0].Root)) { return [pscustomobject]@{ Action = 'delegate-develop-worktree' - HelperRoot = $delegateRoot[0] - Message = "[bootstrap] Current branch '$CurrentBranch' is a work branch; using develop helper checkout '$($delegateRoot[0])' for standing-priority refresh." + HelperRoot = $cleanAlternateRoot[0].Root + Message = "[bootstrap] Current branch '$CurrentBranch' is a work branch; using clean develop helper checkout '$($cleanAlternateRoot[0].Root)' for standing-priority refresh." + } + } + + if ($normalizedDevelopCandidates.Count -gt 0) { + return [pscustomobject]@{ + Action = 'use-current-root' + HelperRoot = $normalizedCurrentRepoRoot + Message = "[bootstrap] Current branch '$CurrentBranch' has only dirty develop helper checkouts; using the caller checkout for priority helpers." } } } diff --git a/tools/priority/bootstrap.ps1 b/tools/priority/bootstrap.ps1 index fdfef3df9..1215f1466 100644 --- a/tools/priority/bootstrap.ps1 +++ b/tools/priority/bootstrap.ps1 @@ -540,6 +540,21 @@ function Get-GitStatusPorcelain { return @($result.Output) } +function Test-GitWorktreeClean { + param( + [Parameter(Mandatory = $true)] + [string]$WorktreeRoot + ) + + $resolvedWorktreeRoot = (Resolve-Path -LiteralPath $WorktreeRoot).Path + $result = Invoke-GitCommand -Arguments @('-C', $resolvedWorktreeRoot, 'status', '--porcelain') -AllowFailure + if ($result.ExitCode -ne 0) { + return $false + } + + return (@($result.Output)).Count -eq 0 +} + function Test-GitBranchExists { param([Parameter(Mandatory=$true)][string]$Name) Invoke-GitCommand -Arguments @('show-ref','--verify','--quiet',"refs/heads/$Name") -AllowFailure | Out-Null @@ -562,7 +577,7 @@ function Get-DevelopWorktreeRoots { return @() } - $roots = New-Object System.Collections.Generic.List[string] + $roots = New-Object System.Collections.Generic.List[object] $currentWorktreePath = $null foreach ($line in @($result.Output)) { @@ -573,7 +588,16 @@ function Get-DevelopWorktreeRoots { } if ($text -eq 'branch refs/heads/develop' -and -not [string]::IsNullOrWhiteSpace($currentWorktreePath)) { - $roots.Add($currentWorktreePath) + $isClean = $false + try { + $isClean = Test-GitWorktreeClean -WorktreeRoot $currentWorktreePath + } catch { + $isClean = $false + } + $roots.Add([pscustomobject]@{ + Root = $currentWorktreePath + IsClean = $isClean + }) continue } @@ -582,7 +606,17 @@ function Get-DevelopWorktreeRoots { } } - return @($roots | Select-Object -Unique) + return @( + $roots | + Group-Object -Property Root | + ForEach-Object { + $first = $_.Group | Select-Object -First 1 + [pscustomobject]@{ + Root = $first.Root + IsClean = [bool]($first.IsClean) + } + } + ) } function Resolve-PriorityHelperRepoRoot { @@ -781,7 +815,7 @@ if (-not $PreflightOnly) { -WorkingDirectory $priorityWorkingDirectory ` -ScriptRelativePath 'tools/priority/sync-standing-priority.mjs' ` -RequiredPackages @('undici') ` - -Arguments @('--fail-on-missing', '--fail-on-multiple', '--auto-select-next') + -Arguments @('--fail-on-missing', '--fail-on-multiple', '--auto-select-next', '--materialize-cache') $routerPath = Join-Path $priorityWorkingDirectory 'tests/results/_agent/issue/router.json' $routerIssue = $null if (Test-Path -LiteralPath $routerPath -PathType Leaf) { diff --git a/tools/priority/concurrent-lane-plan.mjs b/tools/priority/concurrent-lane-plan.mjs index f3ba87586..7346e9c1d 100644 --- a/tools/priority/concurrent-lane-plan.mjs +++ b/tools/priority/concurrent-lane-plan.mjs @@ -179,6 +179,100 @@ function resolveDockerObservation(snapshot = null) { }; } +function resolveDockerCutoverContract(snapshot = null) { + const expected = snapshot?.expected && typeof snapshot.expected === 'object' ? snapshot.expected : {}; + const observed = snapshot?.observed && typeof snapshot.observed === 'object' ? snapshot.observed : {}; + const resultStatus = toOptionalText(snapshot?.result?.status) ?? 'missing'; + const runtimeProvider = normalizeText(expected.provider).toLowerCase() || 'unknown'; + const expectedOsType = normalizeDockerServerOs(expected.osType); + const observedOsType = normalizeDockerServerOs(observed.osType); + const expectedDockerHost = toOptionalText(expected.dockerHost); + const observedDockerHost = toOptionalText(observed.dockerHost); + const hostMutationAllowed = expected.allowHostEngineMutation === true; + const manageDockerEngine = expected.manageDockerEngine !== false; + const observedContext = toOptionalText(observed.context); + const cutoverMode = (() => { + if (runtimeProvider === 'native-wsl') { + return 'pinned-wsl2-linux-daemon'; + } + if (runtimeProvider !== 'desktop') { + return 'unknown'; + } + if (observedOsType === 'linux') { + return 'desktop-linux-engine'; + } + if (observedOsType === 'windows' && manageDockerEngine) { + return 'desktop-engine-switch-to-linux'; + } + if (observedOsType === 'windows') { + return 'desktop-windows-engine'; + } + return 'unknown'; + })(); + const restoreMode = (() => { + if (cutoverMode === 'pinned-wsl2-linux-daemon') { + return 'wsl-shutdown'; + } + if (cutoverMode === 'desktop-engine-switch-to-linux') { + return 'desktop-engine-switch-to-windows'; + } + if (cutoverMode === 'desktop-linux-engine') { + return 'none'; + } + if (cutoverMode === 'desktop-windows-engine') { + return 'manual'; + } + return 'unknown'; + })(); + const canReuseLinuxDaemon = + resultStatus === 'ok' && + ((runtimeProvider === 'native-wsl' && observedOsType === 'linux' && (!expectedDockerHost || expectedDockerHost === observedDockerHost)) || + (runtimeProvider === 'desktop' && observedOsType === 'linux')); + const requiresHostMutation = + runtimeProvider === 'desktop' && observedOsType === 'windows' && manageDockerEngine && hostMutationAllowed; + const requiresWslShutdown = + runtimeProvider === 'desktop' && observedOsType === 'windows' && expectedOsType === 'linux' && hostMutationAllowed; + const reason = (() => { + if (canReuseLinuxDaemon) { + return runtimeProvider === 'native-wsl' + ? 'Pinned WSL2 Linux daemon is reusable as-is for concurrent hosted/manual planning.' + : 'Docker Desktop is already on the Linux engine, so the local Linux daemon is reusable as-is.'; + } + if (runtimeProvider === 'native-wsl') { + return 'Pinned WSL2 Linux daemon contract is present, but the runtime snapshot is not yet green enough to reuse safely.'; + } + if (runtimeProvider !== 'desktop') { + return 'Docker runtime provider is unknown, so the Linux-engine cutover contract cannot be asserted yet.'; + } + if (observedOsType === 'windows') { + return hostMutationAllowed + ? 'Desktop is on the Windows engine; Linux reuse requires an explicit engine cutover and possible WSL shutdown.' + : 'Desktop is on the Windows engine, but host mutation is not allowed for this run.'; + } + if (observedOsType === 'linux') { + return 'Linux engine is observed, but the runtime snapshot did not report a reusable green cutover contract.'; + } + return 'Docker engine cutover contract is unavailable from the current runtime snapshot.'; + })(); + + return { + schema: 'priority/docker-runtime-cutover-contract@v1', + runtimeProvider, + expectedOsType, + observedOsType, + expectedDockerHost, + observedDockerHost, + observedContext, + cutoverMode, + canReuseLinuxDaemon, + requiresHostMutation, + requiresWslShutdown, + restoreMode, + restoreRequired: restoreMode !== 'none' && restoreMode !== 'unknown', + reason + }; +} + function buildLane({ id, laneClass, @@ -220,6 +314,7 @@ export function buildConcurrentLanePlan({ } = {}) { const pairIndex = buildPairIndex(hostPlaneReport); const dockerObservation = resolveDockerObservation(dockerRuntimeSnapshot); + const dockerCutoverContract = resolveDockerCutoverContract(dockerRuntimeSnapshot); const shadowPolicy = resolveShadowPlanePolicy(hostPlaneReport); const nativeX32Status = normalizeText(hostPlaneReport?.native?.planes?.x32?.status).toLowerCase() || 'missing'; const nativeParallelLabVIEWSupported = hostPlaneReport?.native?.parallelLabVIEWSupported === true; @@ -488,6 +583,7 @@ export function buildConcurrentLanePlan({ native32Status: nativeX32Status, nativeParallelLabVIEWSupported }, + dockerRuntimeCutover: dockerCutoverContract, lanes, bundles, recommendedBundle, @@ -496,6 +592,9 @@ export function buildConcurrentLanePlan({ availableLaneCount: lanes.filter((entry) => entry.availability === 'available').length, hostedLaneCount: hostedLaneIds.length, localLaneCount: localLaneIds.length + (shadowAllowed ? 1 : 0), + dockerCutoverMode: dockerCutoverContract.cutoverMode, + dockerCutoverReusable: dockerCutoverContract.canReuseLinuxDaemon, + dockerCutoverRestoreMode: dockerCutoverContract.restoreMode, recommendedBundleId: recommendedBundle?.id ?? null } }; diff --git a/tools/priority/concurrent-lane-status.mjs b/tools/priority/concurrent-lane-status.mjs index da70af612..651d412f1 100644 --- a/tools/priority/concurrent-lane-status.mjs +++ b/tools/priority/concurrent-lane-status.mjs @@ -720,6 +720,13 @@ export function buildConcurrentLaneStatusReceipt({ status: toOptionalText(applyReceipt?.status), selectedBundleId: toOptionalText(applyReceipt?.summary?.selectedBundleId) }, + plan: { + path: toOptionalText(applyReceipt?.plan?.path), + schema: toOptionalText(applyReceipt?.plan?.schema), + source: toOptionalText(applyReceipt?.plan?.source), + recommendedBundleId: toOptionalText(applyReceipt?.plan?.recommendedBundleId), + selectedBundleId: toOptionalText(applyReceipt?.plan?.selectedBundle?.id) + }, hostedRun, pullRequest, laneStatuses: enrichedLaneStatuses, diff --git a/tools/priority/delivery-agent.mjs b/tools/priority/delivery-agent.mjs index 2f7739691..b94449c60 100644 --- a/tools/priority/delivery-agent.mjs +++ b/tools/priority/delivery-agent.mjs @@ -44,6 +44,7 @@ import { loadLiveAgentModelSelectionPolicy, loadLiveAgentModelSelectionReport } from './live-agent-model-selection.mjs'; +import { DEFAULT_STORAGE_ROOT_POLICY, normalizeStorageRootsPolicy } from './lib/storage-root-policy.mjs'; export const DELIVERY_AGENT_POLICY_SCHEMA = 'priority/delivery-agent-policy@v1'; export const DELIVERY_AGENT_RUNTIME_STATE_SCHEMA = 'priority/delivery-agent-runtime-state@v1'; @@ -204,6 +205,14 @@ const DEFAULT_POLICY = { } ] }, + storageRoots: { + worktrees: { + ...DEFAULT_STORAGE_ROOT_POLICY.worktrees + }, + artifacts: { + ...DEFAULT_STORAGE_ROOT_POLICY.artifacts + } + }, workerPool: { targetSlotCount: 20, prewarmSlotCount: 1, @@ -1961,6 +1970,7 @@ export async function loadDeliveryAgentPolicy(repoRoot, deps = {}) { ...DEFAULT_POLICY.dockerRuntime, ...(filePolicy?.dockerRuntime && typeof filePolicy.dockerRuntime === 'object' ? filePolicy.dockerRuntime : {}) }, + storageRoots: normalizeStorageRootsPolicy(filePolicy?.storageRoots), concurrentLaneDispatch: normalizeConcurrentLaneDispatchPolicy(filePolicy?.concurrentLaneDispatch), localReviewLoop: normalizeLocalReviewLoopPolicy(filePolicy?.localReviewLoop), codingTurnCommand: normalizeCommandList(filePolicy?.codingTurnCommand) diff --git a/tools/priority/delivery-agent.policy.json b/tools/priority/delivery-agent.policy.json index 741353484..5b95b302f 100644 --- a/tools/priority/delivery-agent.policy.json +++ b/tools/priority/delivery-agent.policy.json @@ -51,6 +51,16 @@ } ] }, + "storageRoots": { + "worktrees": { + "envVar": "COMPAREVI_BURST_WORKTREE_ROOT", + "preferredRoots": ["E:\\comparevi-lanes"] + }, + "artifacts": { + "envVar": "COMPAREVI_BURST_ARTIFACT_ROOT", + "preferredRoots": ["E:\\comparevi-artifacts"] + } + }, "workerPool": { "targetSlotCount": 20, "prewarmSlotCount": 1, diff --git a/tools/priority/develop-sync.mjs b/tools/priority/develop-sync.mjs index b20bafccf..0c96b4e05 100644 --- a/tools/priority/develop-sync.mjs +++ b/tools/priority/develop-sync.mjs @@ -218,6 +218,48 @@ function isDirtyWorktree({ repoRoot, env = process.env, spawnSyncFn = spawnSync return statusText.length > 0; } +function findDevelopHelperRoot({ + repoRoot, + requireClean = false, + env = process.env, + spawnSyncFn = spawnSync +} = {}) { + const worktreeText = runGitText(spawnSyncFn, repoRoot, ['worktree', 'list', '--porcelain'], env); + const helpers = parseGitWorktreeListPorcelain(worktreeText) + .map((entry) => ({ + ...entry, + path: path.resolve(entry.path) + })) + .filter((entry) => entry.path !== repoRoot && entry.branchRef === 'refs/heads/develop'); + + if (!requireClean) { + return helpers[0]?.path ?? null; + } + + for (const helper of helpers) { + try { + if (!isDirtyWorktree({ repoRoot: helper.path, env, spawnSyncFn })) { + return helper.path; + } + } catch {} + } + + return null; +} + +function hasDevelopHelperRoot({ + repoRoot, + env = process.env, + spawnSyncFn = spawnSync +} = {}) { + return findDevelopHelperRoot({ + repoRoot, + requireClean: false, + env, + spawnSyncFn + }) !== null; +} + function writeJsonFile(filePath, payload) { mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); @@ -289,6 +331,31 @@ export function resolveDevelopSyncExecutionRoot({ repoRoot, env = process.env, s }; } + if (currentBranch === 'develop') { + try { + if (isDirtyWorktree({ repoRoot: normalizedRepoRoot, env, spawnSyncFn })) { + const helperRoot = findDevelopHelperRoot({ + repoRoot: normalizedRepoRoot, + requireClean: true, + env, + spawnSyncFn + }); + if (helperRoot) { + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: helperRoot, + currentBranch, + mode: 'full-sync', + reason: 'dirty-develop-root-helper', + dirtyWorktree: true, + delegated: true, + helperRoot + }; + } + } + } catch {} + } + if (!isWorkBranch(currentBranch)) { return { repoRoot: normalizedRepoRoot, @@ -303,13 +370,12 @@ export function resolveDevelopSyncExecutionRoot({ repoRoot, env = process.env, s } try { - const worktreeText = runGitText(spawnSyncFn, normalizedRepoRoot, ['worktree', 'list', '--porcelain'], env); - const helperRoot = parseGitWorktreeListPorcelain(worktreeText) - .map((entry) => ({ - ...entry, - path: path.resolve(entry.path) - })) - .find((entry) => entry.path !== normalizedRepoRoot && entry.branchRef === 'refs/heads/develop')?.path; + const helperRoot = findDevelopHelperRoot({ + repoRoot: normalizedRepoRoot, + requireClean: true, + env, + spawnSyncFn + }); if (helperRoot) { return { repoRoot: normalizedRepoRoot, @@ -322,6 +388,18 @@ export function resolveDevelopSyncExecutionRoot({ repoRoot, env = process.env, s helperRoot }; } + if (hasDevelopHelperRoot({ repoRoot: normalizedRepoRoot, env, spawnSyncFn })) { + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: normalizedRepoRoot, + currentBranch, + mode: 'ref-refresh', + reason: 'dirty-develop-helper', + dirtyWorktree: false, + delegated: false, + helperRoot: null + }; + } } catch {} try { diff --git a/tools/priority/download-run-artifact.mjs b/tools/priority/download-run-artifact.mjs index c780f86aa..e7bccdc39 100644 --- a/tools/priority/download-run-artifact.mjs +++ b/tools/priority/download-run-artifact.mjs @@ -20,7 +20,7 @@ function printUsage() { console.log(' --run-id Workflow run id to download from.'); console.log(' --artifact Artifact name to download (repeatable).'); console.log(' --all Download every non-expired artifact from the run.'); - console.log(` --destination-root Destination root (default: ${DEFAULT_DESTINATION_ROOT}).`); + console.log(` --destination-root Destination root (default: policy/env-managed from ${DEFAULT_DESTINATION_ROOT}).`); console.log(` --report Output report path (default: ${DEFAULT_REPORT_PATH}).`); console.log(' --step-summary Override the GitHub step summary output path.'); console.log(' -h, --help Show help.'); @@ -43,6 +43,7 @@ export function parseArgs(argv = process.argv, env = process.env) { artifactNames: [], downloadAll: false, destinationRoot: DEFAULT_DESTINATION_ROOT, + destinationRootExplicit: false, reportPath: DEFAULT_REPORT_PATH, stepSummaryPath: normalizeText(env.GITHUB_STEP_SUMMARY), stepSummaryExplicit: false, @@ -80,7 +81,10 @@ export function parseArgs(argv = process.argv, env = process.env) { } options.artifactNames.push(artifactName); } - if (token === '--destination-root') options.destinationRoot = next; + if (token === '--destination-root') { + options.destinationRoot = next; + options.destinationRootExplicit = true; + } if (token === '--report') options.reportPath = next; if (token === '--step-summary') { options.stepSummaryPath = next; @@ -187,6 +191,7 @@ export function buildStepSummaryLines({ options, result }) { `- repository: \`${options.repo}\``, `- run id: \`${options.runId}\``, `- report: \`${result.reportPath}\``, + `- destination root: \`${result.report.destinationRoot}\``, `- requested: \`${report.summary.requestedArtifactCount}\``, `- available: \`${report.summary.availableArtifactCount}\``, `- downloaded: \`${report.summary.downloadedCount}\``, @@ -242,8 +247,11 @@ export async function main( runId: options.runId, artifactNames: options.artifactNames, downloadAll: options.downloadAll, + repoRoot: process.cwd(), destinationRoot: options.destinationRoot, + destinationRootExplicit: options.destinationRootExplicit, reportPath: options.reportPath, + env, }); let projectionFailure = false; diff --git a/tools/priority/downstream-promotion-scorecard.mjs b/tools/priority/downstream-promotion-scorecard.mjs index e67dbbcc4..4961952fc 100644 --- a/tools/priority/downstream-promotion-scorecard.mjs +++ b/tools/priority/downstream-promotion-scorecard.mjs @@ -20,6 +20,7 @@ function printUsage() { console.log('Options:'); console.log(' --success-report Downstream onboarding success report JSON path (required).'); console.log(' --feedback-report Downstream onboarding feedback report JSON path (required).'); + console.log(' --template-agent-verification-report Template-agent verification report JSON path (required).'); console.log(' --manifest-report Downstream promotion manifest JSON path (optional).'); console.log(` --output Output path (default: ${DEFAULT_OUTPUT_PATH}).`); console.log(' --repo Repository slug override.'); @@ -90,6 +91,7 @@ export function parseArgs(argv = process.argv) { const options = { successReportPath: null, feedbackReportPath: null, + templateAgentVerificationReportPath: null, manifestReportPath: null, outputPath: DEFAULT_OUTPUT_PATH, repo: null, @@ -116,6 +118,7 @@ export function parseArgs(argv = process.argv) { if ( token === '--success-report' || token === '--feedback-report' || + token === '--template-agent-verification-report' || token === '--manifest-report' || token === '--output' || token === '--repo' @@ -126,6 +129,7 @@ export function parseArgs(argv = process.argv) { index += 1; if (token === '--success-report') options.successReportPath = next; if (token === '--feedback-report') options.feedbackReportPath = next; + if (token === '--template-agent-verification-report') options.templateAgentVerificationReportPath = next; if (token === '--manifest-report') options.manifestReportPath = next; if (token === '--output') options.outputPath = next; if (token === '--repo') options.repo = next; @@ -140,6 +144,9 @@ export function parseArgs(argv = process.argv) { if (!options.help && !normalizeText(options.feedbackReportPath)) { throw new Error('Missing required option: --feedback-report .'); } + if (!options.help && !normalizeText(options.templateAgentVerificationReportPath)) { + throw new Error('Missing required option: --template-agent-verification-report .'); + } return options; } @@ -246,12 +253,61 @@ function statusFromManifestReport(payload) { }; } +function statusFromTemplateAgentVerificationReport(payload) { + if (!payload || typeof payload !== 'object') { + return { + status: 'missing', + schema: null, + summaryStatus: null, + verificationStatus: null, + targetRepository: null, + consumerRailBranch: null, + templateRepository: null, + templateVersion: null, + templateRef: null, + cookiecutterVersion: null + }; + } + + const schema = normalizeText(payload.schema) || null; + const summaryStatus = normalizeText(payload?.summary?.status) || null; + const verificationStatus = normalizeText(payload?.verification?.status) || null; + const targetRepository = normalizeText(payload?.lane?.targetRepository) || null; + const consumerRailBranch = normalizeText(payload?.lane?.consumerRailBranch) || null; + const templateRepository = normalizeText(payload?.provenance?.templateDependency?.repository) || null; + const templateVersion = normalizeText(payload?.provenance?.templateDependency?.version) || null; + const templateRef = normalizeText(payload?.provenance?.templateDependency?.ref) || null; + const cookiecutterVersion = normalizeText(payload?.provenance?.templateDependency?.cookiecutterVersion) || null; + + return { + status: + schema === 'priority/template-agent-verification-report@v1' && + summaryStatus === 'pass' && + verificationStatus === 'pass' && + targetRepository === 'LabVIEW-Community-CI-CD/LabviewGitHubCiTemplate' && + consumerRailBranch === 'downstream/develop' + ? 'pass' + : 'fail', + schema, + summaryStatus, + verificationStatus, + targetRepository, + consumerRailBranch, + templateRepository, + templateVersion, + templateRef, + cookiecutterVersion + }; +} + export function evaluateDownstreamPromotionScorecard({ successReport, feedbackReport, + templateAgentVerificationReport, manifestReport, successGate, feedbackGate, + templateAgentVerificationGate, manifestGate }) { const blockers = []; @@ -263,6 +319,12 @@ export function evaluateDownstreamPromotionScorecard({ if (!feedbackReport.exists || feedbackReport.error) { recordBlocker('feedback-report-missing', 'Downstream onboarding feedback report is missing or unreadable.'); } + if (!templateAgentVerificationReport.exists || templateAgentVerificationReport.error) { + recordBlocker( + 'template-agent-verification-report-missing', + 'Template-agent verification report is missing or unreadable.' + ); + } if (successGate.status !== 'pass') { recordBlocker( 'downstream-blockers', @@ -272,6 +334,12 @@ export function evaluateDownstreamPromotionScorecard({ if (feedbackGate.status !== 'pass') { recordBlocker('feedback-execution', `Downstream onboarding feedback execution status is ${feedbackGate.executionStatus ?? 'missing'}.`); } + if (templateAgentVerificationGate.status !== 'pass') { + recordBlocker( + 'template-agent-verification-contract', + `Template-agent verification report did not verify the pinned consumer rail evidence (status=${templateAgentVerificationGate.status}).` + ); + } if (manifestReport?.error) { recordBlocker('manifest-report-unreadable', 'Downstream promotion manifest is unreadable.'); } @@ -305,16 +373,20 @@ export function runDownstreamPromotionScorecard(rawOptions = {}) { const successReport = loadInputFile(options.successReportPath); const feedbackReport = loadInputFile(options.feedbackReportPath); + const templateAgentVerificationReport = loadInputFile(options.templateAgentVerificationReportPath); const manifestReport = options.manifestReportPath ? loadInputFile(options.manifestReportPath) : null; const successGate = statusFromSuccessReport(successReport.payload); const feedbackGate = statusFromFeedbackReport(feedbackReport.payload); + const templateAgentVerificationGate = statusFromTemplateAgentVerificationReport(templateAgentVerificationReport.payload); const manifestGate = statusFromManifestReport(manifestReport?.payload); const summary = evaluateDownstreamPromotionScorecard({ successReport, feedbackReport, + templateAgentVerificationReport, manifestReport, successGate, feedbackGate, + templateAgentVerificationGate, manifestGate }); @@ -333,6 +405,11 @@ export function runDownstreamPromotionScorecard(rawOptions = {}) { exists: feedbackReport.exists, error: feedbackReport.error }, + templateAgentVerificationReport: { + path: templateAgentVerificationReport.path, + exists: templateAgentVerificationReport.exists, + error: templateAgentVerificationReport.error + }, manifestReport: { path: manifestReport?.path ?? null, exists: manifestReport?.exists ?? false, @@ -342,6 +419,7 @@ export function runDownstreamPromotionScorecard(rawOptions = {}) { gates: { successReport: successGate, feedbackReport: feedbackGate, + templateAgentVerificationReport: templateAgentVerificationGate, manifestReport: manifestGate }, summary: { @@ -357,7 +435,11 @@ export function runDownstreamPromotionScorecard(rawOptions = {}) { compareviToolsRelease: manifestGate.compareviToolsRelease, compareviHistoryRelease: manifestGate.compareviHistoryRelease, scenarioPackIdentity: manifestGate.scenarioPackIdentity, - cookiecutterTemplateIdentity: manifestGate.cookiecutterTemplateIdentity + cookiecutterTemplateIdentity: manifestGate.cookiecutterTemplateIdentity, + templateVerificationRepository: templateAgentVerificationGate.templateRepository, + templateVerificationVersion: templateAgentVerificationGate.templateVersion, + templateVerificationRef: templateAgentVerificationGate.templateRef, + templateVerificationConsumerRailBranch: templateAgentVerificationGate.consumerRailBranch } } }; @@ -375,6 +457,8 @@ export function runDownstreamPromotionScorecard(rawOptions = {}) { `- repositories evaluated: \`${report.summary.metrics.repositoriesEvaluated ?? 'n/a'}\``, `- total blockers: \`${report.summary.metrics.totalBlockers ?? 'n/a'}\``, `- total warnings: \`${report.summary.metrics.totalWarnings ?? 'n/a'}\``, + `- template verification status: \`${report.gates.templateAgentVerificationReport.status}\``, + `- template consumer rail branch: \`${report.gates.templateAgentVerificationReport.consumerRailBranch ?? 'n/a'}\``, `- manifest status: \`${report.gates.manifestReport.status}\``, `- CompareVI.Tools: \`${report.summary.provenance.compareviToolsRelease ?? 'n/a'}\``, `- comparevi-history: \`${report.summary.provenance.compareviHistoryRelease ?? 'n/a'}\``, diff --git a/tools/priority/human-go-no-go-latest.mjs b/tools/priority/human-go-no-go-latest.mjs index 638291cfc..2d4ee5532 100644 --- a/tools/priority/human-go-no-go-latest.mjs +++ b/tools/priority/human-go-no-go-latest.mjs @@ -54,7 +54,7 @@ function printUsage() { console.log(' --run-id Explicit workflow run id to resolve.'); console.log(' --ref Optional head branch filter when selecting the latest run.'); console.log(' --fail-on-nogo Exit non-zero when the resolved decision is nogo.'); - console.log(` --destination-root Download destination root (default: ${DEFAULT_DESTINATION_ROOT}).`); + console.log(` --destination-root Download destination root (default: policy/env-managed from ${DEFAULT_DESTINATION_ROOT}).`); console.log(` --download-report Artifact download report path (default: ${DEFAULT_DOWNLOAD_REPORT_PATH}).`); console.log(` --out Summary report path (default: ${DEFAULT_REPORT_PATH}).`); console.log(' -h, --help Show help.'); @@ -243,6 +243,7 @@ export function parseArgs(argv = process.argv, environment = process.env, repoRo ref: null, failOnNogo: false, destinationRoot: DEFAULT_DESTINATION_ROOT, + destinationRootExplicit: false, downloadReportPath: DEFAULT_DOWNLOAD_REPORT_PATH, reportPath: DEFAULT_REPORT_PATH, }; @@ -277,7 +278,10 @@ export function parseArgs(argv = process.argv, environment = process.env, repoRo if (token === '--artifact') options.artifactName = normalizeText(next); if (token === '--run-id') options.runId = normalizeText(next); if (token === '--ref') options.ref = normalizeText(next); - if (token === '--destination-root') options.destinationRoot = next; + if (token === '--destination-root') { + options.destinationRoot = next; + options.destinationRootExplicit = true; + } if (token === '--download-report') options.downloadReportPath = next; if (token === '--out') options.reportPath = next; continue; @@ -378,9 +382,12 @@ export async function runHumanGoNoGoLatest({ repository: options.repo, runId: String(selectedRun.id), artifactNames: [options.artifactName], + repoRoot, destinationRoot: options.destinationRoot, + destinationRootExplicit: options.destinationRootExplicit, reportPath: options.downloadReportPath, now, + env: environment, }); if (downloadResult.report.status !== 'pass') { throw new Error(downloadResult.report.errors?.[0] ?? 'Artifact download failed.'); diff --git a/tools/priority/jarvis-session-observer.mjs b/tools/priority/jarvis-session-observer.mjs new file mode 100644 index 000000000..833e450a3 --- /dev/null +++ b/tools/priority/jarvis-session-observer.mjs @@ -0,0 +1,820 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +export const JARVIS_SESSION_OBSERVER_SCHEMA = 'priority/jarvis-session-observer@v1'; +export const DEFAULT_RUNTIME_DIR = path.join('tests', 'results', '_agent', 'runtime'); +export const DEFAULT_OUTPUT_PATH = path.join(DEFAULT_RUNTIME_DIR, 'jarvis-session-observer.json'); +export const DEFAULT_POLICY_PATH = path.join('tools', 'priority', 'delivery-agent.policy.json'); +export const DEFAULT_TAIL_LINES = 10; + +function normalizeText(value) { + if (value == null) { + return ''; + } + return String(value).trim(); +} + +function toOptionalText(value) { + const normalized = normalizeText(value); + return normalized || null; +} + +function normalizeLower(value) { + return normalizeText(value).toLowerCase(); +} + +function coercePositiveInteger(value) { + if (value == null || value === '') { + return null; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function resolvePath(repoRoot, targetPath) { + if (!targetPath) { + return repoRoot; + } + return path.isAbsolute(targetPath) ? targetPath : path.join(repoRoot, targetPath); +} + +async function readJsonIfPresent(filePath) { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { + return null; + } +} + +async function readTailIfPresent(filePath, tailLines) { + try { + const text = await fs.readFile(filePath, 'utf8'); + const lines = text.split(/\r?\n/).filter((line) => line.length > 0); + return lines.slice(Math.max(0, lines.length - tailLines)); + } catch { + return []; + } +} + +async function writeReceipt(outputPath, payload) { + const resolved = path.resolve(outputPath); + await fs.mkdir(path.dirname(resolved), { recursive: true }); + await fs.writeFile(resolved, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + return resolved; +} + +function printUsage() { + console.log('Usage: node tools/priority/jarvis-session-observer.mjs [options]'); + console.log(''); + console.log('Options:'); + console.log(` --repo-root Repository root (default: current working directory).`); + console.log(` --runtime-dir Runtime receipt directory (default: ${DEFAULT_RUNTIME_DIR}).`); + console.log(` --policy Delivery-agent policy path (default: ${DEFAULT_POLICY_PATH}).`); + console.log(` --output Observer receipt path (default: ${DEFAULT_OUTPUT_PATH}).`); + console.log(` --tail-lines Log tail line count (default: ${DEFAULT_TAIL_LINES}).`); + console.log(' -h, --help Show this message and exit.'); +} + +export function parseArgs(argv = process.argv) { + const args = argv.slice(2); + const options = { + repoRoot: process.cwd(), + runtimeDir: DEFAULT_RUNTIME_DIR, + policyPath: DEFAULT_POLICY_PATH, + outputPath: DEFAULT_OUTPUT_PATH, + tailLines: DEFAULT_TAIL_LINES, + help: false + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + const next = args[index + 1]; + if (token === '-h' || token === '--help') { + options.help = true; + continue; + } + if (['--repo-root', '--runtime-dir', '--policy', '--output', '--tail-lines'].includes(token)) { + if (!next || next.startsWith('-')) { + throw new Error(`Missing value for ${token}.`); + } + index += 1; + if (token === '--repo-root') options.repoRoot = next; + if (token === '--runtime-dir') options.runtimeDir = next; + if (token === '--policy') options.policyPath = next; + if (token === '--output') options.outputPath = next; + if (token === '--tail-lines') options.tailLines = next; + continue; + } + throw new Error(`Unknown option: ${token}`); + } + + const tailLines = coercePositiveInteger(options.tailLines); + if (!options.help && tailLines == null) { + throw new Error('Tail line count must be a positive integer.'); + } + options.tailLines = tailLines ?? DEFAULT_TAIL_LINES; + return options; +} + +function normalizeJarvisPolicy(policy = {}, runtimeState = {}) { + const capitalFabric = policy?.capitalFabric ?? {}; + const specialtyLane = Array.isArray(capitalFabric?.specialtyLanes) + ? capitalFabric.specialtyLanes.find((entry) => normalizeLower(entry?.id) === 'jarvis') ?? null + : null; + const dockerRuntime = policy?.dockerRuntime ?? {}; + const logicalLaneActivation = runtimeState?.logicalLaneActivation ?? {}; + const effectiveLogicalLaneCount = + coercePositiveInteger(logicalLaneActivation?.effectiveLogicalLaneCount) ?? + coercePositiveInteger(runtimeState?.effectiveLogicalLaneCount) ?? + coercePositiveInteger(capitalFabric?.maxLogicalLaneCount) ?? + 1; + const configuredSessionCapacity = coercePositiveInteger(specialtyLane?.maxInstanceCount) ?? 1; + const effectiveSessionCapacity = Math.max(1, Math.min(configuredSessionCapacity, effectiveLogicalLaneCount)); + + return { + specialtyLaneId: toOptionalText(specialtyLane?.id) ?? 'jarvis', + enabled: specialtyLane?.enabled !== false, + primaryRecordedResponsibility: toOptionalText(specialtyLane?.primaryRecordedResponsibility), + purpose: toOptionalText(specialtyLane?.purpose), + allocationMode: toOptionalText(specialtyLane?.allocationMode), + preferredExecutionPlane: toOptionalText(specialtyLane?.preferredExecutionPlane), + preferredContainerImage: toOptionalText(specialtyLane?.preferredContainerImage), + configuredSessionCapacity, + effectiveSessionCapacity, + effectiveLogicalLaneCount, + dockerRuntimePolicy: { + provider: toOptionalText(dockerRuntime?.provider), + expectedDockerHost: toOptionalText(dockerRuntime?.dockerHost), + expectedOsType: toOptionalText(dockerRuntime?.expectedOsType), + expectedContext: toOptionalText(dockerRuntime?.expectedContext), + manageDockerEngine: dockerRuntime?.manageDockerEngine === true, + allowHostEngineMutation: dockerRuntime?.allowHostEngineMutation === true + } + }; +} + +function normalizeHostSignalDiagnostics(diagnostics = null) { + return { + usedHostSignal: diagnostics?.usedHostSignal === true, + reason: toOptionalText(diagnostics?.reason), + hostSignalGeneratedAt: toOptionalText(diagnostics?.hostSignalGeneratedAt), + managerStartedAt: toOptionalText(diagnostics?.managerStartedAt), + hostSignalRepository: toOptionalText(diagnostics?.hostSignalRepository) + }; +} + +function normalizeHostSignal(hostSignal = null, hostIsolation = null, options = {}) { + const reasons = Array.isArray(hostSignal?.reasons) + ? hostSignal.reasons.map((entry) => normalizeText(entry)).filter(Boolean) + : []; + const diagnostics = { + managerStateAvailable: options.managerStateAvailable === true, + managerGeneratedAt: toOptionalText(options.managerGeneratedAt), + hostSignalDiagnostics: normalizeHostSignalDiagnostics(options.hostSignalDiagnostics) + }; + return { + status: toOptionalText(hostSignal?.status) ?? 'unknown', + provider: toOptionalText(hostSignal?.provider), + source: toOptionalText(options.source) ?? 'unavailable', + reasons, + daemonFingerprint: toOptionalText(hostSignal?.daemonFingerprint), + previousFingerprint: toOptionalText(hostSignal?.previousFingerprint), + fingerprintChanged: hostSignal?.fingerprintChanged === true, + windowsDocker: { + available: hostSignal?.windowsDocker?.available === true, + context: toOptionalText(hostSignal?.windowsDocker?.context), + osType: toOptionalText(hostSignal?.windowsDocker?.osType), + operatingSystem: toOptionalText(hostSignal?.windowsDocker?.operatingSystem), + serverName: toOptionalText(hostSignal?.windowsDocker?.serverName), + platformName: toOptionalText(hostSignal?.windowsDocker?.platformName), + serverVersion: toOptionalText(hostSignal?.windowsDocker?.serverVersion), + labels: Array.isArray(hostSignal?.windowsDocker?.labels) ? hostSignal.windowsDocker.labels : [], + error: toOptionalText(hostSignal?.windowsDocker?.error) + }, + wslDocker: { + distro: toOptionalText(hostSignal?.wslDocker?.distro), + dockerHost: toOptionalText(hostSignal?.wslDocker?.dockerHost), + available: hostSignal?.wslDocker?.available === true, + socketPath: toOptionalText(hostSignal?.wslDocker?.socketPath), + socketPresent: hostSignal?.wslDocker?.socketPresent === true, + socketOwner: toOptionalText(hostSignal?.wslDocker?.socketOwner), + socketMode: toOptionalText(hostSignal?.wslDocker?.socketMode), + systemdState: toOptionalText(hostSignal?.wslDocker?.systemdState), + serviceState: toOptionalText(hostSignal?.wslDocker?.serviceState), + context: toOptionalText(hostSignal?.wslDocker?.context), + osType: toOptionalText(hostSignal?.wslDocker?.osType), + operatingSystem: toOptionalText(hostSignal?.wslDocker?.operatingSystem), + serverName: toOptionalText(hostSignal?.wslDocker?.serverName), + platformName: toOptionalText(hostSignal?.wslDocker?.platformName), + serverVersion: toOptionalText(hostSignal?.wslDocker?.serverVersion), + labels: Array.isArray(hostSignal?.wslDocker?.labels) ? hostSignal.wslDocker.labels : [], + isDockerDesktop: hostSignal?.wslDocker?.isDockerDesktop === true, + error: toOptionalText(hostSignal?.wslDocker?.error) + }, + runnerServices: { + running: Array.isArray(hostSignal?.runnerServices?.running) ? hostSignal.runnerServices.running : [], + stopped: Array.isArray(hostSignal?.runnerServices?.stopped) ? hostSignal.runnerServices.stopped : [] + }, + isolation: { + lastStatus: toOptionalText(hostIsolation?.lastStatus), + lastAction: toOptionalText(hostIsolation?.lastAction), + preemptedServices: Array.isArray(hostIsolation?.preemptedServices) ? hostIsolation.preemptedServices : [], + counters: hostIsolation?.counters ?? {} + }, + diagnostics + }; +} + +function resolveHostRuntimeEvidence(managerState, hostSignal, hostIsolation, warnings) { + const managerStateAvailable = Boolean(managerState); + const managerGeneratedAt = toOptionalText(managerState?.generatedAt); + const managerHostSignalDiagnostics = managerState?.hostSignalDiagnostics ?? null; + const managerHostSignal = managerState && Object.hasOwn(managerState, 'hostSignal') + ? managerState.hostSignal + : null; + const managerHostIsolation = managerState && Object.hasOwn(managerState, 'hostIsolation') + ? managerState.hostIsolation + : hostIsolation; + const normalizedManagerDiagnostics = normalizeHostSignalDiagnostics(managerHostSignalDiagnostics); + + if (managerStateAvailable && normalizedManagerDiagnostics.usedHostSignal === false) { + if (normalizedManagerDiagnostics.reason) { + warnings.push( + `delivery-agent manager rejected daemon-host-signal.json (${normalizedManagerDiagnostics.reason}).` + ); + } + return normalizeHostSignal(null, managerHostIsolation, { + source: 'delivery-agent-manager-state', + managerStateAvailable, + managerGeneratedAt, + hostSignalDiagnostics: managerHostSignalDiagnostics + }); + } + + if (managerHostSignal) { + return normalizeHostSignal(managerHostSignal, managerHostIsolation, { + source: 'delivery-agent-manager-state', + managerStateAvailable, + managerGeneratedAt, + hostSignalDiagnostics: managerHostSignalDiagnostics + }); + } + + return normalizeHostSignal(hostSignal, hostIsolation, { + source: hostSignal ? 'daemon-host-signal' : 'unavailable', + managerStateAvailable, + managerGeneratedAt, + hostSignalDiagnostics: managerHostSignalDiagnostics + }); +} + +function buildDaemonCutoverAssessment(jarvisPolicy, hostRuntime) { + const runtimeProvider = normalizeLower(jarvisPolicy?.dockerRuntimePolicy?.provider); + const expectedDockerHost = toOptionalText(jarvisPolicy?.dockerRuntimePolicy?.expectedDockerHost); + const expectedOsType = toOptionalText(jarvisPolicy?.dockerRuntimePolicy?.expectedOsType); + const expectedContext = toOptionalText(jarvisPolicy?.dockerRuntimePolicy?.expectedContext); + const observedDockerHost = toOptionalText(hostRuntime?.wslDocker?.dockerHost); + const observedContext = toOptionalText(hostRuntime?.wslDocker?.context) ?? toOptionalText(hostRuntime?.windowsDocker?.context); + const observedOsType = toOptionalText(hostRuntime?.wslDocker?.osType) ?? toOptionalText(hostRuntime?.windowsDocker?.osType); + const hostStatus = normalizeLower(hostRuntime?.status); + const runningRunnerServices = Array.isArray(hostRuntime?.runnerServices?.running) ? hostRuntime.runnerServices.running : []; + const runnerServiceCount = runningRunnerServices.length; + + const buildRequiredActions = (...actions) => actions.filter((action) => normalizeText(action).length > 0); + + if (runtimeProvider !== 'native-wsl') { + return { + schema: 'priority/jarvis-daemon-cutover-assessment@v1', + status: 'not-required', + runtimeProvider: jarvisPolicy?.dockerRuntimePolicy?.provider ?? null, + expectedDockerHost, + observedDockerHost, + expectedContext, + observedContext, + expectedOsType, + observedOsType, + canReuseLinuxDaemon: false, + readyForLinuxDaemon: false, + requiresOperatorCutover: false, + requiredActions: [], + reason: 'Delivery policy does not require native-wsl daemon reuse.' + }; + } + + if (hostStatus === 'native-wsl') { + return { + schema: 'priority/jarvis-daemon-cutover-assessment@v1', + status: 'ready', + runtimeProvider: jarvisPolicy?.dockerRuntimePolicy?.provider ?? null, + expectedDockerHost, + observedDockerHost, + expectedContext, + observedContext, + expectedOsType, + observedOsType, + canReuseLinuxDaemon: true, + readyForLinuxDaemon: true, + requiresOperatorCutover: false, + requiredActions: [], + reason: 'Pinned WSL Docker host resolves to a distro-owned Linux daemon.' + }; + } + + if (hostStatus === 'desktop-backed') { + return { + schema: 'priority/jarvis-daemon-cutover-assessment@v1', + status: 'cutover-required', + runtimeProvider: jarvisPolicy?.dockerRuntimePolicy?.provider ?? null, + expectedDockerHost, + observedDockerHost, + expectedContext, + observedContext, + expectedOsType, + observedOsType, + canReuseLinuxDaemon: false, + readyForLinuxDaemon: false, + requiresOperatorCutover: true, + requiredActions: buildRequiredActions( + runnerServiceCount > 0 + ? `Stop or explicitly govern the ${runnerServiceCount} running actions.runner.* service${runnerServiceCount === 1 ? '' : 's'} on this host.` + : 'Verify the host has no unmanaged actions.runner.* services running.', + 'Switch WSL Docker to a distro-owned Linux daemon before reusing the daemon-first Linux plane.', + 'Rerun priority:delivery:host:signal.', + 'Rerun priority:jarvis:status.' + ), + reason: 'WSL Docker still resolves to Docker Desktop; cut over to a distro-owned Linux daemon before reusing the daemon-first Linux plane.' + }; + } + + if (hostStatus === 'drifted') { + return { + schema: 'priority/jarvis-daemon-cutover-assessment@v1', + status: 'drifted', + runtimeProvider: jarvisPolicy?.dockerRuntimePolicy?.provider ?? null, + expectedDockerHost, + observedDockerHost, + expectedContext, + observedContext, + expectedOsType, + observedOsType, + canReuseLinuxDaemon: false, + readyForLinuxDaemon: false, + requiresOperatorCutover: false, + requiredActions: buildRequiredActions( + 'Reconcile the WSL Docker daemon fingerprint.', + 'Rerun priority:delivery:host:signal.', + 'Rerun priority:jarvis:status.' + ), + reason: 'WSL Docker daemon fingerprint drifted and must be reconciled before Linux daemon reuse.' + }; + } + + if (hostStatus === 'runner-conflict') { + return { + schema: 'priority/jarvis-daemon-cutover-assessment@v1', + status: 'runner-conflict', + runtimeProvider: jarvisPolicy?.dockerRuntimePolicy?.provider ?? null, + expectedDockerHost, + observedDockerHost, + expectedContext, + observedContext, + expectedOsType, + observedOsType, + canReuseLinuxDaemon: false, + readyForLinuxDaemon: false, + requiresOperatorCutover: false, + requiredActions: buildRequiredActions( + runnerServiceCount > 0 + ? `Stop or explicitly govern the ${runnerServiceCount} running actions.runner.* service${runnerServiceCount === 1 ? '' : 's'} on this host.` + : 'Verify the host has no unmanaged actions.runner.* services running.', + 'Rerun priority:delivery:host:signal.', + 'Rerun priority:jarvis:status.' + ), + reason: 'Runner-service isolation is still required before Linux daemon reuse.' + }; + } + + return { + schema: 'priority/jarvis-daemon-cutover-assessment@v1', + status: 'unknown', + runtimeProvider: jarvisPolicy?.dockerRuntimePolicy?.provider ?? null, + expectedDockerHost, + observedDockerHost, + expectedContext, + observedContext, + expectedOsType, + observedOsType, + canReuseLinuxDaemon: false, + readyForLinuxDaemon: false, + requiresOperatorCutover: false, + requiredActions: buildRequiredActions( + 'Re-run priority:delivery:host:signal to capture the current host state.', + 'Re-run priority:jarvis:status to re-evaluate daemon cutover readiness.' + ), + reason: 'Jarvis could not determine whether the Linux daemon plane is reusable.' + }; +} + +function mapConcurrentLanePhase(entry = {}) { + const runtimeStatus = normalizeLower(entry?.runtimeStatus); + if (runtimeStatus === 'active' || runtimeStatus === 'completed') { + return 'active'; + } + if (runtimeStatus === 'planned' || runtimeStatus === 'unknown') { + return 'queued'; + } + if (runtimeStatus === 'blocked' || runtimeStatus === 'failed') { + return 'blocked'; + } + if (runtimeStatus === 'deferred') { + return 'deferred'; + } + if (runtimeStatus === 'idle') { + return 'idle'; + } + return 'unknown'; +} + +function isWindowsDockerLane(entry = {}) { + const reasons = Array.isArray(entry?.reasons) ? entry.reasons.map((reason) => normalizeLower(reason)) : []; + const metadata = entry?.metadata ?? {}; + const dockerServerOs = normalizeLower(metadata?.dockerServerOs ?? entry?.dockerServerOs); + return ( + normalizeLower(entry?.id) === 'manual-windows-docker' || + normalizeLower(entry?.resourceGroup) === 'docker-desktop-windows' || + reasons.some((reason) => reason.includes('docker-engine-windows')) || + dockerServerOs === 'windows' + ); +} + +function collectConcurrentLaneSessions(concurrentLaneStatus = {}, jarvisPolicy = {}) { + const laneStatuses = Array.isArray(concurrentLaneStatus?.laneStatuses) ? concurrentLaneStatus.laneStatuses : []; + return laneStatuses + .filter((entry) => isWindowsDockerLane(entry)) + .map((entry, index) => { + const metadata = entry?.metadata ?? {}; + return { + source: 'concurrent-lane-status', + sessionId: toOptionalText(entry?.id) ?? `jarvis-session-${index + 1}`, + logicalLaneId: toOptionalText(entry?.logicalLaneId), + issue: coercePositiveInteger(entry?.issue), + phase: mapConcurrentLanePhase(entry), + laneId: toOptionalText(entry?.id), + laneClass: toOptionalText(entry?.laneClass), + executionPlane: toOptionalText(entry?.executionPlane), + resourceGroup: toOptionalText(entry?.resourceGroup), + branchRef: toOptionalText(entry?.branchRef ?? metadata?.branchRef), + dockerContext: toOptionalText(metadata?.dockerContext ?? entry?.dockerContext), + dockerServerOs: toOptionalText(metadata?.dockerServerOs ?? entry?.dockerServerOs), + preferredContainerImage: jarvisPolicy?.preferredContainerImage ?? null, + primaryRecordedResponsibility: jarvisPolicy?.primaryRecordedResponsibility ?? null, + reason: Array.isArray(entry?.reasons) ? entry.reasons.map((reason) => normalizeText(reason)).filter(Boolean).join('; ') : null + }; + }); +} + +function buildRuntimeFallbackSession(runtimeState = {}, hostRuntime = {}, jarvisPolicy = {}) { + const activeLane = runtimeState?.activeLane ?? null; + const providerDispatch = activeLane?.providerDispatch ?? runtimeState?.providerDispatch ?? null; + const executionPlane = normalizeLower(providerDispatch?.executionPlane); + const windowsDockerOsType = normalizeLower(hostRuntime?.windowsDocker?.osType); + if (!activeLane || executionPlane !== 'local' || windowsDockerOsType !== 'windows') { + return null; + } + return { + source: 'runtime-state-active-lane', + sessionId: toOptionalText(activeLane?.laneId) ?? 'jarvis-runtime-active-lane', + logicalLaneId: toOptionalText(activeLane?.workerSlotId ?? providerDispatch?.workerSlotId), + issue: coercePositiveInteger(activeLane?.issue), + phase: normalizeLower(runtimeState?.laneLifecycle) === 'blocked' ? 'blocked' : 'active', + laneId: toOptionalText(activeLane?.laneId), + laneClass: toOptionalText(activeLane?.laneClass), + executionPlane: toOptionalText(providerDispatch?.executionPlane), + resourceGroup: toOptionalText(providerDispatch?.providerId), + branchRef: toOptionalText(activeLane?.branch), + dockerContext: toOptionalText(hostRuntime?.windowsDocker?.context), + dockerServerOs: toOptionalText(hostRuntime?.windowsDocker?.osType), + preferredContainerImage: jarvisPolicy?.preferredContainerImage ?? null, + primaryRecordedResponsibility: jarvisPolicy?.primaryRecordedResponsibility ?? null, + reason: 'Fallback local active lane projected from delivery-agent-state.' + }; +} + +function buildObserverHeartbeat(heartbeat = null) { + return { + exists: Boolean(heartbeat), + generatedAt: toOptionalText(heartbeat?.generatedAt), + outcome: toOptionalText(heartbeat?.outcome), + cyclesCompleted: coercePositiveInteger(heartbeat?.cyclesCompleted) ?? 0, + activeLaneId: toOptionalText(heartbeat?.activeLane?.laneId), + activeIssue: coercePositiveInteger(heartbeat?.activeLane?.issue), + stopRequested: heartbeat?.stopRequested === true + }; +} + +function buildWslDaemonState(daemonPid = null) { + return { + exists: Boolean(daemonPid), + pid: coercePositiveInteger(daemonPid?.pid), + generatedAt: toOptionalText(daemonPid?.generatedAt), + running: daemonPid?.running === true, + unitName: toOptionalText(daemonPid?.unitName), + distro: toOptionalText(daemonPid?.distro) + }; +} + +function buildDockerDaemonEngine(dockerDaemonEngine = null) { + return { + exists: Boolean(dockerDaemonEngine), + generatedAt: toOptionalText(dockerDaemonEngine?.generatedAt), + requiredOs: toOptionalText(dockerDaemonEngine?.requiredOs), + lockPath: toOptionalText(dockerDaemonEngine?.lockPath), + lockAcquired: dockerDaemonEngine?.lockAcquired === true, + dockerCommand: toOptionalText(dockerDaemonEngine?.docker?.command), + observedOs: toOptionalText(dockerDaemonEngine?.docker?.os), + previousContext: toOptionalText(dockerDaemonEngine?.docker?.context?.previous), + activeContext: toOptionalText(dockerDaemonEngine?.docker?.context?.active), + contextMode: toOptionalText(dockerDaemonEngine?.docker?.context?.mode), + contextSwitched: dockerDaemonEngine?.docker?.context?.switched === true + }; +} + +function determineReportStatus({ activeSessionCount, daemonCutoverStatus, warnings }) { + if (activeSessionCount > 0) { + return 'active'; + } + if (['cutover-required', 'drifted', 'runner-conflict'].includes(daemonCutoverStatus)) { + return 'blocked'; + } + if (warnings.length > 0) { + return 'unknown'; + } + return 'idle'; +} + +function createWatchPaths(paths) { + return [ + paths.policyPath, + paths.deliveryStatePath, + paths.concurrentLaneStatusPath, + paths.deliveryMemoryPath, + paths.managerStatePath, + paths.hostSignalPath, + paths.hostIsolationPath, + paths.observerHeartbeatPath, + paths.wslDaemonPidPath, + paths.dockerDaemonEnginePath, + paths.runtimeDaemonLogPath, + paths.dockerDaemonLogPath + ]; +} + +function printHumanSummary(report) { + const lines = []; + lines.push( + `[jarvis-session-observer] wrote ${report.artifacts.receiptPath} ` + + `(status=${report.status}, sessions=${report.summary.activeSessionCount}/${report.summary.totalSessionCount}, daemonCutover=${report.summary.daemonCutoverStatus})` + ); + lines.push( + `Jarvis owner=${report.jarvisPolicy.primaryRecordedResponsibility ?? 'unassigned'} ` + + `capacity=${report.jarvisPolicy.effectiveSessionCapacity}/${report.jarvisPolicy.configuredSessionCapacity} ` + + `plane=${report.jarvisPolicy.preferredExecutionPlane ?? 'unknown'}` + ); + lines.push( + `Host runtime=${report.hostRuntime.status} provider=${report.hostRuntime.provider ?? 'unknown'} ` + + `windowsContext=${report.hostRuntime.windowsDocker.context ?? ''} ` + + `windowsOs=${report.hostRuntime.windowsDocker.osType ?? ''}` + ); + lines.push( + `Linux daemon reuse=${report.daemon.daemonCutover.status} ` + + `expectedDockerHost=${report.daemon.daemonCutover.expectedDockerHost ?? ''} ` + + `observedDockerHost=${report.daemon.daemonCutover.observedDockerHost ?? ''}` + ); + if (Array.isArray(report.daemon.daemonCutover.requiredActions) && report.daemon.daemonCutover.requiredActions.length > 0) { + lines.push('Required actions:'); + for (const action of report.daemon.daemonCutover.requiredActions) { + lines.push(`- ${action}`); + } + } + if (report.sessions.length > 0) { + lines.push('Sessions:'); + for (const session of report.sessions) { + lines.push( + `- ${session.sessionId}: phase=${session.phase} source=${session.source} context=${session.dockerContext ?? ''} os=${session.dockerServerOs ?? ''} issue=${session.issue ?? ''}` + ); + } + } else { + lines.push('Sessions: none observed'); + } + if (report.warnings.length > 0) { + lines.push('Warnings:'); + for (const warning of report.warnings) { + lines.push(`- ${warning}`); + } + } + const runtimeDaemonLines = report.daemon.logs.runtimeDaemonWsl.lines; + const dockerDaemonLines = report.daemon.logs.dockerDaemon.lines; + if (runtimeDaemonLines.length > 0) { + lines.push('runtime-daemon-wsl.log tail:'); + for (const line of runtimeDaemonLines) { + lines.push(` ${line}`); + } + } + if (dockerDaemonLines.length > 0) { + lines.push('docker-daemon-logs.txt tail:'); + for (const line of dockerDaemonLines) { + lines.push(` ${line}`); + } + } + console.log(lines.join('\n')); +} + +export async function buildJarvisSessionObserverReport({ + repoRoot, + runtimeDir = DEFAULT_RUNTIME_DIR, + policyPath = DEFAULT_POLICY_PATH, + outputPath = DEFAULT_OUTPUT_PATH, + tailLines = DEFAULT_TAIL_LINES +}) { + const resolvedRepoRoot = path.resolve(repoRoot); + const resolvedRuntimeDir = resolvePath(resolvedRepoRoot, runtimeDir); + const paths = { + policyPath: resolvePath(resolvedRepoRoot, policyPath), + outputPath: resolvePath(resolvedRepoRoot, outputPath), + deliveryStatePath: path.join(resolvedRuntimeDir, 'delivery-agent-state.json'), + concurrentLaneStatusPath: path.join(resolvedRuntimeDir, 'concurrent-lane-status-receipt.json'), + deliveryMemoryPath: path.join(resolvedRuntimeDir, 'delivery-memory.json'), + managerStatePath: path.join(resolvedRuntimeDir, 'delivery-agent-manager-state.json'), + hostSignalPath: path.join(resolvedRuntimeDir, 'daemon-host-signal.json'), + hostIsolationPath: path.join(resolvedRuntimeDir, 'delivery-agent-host-isolation.json'), + observerHeartbeatPath: path.join(resolvedRuntimeDir, 'observer-heartbeat.json'), + wslDaemonPidPath: path.join(resolvedRuntimeDir, 'delivery-agent-wsl-daemon-pid.json'), + dockerDaemonEnginePath: path.join(resolvedRuntimeDir, 'docker-daemon-engine.json'), + runtimeDaemonLogPath: path.join(resolvedRuntimeDir, 'runtime-daemon-wsl.log'), + dockerDaemonLogPath: path.join(resolvedRuntimeDir, 'docker-daemon-logs.txt') + }; + + const [ + policy, + runtimeState, + concurrentLaneStatus, + deliveryMemory, + managerState, + hostSignal, + hostIsolation, + observerHeartbeat, + daemonPid, + dockerDaemonEngine, + runtimeDaemonLogTail, + dockerDaemonLogTail + ] = await Promise.all([ + readJsonIfPresent(paths.policyPath), + readJsonIfPresent(paths.deliveryStatePath), + readJsonIfPresent(paths.concurrentLaneStatusPath), + readJsonIfPresent(paths.deliveryMemoryPath), + readJsonIfPresent(paths.managerStatePath), + readJsonIfPresent(paths.hostSignalPath), + readJsonIfPresent(paths.hostIsolationPath), + readJsonIfPresent(paths.observerHeartbeatPath), + readJsonIfPresent(paths.wslDaemonPidPath), + readJsonIfPresent(paths.dockerDaemonEnginePath), + readTailIfPresent(paths.runtimeDaemonLogPath, tailLines), + readTailIfPresent(paths.dockerDaemonLogPath, tailLines) + ]); + + const warnings = []; + if (!policy) warnings.push('delivery-agent policy receipt is missing.'); + if (!hostSignal) warnings.push('daemon-host-signal.json is missing.'); + if (!runtimeState) warnings.push('delivery-agent-state.json is missing.'); + + const jarvisPolicy = normalizeJarvisPolicy(policy ?? {}, runtimeState ?? {}); + const hostRuntime = resolveHostRuntimeEvidence(managerState, hostSignal, hostIsolation, warnings); + const daemonCutover = buildDaemonCutoverAssessment(jarvisPolicy, hostRuntime); + const concurrentSessions = collectConcurrentLaneSessions(concurrentLaneStatus ?? {}, jarvisPolicy); + const runtimeFallbackSession = buildRuntimeFallbackSession(runtimeState ?? {}, hostRuntime, jarvisPolicy); + const sessions = runtimeFallbackSession && concurrentSessions.length === 0 + ? [runtimeFallbackSession] + : concurrentSessions; + + const activeSessionCount = sessions.filter((entry) => entry.phase === 'active').length; + const queuedSessionCount = sessions.filter((entry) => entry.phase === 'queued').length; + const blockedSessionCount = sessions.filter((entry) => entry.phase === 'blocked').length; + const deferredSessionCount = sessions.filter((entry) => entry.phase === 'deferred').length; + const totalSessionCount = sessions.length; + const status = determineReportStatus({ activeSessionCount, daemonCutoverStatus: daemonCutover.status, warnings }); + + const report = { + schema: JARVIS_SESSION_OBSERVER_SCHEMA, + generatedAt: new Date().toISOString(), + repository: toOptionalText(runtimeState?.repository) ?? toOptionalText(policy?.repo) ?? null, + status, + summary: { + specialtyLaneId: jarvisPolicy.specialtyLaneId, + primaryRecordedResponsibility: jarvisPolicy.primaryRecordedResponsibility, + configuredSessionCapacity: jarvisPolicy.configuredSessionCapacity, + effectiveSessionCapacity: jarvisPolicy.effectiveSessionCapacity, + activeSessionCount, + queuedSessionCount, + blockedSessionCount, + deferredSessionCount, + totalSessionCount, + daemonCutoverStatus: daemonCutover.status, + readyForLinuxDaemon: daemonCutover.readyForLinuxDaemon, + requiresOperatorCutover: daemonCutover.requiresOperatorCutover + }, + jarvisPolicy, + hostRuntime, + daemon: { + observerHeartbeat: buildObserverHeartbeat(observerHeartbeat), + wslDaemon: buildWslDaemonState(daemonPid), + dockerDaemonEngine: buildDockerDaemonEngine(dockerDaemonEngine), + daemonCutover, + deliveryMemory: { + exists: Boolean(deliveryMemory), + generatedAt: toOptionalText(deliveryMemory?.generatedAt), + workerPoolTarget: coercePositiveInteger(deliveryMemory?.summary?.targetSlotCount ?? deliveryMemory?.workerPool?.targetSlotCount) + }, + logs: { + runtimeDaemonWsl: { + path: paths.runtimeDaemonLogPath, + lineCount: runtimeDaemonLogTail.length, + lines: runtimeDaemonLogTail + }, + dockerDaemon: { + path: paths.dockerDaemonLogPath, + lineCount: dockerDaemonLogTail.length, + lines: dockerDaemonLogTail + } + } + }, + sessions, + warnings, + artifacts: { + receiptPath: paths.outputPath, + watchPaths: createWatchPaths(paths), + policyPath: paths.policyPath, + runtimeDir: resolvedRuntimeDir, + deliveryStatePath: paths.deliveryStatePath, + concurrentLaneStatusPath: paths.concurrentLaneStatusPath, + managerStatePath: paths.managerStatePath, + hostSignalPath: paths.hostSignalPath, + hostIsolationPath: paths.hostIsolationPath, + observerHeartbeatPath: paths.observerHeartbeatPath, + wslDaemonPidPath: paths.wslDaemonPidPath, + dockerDaemonEnginePath: paths.dockerDaemonEnginePath + } + }; + + return report; +} + +export async function observeJarvisSessionObserver(options) { + const report = await buildJarvisSessionObserverReport({ + repoRoot: options.repoRoot, + runtimeDir: options.runtimeDir, + policyPath: options.policyPath, + outputPath: options.outputPath, + tailLines: options.tailLines + }); + const outputPath = await writeReceipt(resolvePath(options.repoRoot, options.outputPath), report); + const reportWithPath = { + ...report, + artifacts: { + ...report.artifacts, + receiptPath: outputPath + } + }; + await writeReceipt(outputPath, reportWithPath); + return { report: reportWithPath, outputPath }; +} + +export async function main(argv = process.argv) { + const options = parseArgs(argv); + if (options.help) { + printUsage(); + return 0; + } + const { report } = await observeJarvisSessionObserver(options); + printHumanSummary(report); + return report.status === 'blocked' ? 1 : 0; +} + +const modulePath = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === modulePath) { + main().then( + (code) => { + process.exit(code); + }, + (error) => { + console.error(`[jarvis-session-observer] ${error.message}`); + process.exit(1); + } + ); +} diff --git a/tools/priority/lib/delivery-agent-common.ts b/tools/priority/lib/delivery-agent-common.ts index 3ad2274ce..325b31f22 100644 --- a/tools/priority/lib/delivery-agent-common.ts +++ b/tools/priority/lib/delivery-agent-common.ts @@ -241,6 +241,30 @@ export function resolveObserverTelemetry(codexStateHygiene, reportPath) { return fallback; } +export function resolveHostSignalForStatus({ hostSignal, managerStartedAt = null }) { + const hostSignalGeneratedAt = getOptionalDateTimeProperty(hostSignal, 'generatedAt'); + const diagnostics = { + usedHostSignal: false, + reason: 'host-signal-missing', + hostSignalGeneratedAt: hostSignalGeneratedAt ? hostSignalGeneratedAt.toISOString() : null, + managerStartedAt: managerStartedAt ? managerStartedAt.toISOString() : null, + hostSignalRepository: getOptionalStringProperty(hostSignal, 'repository'), + }; + + if (!hostSignal) { + return { hostSignal: null, diagnostics }; + } + + if (managerStartedAt && hostSignalGeneratedAt && hostSignalGeneratedAt < managerStartedAt) { + diagnostics.reason = 'stale-before-current-manager'; + return { hostSignal: null, diagnostics }; + } + + diagnostics.usedHostSignal = true; + diagnostics.reason = 'host-signal-current'; + return { hostSignal, diagnostics }; +} + export function getArtifactPaths(repoRoot, runtimeDir) { const runtimeDirPath = resolvePath(repoRoot, runtimeDir); return { diff --git a/tools/priority/lib/delivery-agent-manager.ts b/tools/priority/lib/delivery-agent-manager.ts index fa619005a..4356098b7 100644 --- a/tools/priority/lib/delivery-agent-manager.ts +++ b/tools/priority/lib/delivery-agent-manager.ts @@ -24,6 +24,7 @@ import { normalizeText, readJsonFile, readLogTail, + resolveHostSignalForStatus, resolveDeliveryStateForStatus, resolveGitDirPath, resolveObserverTelemetry, @@ -151,7 +152,11 @@ export function emitStatus(options) { const codexStateHygiene = readJsonFile(paths.codexStateHygienePath); const observer = resolveObserverTelemetry(codexStateHygiene, paths.codexStateHygienePath); const workspaceQuarantine = getOptionalProperty(options, 'workspaceQuarantine') || resolveWorkspaceQuarantine(repoRoot); - const hostSignal = readJsonFile(paths.hostSignalPath); + const hostSignalResolution = resolveHostSignalForStatus({ + hostSignal: readJsonFile(paths.hostSignalPath), + managerStartedAt, + }); + const hostSignal = hostSignalResolution.hostSignal; const hostIsolation = readJsonFile(paths.hostIsolationPath); const wslNativeDocker = readJsonFile(paths.wslNativeDockerPath); const daemonLogTail = readLogTail(paths.daemonLogPath); @@ -191,6 +196,7 @@ export function emitStatus(options) { workspaceQuarantine, codexStateHygiene, hostSignal, + hostSignalDiagnostics: hostSignalResolution.diagnostics, hostIsolation, wslNativeDocker, logTail: { @@ -239,6 +245,8 @@ export function emitStatus(options) { daemonLogLineCount: daemonLogTail.length, managerStdoutLineCount: managerLogTail.length, managerStderrLineCount: managerErrorLogTail.length, + hostSignalReason: hostSignalResolution.diagnostics.reason, + hostSignalUsed: Boolean(hostSignalResolution.diagnostics.usedHostSignal), }, }); return report; diff --git a/tools/priority/lib/run-artifact-download.mjs b/tools/priority/lib/run-artifact-download.mjs index a54f2a5c4..859c347c0 100644 --- a/tools/priority/lib/run-artifact-download.mjs +++ b/tools/priority/lib/run-artifact-download.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { loadStorageRootsPolicy, resolveArtifactDestinationRoot } from './storage-root-policy.mjs'; export const REPORT_SCHEMA = 'priority/run-artifact-download@v1'; export const DEFAULT_DESTINATION_ROOT = path.join('tests', 'results', '_agent', 'reviews', 'run-artifacts'); @@ -220,9 +221,13 @@ export function downloadNamedArtifacts({ runId, artifactNames, downloadAll = false, + repoRoot = process.cwd(), destinationRoot = DEFAULT_DESTINATION_ROOT, + destinationRootExplicit = false, + storageRootsPolicy = null, reportPath = DEFAULT_REPORT_PATH, now = new Date(), + env = process.env, runGhJsonFn = runGhJson, runProcessFn = runProcess, }) { @@ -236,7 +241,8 @@ export function downloadNamedArtifacts({ status: 'pass', repository: normalizedRepository, runId: normalizedRunId, - destinationRoot, + destinationRoot: null, + destinationRootPolicy: null, requestedArtifacts, discovery: { status: 'pass', @@ -256,6 +262,16 @@ export function downloadNamedArtifacts({ errors: [], }; + const resolvedDestinationRoot = resolveArtifactDestinationRoot({ + repoRoot, + destinationRoot, + destinationRootExplicit, + policy: storageRootsPolicy ?? loadStorageRootsPolicy(repoRoot), + env + }); + report.destinationRoot = resolvedDestinationRoot.destinationRoot; + report.destinationRootPolicy = resolvedDestinationRoot.destinationRootPolicy; + const invalidRequestErrors = []; if (!normalizedRepository) { invalidRequestErrors.push('Repository is required.'); @@ -321,7 +337,7 @@ export function downloadNamedArtifacts({ for (const artifactName of requestedArtifacts) { const artifact = artifactsByName.get(artifactName) ?? null; - const destination = path.join(destinationRoot, sanitizeArtifactDestinationSegment(artifactName)); + const destination = path.join(report.destinationRoot, sanitizeArtifactDestinationSegment(artifactName)); const commandArgs = [ 'run', 'download', diff --git a/tools/priority/lib/storage-root-policy.mjs b/tools/priority/lib/storage-root-policy.mjs new file mode 100644 index 000000000..605df46f7 --- /dev/null +++ b/tools/priority/lib/storage-root-policy.mjs @@ -0,0 +1,216 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const DEFAULT_STORAGE_ROOT_POLICY = Object.freeze({ + worktrees: { + envVar: 'COMPAREVI_BURST_WORKTREE_ROOT', + preferredRoots: ['E:\\comparevi-lanes'] + }, + artifacts: { + envVar: 'COMPAREVI_BURST_ARTIFACT_ROOT', + preferredRoots: ['E:\\comparevi-artifacts'] + } +}); + +function normalizeText(value) { + if (value == null) { + return ''; + } + return String(value).trim(); +} + +function uniqueStrings(values) { + return [...new Set((Array.isArray(values) ? values : []).map((value) => normalizeText(value)).filter(Boolean))]; +} + +export function isPathWithin(parentPath, childPath) { + const relativePath = path.relative(parentPath, childPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function isForeignWindowsAbsolutePath(candidatePath, platform = process.platform) { + return platform !== 'win32' && path.win32.isAbsolute(normalizeText(candidatePath)); +} + +function resolveCandidatePath(repoRoot, candidatePath, { platform = process.platform } = {}) { + const normalized = normalizeText(candidatePath); + if (!normalized) { + return null; + } + if (isForeignWindowsAbsolutePath(normalized, platform)) { + return null; + } + return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(repoRoot, normalized); +} + +function pathRootExists(targetPath, { platform = process.platform } = {}) { + if (!targetPath || isForeignWindowsAbsolutePath(targetPath, platform)) { + return false; + } + const rootPath = path.parse(path.resolve(targetPath)).root; + return Boolean(rootPath) && fs.existsSync(rootPath); +} + +function normalizeStorageRootEntry(value, fallback) { + const source = value && typeof value === 'object' ? value : {}; + const preferredRoots = uniqueStrings( + Array.isArray(source.preferredRoots) && source.preferredRoots.length > 0 ? source.preferredRoots : fallback.preferredRoots + ); + return { + envVar: normalizeText(source.envVar) || fallback.envVar, + preferredRoots + }; +} + +export function normalizeStorageRootsPolicy(value = {}) { + const source = value && typeof value === 'object' ? value : {}; + return { + worktrees: normalizeStorageRootEntry(source.worktrees, DEFAULT_STORAGE_ROOT_POLICY.worktrees), + artifacts: normalizeStorageRootEntry(source.artifacts, DEFAULT_STORAGE_ROOT_POLICY.artifacts) + }; +} + +export function loadStorageRootsPolicy(repoRoot, { policyPath = path.join('tools', 'priority', 'delivery-agent.policy.json') } = {}) { + const resolvedPolicyPath = path.isAbsolute(policyPath) ? policyPath : path.resolve(repoRoot, policyPath); + if (!fs.existsSync(resolvedPolicyPath)) { + return normalizeStorageRootsPolicy(); + } + try { + const payload = JSON.parse(fs.readFileSync(resolvedPolicyPath, 'utf8')); + return normalizeStorageRootsPolicy(payload?.storageRoots); + } catch { + return normalizeStorageRootsPolicy(); + } +} + +function buildRootSelection({ + repoRoot, + strategy, + source, + baseRoot, + relativeRoot = null +}) { + const resolvedBaseRoot = path.resolve(baseRoot); + const normalizedRelativeRoot = normalizeText(relativeRoot) || null; + return { + strategy, + source, + baseRoot: resolvedBaseRoot, + relativeRoot: normalizedRelativeRoot, + usesExternalRoot: !isPathWithin(path.resolve(repoRoot), resolvedBaseRoot) + }; +} + +export function sanitizeRelativeRoot(value) { + const normalized = normalizeText(value); + if (!normalized) { + return ''; + } + const segments = path + .normalize(normalized) + .split(/[\\/]+/) + .map((segment) => segment.trim()) + .filter((segment) => segment && segment !== '.' && segment !== '..'); + return segments.length > 0 ? path.join(...segments) : ''; +} + +export function resolveStorageBaseRoot({ + repoRoot, + kind, + policy, + env = process.env, + platform = process.platform +}) { + const normalizedKind = normalizeText(kind); + if (!['worktrees', 'artifacts'].includes(normalizedKind)) { + throw new Error(`Unsupported storage root kind: ${kind}`); + } + const normalizedPolicy = normalizeStorageRootsPolicy(policy); + const scope = normalizedPolicy[normalizedKind]; + const envValue = normalizeText(env?.[scope.envVar]); + if (envValue) { + const resolvedEnvRoot = resolveCandidatePath(repoRoot, envValue, { platform }); + if (resolvedEnvRoot && pathRootExists(resolvedEnvRoot, { platform })) { + return buildRootSelection({ + repoRoot, + strategy: 'environment', + source: scope.envVar, + baseRoot: resolvedEnvRoot + }); + } + } + + for (const [index, preferredRoot] of scope.preferredRoots.entries()) { + const resolvedPreferredRoot = resolveCandidatePath(repoRoot, preferredRoot, { platform }); + if (resolvedPreferredRoot && pathRootExists(resolvedPreferredRoot, { platform })) { + return buildRootSelection({ + repoRoot, + strategy: 'policy-preferred-root', + source: `delivery-agent.policy.json#storageRoots.${normalizedKind}.preferredRoots[${index}]`, + baseRoot: resolvedPreferredRoot + }); + } + } + + return null; +} + +export function resolveArtifactDestinationRoot({ + repoRoot, + destinationRoot, + destinationRootExplicit = false, + policy, + env = process.env, + platform = process.platform +}) { + const requestedRoot = normalizeText(destinationRoot); + if (!requestedRoot) { + throw new Error('Artifact destination root is required.'); + } + + if (destinationRootExplicit || path.isAbsolute(requestedRoot) || path.win32.isAbsolute(requestedRoot)) { + const resolvedDestinationRoot = resolveCandidatePath(repoRoot, requestedRoot, { platform }); + if (!resolvedDestinationRoot) { + throw new Error(`Artifact destination root is not valid on ${platform}: ${requestedRoot}`); + } + return { + destinationRoot: resolvedDestinationRoot, + destinationRootPolicy: buildRootSelection({ + repoRoot, + strategy: 'explicit', + source: 'explicit-destination-root', + baseRoot: resolvedDestinationRoot + }) + }; + } + + const selectedRoot = resolveStorageBaseRoot({ + repoRoot, + kind: 'artifacts', + policy, + env, + platform + }); + if (!selectedRoot) { + const relativeRoot = sanitizeRelativeRoot(requestedRoot); + return { + destinationRoot: resolveCandidatePath(repoRoot, requestedRoot, { platform }), + destinationRootPolicy: buildRootSelection({ + repoRoot, + strategy: 'repo-default', + source: 'repo-default-artifact-root', + baseRoot: repoRoot, + relativeRoot + }) + }; + } + + const relativeRoot = sanitizeRelativeRoot(requestedRoot); + return { + destinationRoot: relativeRoot ? path.join(selectedRoot.baseRoot, relativeRoot) : selectedRoot.baseRoot, + destinationRootPolicy: { + ...selectedRoot, + relativeRoot: relativeRoot || null + } + }; +} diff --git a/tools/priority/project-session-index-v2-promotion-decision.mjs b/tools/priority/project-session-index-v2-promotion-decision.mjs index 634267a40..4ac6cfeea 100644 --- a/tools/priority/project-session-index-v2-promotion-decision.mjs +++ b/tools/priority/project-session-index-v2-promotion-decision.mjs @@ -40,6 +40,18 @@ function toRepoRelative(repoRoot, targetPath) { return path.relative(repoRoot, targetPath).replace(/\\/g, '/'); } +function toRepoRelativeOrAbsolute(repoRoot, targetPath) { + if (!targetPath) { + return null; + } + const resolvedTargetPath = path.resolve(targetPath); + const relativePath = path.relative(repoRoot, resolvedTargetPath); + if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) { + return relativePath.replace(/\\/g, '/'); + } + return resolvedTargetPath; +} + export function clearProjectionOutputs(repoRoot) { for (const relativePath of [DEFAULT_REPORT_PATH, DEFAULT_DOWNLOAD_REPORT_PATH, DEFAULT_DESTINATION_ROOT]) { fs.rmSync(path.join(repoRoot, relativePath), { recursive: true, force: true }); @@ -104,10 +116,10 @@ export function buildProjectionSnapshot({ }) { const relativeReportPath = reportPath ? toRepoRelative(repoRoot, reportPath) : toRepoRelative(repoRoot, path.join(repoRoot, DEFAULT_REPORT_PATH)); const relativeDownloadReportPath = report?.artifact?.downloadReportPath - ? toRepoRelative(repoRoot, report.artifact.downloadReportPath) + ? toRepoRelativeOrAbsolute(repoRoot, report.artifact.downloadReportPath) : toRepoRelative(repoRoot, path.join(repoRoot, DEFAULT_DOWNLOAD_REPORT_PATH)); const relativeArtifactRoot = report?.artifact?.destinationRoot - ? toRepoRelative(repoRoot, report.artifact.destinationRoot) + ? toRepoRelativeOrAbsolute(repoRoot, report.artifact.destinationRoot) : toRepoRelative(repoRoot, path.join(repoRoot, DEFAULT_DESTINATION_ROOT)); if (!report) { diff --git a/tools/priority/queue-refresh-pr.mjs b/tools/priority/queue-refresh-pr.mjs index 3ea4b5c59..b18b981a9 100644 --- a/tools/priority/queue-refresh-pr.mjs +++ b/tools/priority/queue-refresh-pr.mjs @@ -31,7 +31,7 @@ const USAGE_LINES = [ ]; function printUsage() { - console.log('Safe helper for queued-branch amendment and merge-queue requeue.'); + console.log('Safe helper for queued PR dequeue-update-requeue and merge-queue requeue.'); console.log(''); for (const line of USAGE_LINES) { console.log(line); @@ -395,6 +395,7 @@ function createReceipt({ }) { return { schema: RECEIPT_SCHEMA, + operation: 'dequeue-update-requeue', generatedAt: new Date().toISOString(), repo, pr, diff --git a/tools/priority/resolve-downstream-proving-artifact.mjs b/tools/priority/resolve-downstream-proving-artifact.mjs index 1efbd6ede..b648db066 100644 --- a/tools/priority/resolve-downstream-proving-artifact.mjs +++ b/tools/priority/resolve-downstream-proving-artifact.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { downloadNamedArtifacts } from './lib/run-artifact-download.mjs'; +import { loadStorageRootsPolicy, resolveArtifactDestinationRoot } from './lib/storage-root-policy.mjs'; export const REPORT_SCHEMA = 'priority/downstream-proving-selection@v1'; export const DEFAULT_WORKFLOW = 'downstream-promotion.yml'; @@ -60,7 +61,7 @@ function printUsage(log = console.log) { ` --branch Workflow run branch filter (default: ${DEFAULT_BRANCH}).`, ' --expected-source-sha Required source commit sha to match in the scorecard provenance.', ` --artifact-prefix Artifact prefix (default: ${DEFAULT_ARTIFACT_PREFIX}).`, - ` --destination-root Destination root (default: ${DEFAULT_DESTINATION_ROOT}).`, + ` --destination-root Destination root (default: policy/env-managed from ${DEFAULT_DESTINATION_ROOT}).`, ` --output Selection report path (default: ${DEFAULT_REPORT_PATH}).`, ' -h, --help Show help.' ].forEach((line) => log(line)); @@ -76,6 +77,7 @@ export function parseArgs(argv = process.argv, env = process.env) { expectedSourceSha: null, artifactPrefix: DEFAULT_ARTIFACT_PREFIX, destinationRoot: DEFAULT_DESTINATION_ROOT, + destinationRootExplicit: false, outputPath: DEFAULT_REPORT_PATH }; @@ -104,7 +106,10 @@ export function parseArgs(argv = process.argv, env = process.env) { if (token === '--branch') options.branch = normalizeText(next); if (token === '--expected-source-sha') options.expectedSourceSha = normalizeText(next); if (token === '--artifact-prefix') options.artifactPrefix = normalizeText(next); - if (token === '--destination-root') options.destinationRoot = next; + if (token === '--destination-root') { + options.destinationRoot = next; + options.destinationRootExplicit = true; + } if (token === '--output') options.outputPath = next; continue; } @@ -270,7 +275,8 @@ export async function runResolveDownstreamProvingArtifact( { now = new Date(), runGhJsonFn, - downloadNamedArtifactsFn = downloadNamedArtifacts + downloadNamedArtifactsFn = downloadNamedArtifacts, + env = process.env } = {} ) { const options = { @@ -279,6 +285,7 @@ export async function runResolveDownstreamProvingArtifact( branch: normalizeText(rawOptions.branch) || DEFAULT_BRANCH, artifactPrefix: normalizeText(rawOptions.artifactPrefix) || DEFAULT_ARTIFACT_PREFIX, destinationRoot: rawOptions.destinationRoot || DEFAULT_DESTINATION_ROOT, + destinationRootExplicit: rawOptions.destinationRootExplicit === true, outputPath: rawOptions.outputPath || DEFAULT_REPORT_PATH }; @@ -288,6 +295,13 @@ export async function runResolveDownstreamProvingArtifact( const candidates = []; let selected = null; + const rootSelection = resolveArtifactDestinationRoot({ + repoRoot: process.cwd(), + destinationRoot: options.destinationRoot, + destinationRootExplicit: options.destinationRootExplicit, + policy: loadStorageRootsPolicy(process.cwd()), + env + }); for (let page = 1; page <= WORKFLOW_RUNS_MAX_PAGES; page += 1) { const payload = await runGhJsonFn([ @@ -306,13 +320,15 @@ export async function runResolveDownstreamProvingArtifact( continue; } const artifactName = `${options.artifactPrefix}${run.id}`; - const candidateRoot = path.join(options.destinationRoot, String(run.id)); + const candidateRoot = path.join(rootSelection.destinationRoot, String(run.id)); const downloadReportPath = path.join(candidateRoot, 'download-report.json'); const downloadResult = await downloadNamedArtifactsFn({ repository: options.repo, runId: String(run.id), artifactNames: [artifactName], + repoRoot: process.cwd(), destinationRoot: candidateRoot, + destinationRootExplicit: true, reportPath: downloadReportPath }); const scorecardPath = findArtifactFile(candidateRoot, 'downstream-develop-promotion-scorecard.json'); diff --git a/tools/priority/run-sync-standing-priority.mjs b/tools/priority/run-sync-standing-priority.mjs new file mode 100644 index 000000000..9a5e7acb0 --- /dev/null +++ b/tools/priority/run-sync-standing-priority.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { getRepoRoot } from './lib/branch-utils.mjs'; +import { parseGitWorktreeListPorcelain } from './develop-sync.mjs'; + +const WORK_BRANCH_PATTERN = /^(issue\/|feature\/|release\/|hotfix\/|bugfix\/)/i; +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SYNC_SCRIPT_RELATIVE_PATH = path.join('tools', 'priority', 'sync-standing-priority.mjs'); + +function runGitText(spawnSyncFn, cwd, args, env) { + const result = spawnSyncFn('git', args, { + cwd, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + if (result.status !== 0) { + const detail = String(result.stderr ?? result.stdout ?? '').trim() || `git exited with status ${result.status}`; + throw new Error(detail); + } + return String(result.stdout ?? '').trim(); +} + +function isDirtyWorktree({ repoRoot, env = process.env, spawnSyncFn = spawnSync } = {}) { + const statusText = runGitText(spawnSyncFn, repoRoot, ['status', '--porcelain'], env); + return statusText.length > 0; +} + +export function resolvePrioritySyncExecutionRoot({ + repoRoot = getRepoRoot(), + env = process.env, + spawnSyncFn = spawnSync +} = {}) { + const normalizedRepoRoot = path.resolve(repoRoot); + let currentBranch = ''; + try { + currentBranch = runGitText(spawnSyncFn, normalizedRepoRoot, ['branch', '--show-current'], env); + } catch { + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: normalizedRepoRoot, + currentBranch: null, + delegated: false, + helperRoot: null, + reason: null + }; + } + + if (!WORK_BRANCH_PATTERN.test(currentBranch)) { + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: normalizedRepoRoot, + currentBranch, + delegated: false, + helperRoot: null, + reason: null + }; + } + + try { + const worktreeText = runGitText(spawnSyncFn, normalizedRepoRoot, ['worktree', 'list', '--porcelain'], env); + const helpers = parseGitWorktreeListPorcelain(worktreeText) + .map((entry) => ({ + ...entry, + path: path.resolve(entry.path) + })) + .filter((entry) => entry.path !== normalizedRepoRoot && entry.branchRef === 'refs/heads/develop'); + + for (const helper of helpers) { + try { + if (!isDirtyWorktree({ repoRoot: helper.path, env, spawnSyncFn })) { + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: helper.path, + currentBranch, + delegated: true, + helperRoot: helper.path, + reason: 'clean-develop-helper' + }; + } + } catch { + // Keep scanning remaining helpers. + } + } + + if (helpers.length > 0) { + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: normalizedRepoRoot, + currentBranch, + delegated: false, + helperRoot: null, + reason: 'dirty-develop-helper' + }; + } + } catch { + // Fall back to the current checkout when helper resolution is unavailable. + } + + return { + repoRoot: normalizedRepoRoot, + executionRepoRoot: normalizedRepoRoot, + currentBranch, + delegated: false, + helperRoot: null, + reason: null + }; +} + +export function runPrioritySync({ + argv = process.argv, + env = process.env, + repoRoot = getRepoRoot(), + spawnSyncFn = spawnSync, + stdout = process.stdout, + stderr = process.stderr +} = {}) { + const executionPlan = resolvePrioritySyncExecutionRoot({ + repoRoot, + env, + spawnSyncFn + }); + + const scriptPath = path.join(executionPlan.executionRepoRoot, SYNC_SCRIPT_RELATIVE_PATH); + const childArgs = [scriptPath, ...argv.slice(2)]; + const result = spawnSyncFn(process.execPath, childArgs, { + cwd: executionPlan.repoRoot, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + + if (executionPlan.delegated && stdout?.write) { + stdout.write( + `[priority:sync] delegated to clean develop helper '${executionPlan.helperRoot}' from '${executionPlan.repoRoot}'.\n` + ); + } else if (executionPlan.reason === 'dirty-develop-helper' && stdout?.write) { + stdout.write( + `[priority:sync] only dirty attached develop helpers were available for '${executionPlan.repoRoot}'; using caller checkout.\n` + ); + } + + if (result.stdout && stdout?.write) { + stdout.write(result.stdout); + } + if (result.stderr && stderr?.write) { + stderr.write(result.stderr); + } + + return { + ...executionPlan, + status: typeof result.status === 'number' ? result.status : 1 + }; +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; +const modulePath = path.resolve(fileURLToPath(import.meta.url)); +if (invokedPath && invokedPath === modulePath) { + const result = runPrioritySync(); + process.exitCode = result.status; +} diff --git a/tools/priority/runtime-supervisor.mjs b/tools/priority/runtime-supervisor.mjs index 572eb144f..255db3ca5 100644 --- a/tools/priority/runtime-supervisor.mjs +++ b/tools/priority/runtime-supervisor.mjs @@ -42,6 +42,7 @@ import { activateCompareviWorkerLane, prepareCompareviWorkerCheckout, repairRegisteredWorktreeGitPointers, + resolveCompareviWorkerCheckoutLocation, resolveCompareviWorkerCheckoutPath } from './runtime-worker-checkout.mjs'; import { @@ -796,6 +797,16 @@ async function buildCompareviTaskPacket({ repoRoot, schedulerDecision, preparedW normalizeText(preparedWorker?.providerId) || workerProviderSelection.selectedProviderId || null, + workerCheckoutRoot: + normalizeText(workerBranch?.checkoutRoot) || + normalizeText(workerReady?.checkoutRoot) || + normalizeText(preparedWorker?.checkoutRoot) || + null, + workerCheckoutRootPolicy: + workerBranch?.checkoutRootPolicy ?? + workerReady?.checkoutRootPolicy ?? + preparedWorker?.checkoutRootPolicy ?? + null, workerCheckoutPath: normalizeText(workerBranch?.checkoutPath) || normalizeText(workerReady?.checkoutPath) || @@ -1328,6 +1339,7 @@ export const compareviRuntimeTest = { prepareCompareviWorkerCheckout, repairRegisteredWorktreeGitPointers, resolveCompareviIssueBranchName, + resolveCompareviWorkerCheckoutLocation, resolveCompareviWorkerCheckoutPath, resolveForkRemoteForRepository }; diff --git a/tools/priority/runtime-worker-checkout.mjs b/tools/priority/runtime-worker-checkout.mjs index 0fd342c65..391528c95 100644 --- a/tools/priority/runtime-worker-checkout.mjs +++ b/tools/priority/runtime-worker-checkout.mjs @@ -12,6 +12,7 @@ import { loadDeliveryAgentPolicy, selectWorkerProviderAssignment } from './delivery-agent.mjs'; +import { resolveStorageBaseRoot } from './lib/storage-root-policy.mjs'; const execFileAsync = promisify(execFile); const DEFAULT_WORKER_REF = 'upstream/develop'; @@ -561,15 +562,71 @@ export function resolveCompareviWorkerCheckoutRoot({ repoRoot, repository }) { return path.join(repoRoot, '.runtime-worktrees', repoKey); } -export function resolveCompareviWorkerCheckoutPath({ repoRoot, repository, laneId, slotId }) { - const checkoutRoot = resolveCompareviWorkerCheckoutRoot({ repoRoot, repository }); +function buildCheckoutRootPolicy({ repoRoot, repoKey, selectedRoot = null }) { + if (selectedRoot) { + return { + ...selectedRoot, + relativeRoot: repoKey + }; + } + return { + strategy: 'repo-default', + source: 'repo-default-runtime-worktree-root', + baseRoot: path.join(repoRoot, '.runtime-worktrees'), + relativeRoot: repoKey, + usesExternalRoot: false + }; +} + +export function resolveCompareviWorkerCheckoutLocation({ + repoRoot, + repository, + laneId, + slotId, + storageRootsPolicy, + env = process.env +}) { + const rawRepoKey = normalizeText(repository)?.replace(/\//g, '--') || path.basename(repoRoot); + const repoKey = sanitizeSegment(rawRepoKey); const checkoutSegment = sanitizeSegment(slotId || laneId); + const selectedRoot = resolveStorageBaseRoot({ + repoRoot, + kind: 'worktrees', + policy: storageRootsPolicy, + env + }); + const checkoutRootPolicy = buildCheckoutRootPolicy({ + repoRoot, + repoKey, + selectedRoot + }); + const checkoutRoot = path.join(checkoutRootPolicy.baseRoot, repoKey); return { checkoutRoot, - checkoutPath: path.join(checkoutRoot, checkoutSegment) + checkoutPath: path.join(checkoutRoot, checkoutSegment), + checkoutRootPolicy }; } +export function resolveCompareviWorkerCheckoutPath({ + repoRoot, + repository, + laneId, + slotId, + storageRootsPolicy, + env = process.env +}) { + const { checkoutRoot, checkoutPath } = resolveCompareviWorkerCheckoutLocation({ + repoRoot, + repository, + laneId, + slotId, + storageRootsPolicy, + env + }); + return { checkoutRoot, checkoutPath }; +} + function resolveRuntimeDir({ repoRoot, options = {} }) { const runtimeDir = normalizeText(options.runtimeDir); if (!runtimeDir) { @@ -649,16 +706,20 @@ export async function prepareCompareviWorkerCheckout({ laneId: activeLane.laneId, selectedProviderId: providerSelection.selectedProviderId }); - const { checkoutRoot, checkoutPath } = resolveCompareviWorkerCheckoutPath({ + const { checkoutRoot, checkoutPath, checkoutRootPolicy } = resolveCompareviWorkerCheckoutLocation({ repoRoot, repository, laneId: activeLane.laneId, - slotId + slotId, + storageRootsPolicy: deliveryPolicy?.storageRoots, + env: deps.env ?? process.env }); - const legacyCheckout = resolveCompareviWorkerCheckoutPath({ + const legacyCheckout = resolveCompareviWorkerCheckoutLocation({ repoRoot, repository, - laneId: activeLane.laneId + laneId: activeLane.laneId, + storageRootsPolicy: deliveryPolicy?.storageRoots, + env: deps.env ?? process.env }); const useLegacyCheckout = !(await pathExists(checkoutPath)) && slotId !== activeLane.laneId && (await pathExists(legacyCheckout.checkoutPath)); const resolvedCheckoutPath = useLegacyCheckout ? legacyCheckout.checkoutPath : checkoutPath; @@ -720,6 +781,7 @@ export async function prepareCompareviWorkerCheckout({ assignmentMode: providerSelection.selectedAssignmentMode, checkoutRoot, checkoutPath: resolvedCheckoutPath, + checkoutRootPolicy, status: 'reused', ref: resolvedRef, requestedBranch: normalizeText(schedulerDecision?.stepOptions?.branch) || null, @@ -739,6 +801,7 @@ export async function prepareCompareviWorkerCheckout({ assignmentMode: providerSelection.selectedAssignmentMode, checkoutRoot, checkoutPath: resolvedCheckoutPath, + checkoutRootPolicy, status: 'blocked', ref: DEFAULT_WORKER_REF, requestedBranch: normalizeText(schedulerDecision?.stepOptions?.branch) || null, @@ -756,6 +819,7 @@ export async function prepareCompareviWorkerCheckout({ slotId, checkoutRoot, checkoutPath: resolvedCheckoutPath, + checkoutRootPolicy, status: 'blocked', ref: DEFAULT_WORKER_REF, requestedBranch: normalizeText(schedulerDecision?.stepOptions?.branch) || null, @@ -786,6 +850,7 @@ export async function prepareCompareviWorkerCheckout({ assignmentMode: providerSelection.selectedAssignmentMode, checkoutRoot, checkoutPath: resolvedCheckoutPath, + checkoutRootPolicy, status: 'created', ref: DEFAULT_WORKER_REF, requestedBranch: normalizeText(schedulerDecision?.stepOptions?.branch) || null, diff --git a/tools/priority/session-index-v2-promotion-decision.mjs b/tools/priority/session-index-v2-promotion-decision.mjs index 83af32e0c..b06fd4ff3 100644 --- a/tools/priority/session-index-v2-promotion-decision.mjs +++ b/tools/priority/session-index-v2-promotion-decision.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { downloadNamedArtifacts } from './lib/run-artifact-download.mjs'; +import { loadStorageRootsPolicy, resolveArtifactDestinationRoot } from './lib/storage-root-policy.mjs'; import { loadBranchRequiredChecksPolicy, resolveProjectedRequiredStatusChecks, @@ -71,7 +72,7 @@ function printUsage() { console.log(` --required-check Required-check context to evaluate (default: ${DEFAULT_REQUIRED_CHECK}).`); console.log(` --enforce-variable Repository variable to inspect (default: ${DEFAULT_ENFORCE_VARIABLE}).`); console.log(` --policy Branch-required-check policy path (default: ${DEFAULT_POLICY_PATH}).`); - console.log(` --destination-root Artifact download root (default: ${DEFAULT_DESTINATION_ROOT}).`); + console.log(` --destination-root Artifact download root (default: policy/env-managed from ${DEFAULT_DESTINATION_ROOT}).`); console.log(` --download-report Artifact download report path (default: ${DEFAULT_DOWNLOAD_REPORT_PATH}).`); console.log(` --out Output report path (default: ${DEFAULT_REPORT_PATH}).`); console.log(' -h, --help Show help.'); @@ -853,6 +854,7 @@ export function parseArgs(argv = process.argv, environment = process.env, repoRo enforceVariableName: DEFAULT_ENFORCE_VARIABLE, policyPath: DEFAULT_POLICY_PATH, destinationRoot: DEFAULT_DESTINATION_ROOT, + destinationRootExplicit: false, downloadReportPath: DEFAULT_DOWNLOAD_REPORT_PATH, reportPath: DEFAULT_REPORT_PATH, }; @@ -889,7 +891,10 @@ export function parseArgs(argv = process.argv, environment = process.env, repoRo if (token === '--required-check') options.requiredCheckName = normalizeText(next); if (token === '--enforce-variable') options.enforceVariableName = normalizeText(next); if (token === '--policy') options.policyPath = next; - if (token === '--destination-root') options.destinationRoot = next; + if (token === '--destination-root') { + options.destinationRoot = next; + options.destinationRootExplicit = true; + } if (token === '--download-report') options.downloadReportPath = next; if (token === '--out') options.reportPath = next; continue; @@ -1193,8 +1198,15 @@ export async function runSessionIndexV2PromotionDecision({ return { exitCode: report.status === 'fail' ? 1 : 0, report, reportPath }; } - const downloadDestinationRoot = path.join(options.destinationRoot, String(selectedRun.run.id)); - const resolvedDownloadDestinationRoot = resolveRepoPath(repoRoot, downloadDestinationRoot); + const rootSelection = resolveArtifactDestinationRoot({ + repoRoot, + destinationRoot: options.destinationRoot, + destinationRootExplicit: options.destinationRootExplicit, + policy: loadStorageRootsPolicy(repoRoot), + env, + }); + const downloadDestinationRoot = path.join(rootSelection.destinationRoot, String(selectedRun.run.id)); + const resolvedDownloadDestinationRoot = path.resolve(downloadDestinationRoot); fs.rmSync(resolvedDownloadDestinationRoot, { recursive: true, force: true }); report.artifact.destinationRoot = resolvedDownloadDestinationRoot; @@ -1202,9 +1214,12 @@ export async function runSessionIndexV2PromotionDecision({ repository: options.repo, runId: String(selectedRun.run.id), artifactNames: [options.artifactName], + repoRoot, destinationRoot: resolvedDownloadDestinationRoot, + destinationRootExplicit: true, reportPath: resolveRepoPath(repoRoot, options.downloadReportPath), now, + env, }); report.artifact.downloadReportPath = resolveRepoPath(repoRoot, downloadResult.reportPath ?? options.downloadReportPath); diff --git a/tools/priority/sync-standing-priority.mjs b/tools/priority/sync-standing-priority.mjs index 04c8e4171..98c190e59 100644 --- a/tools/priority/sync-standing-priority.mjs +++ b/tools/priority/sync-standing-priority.mjs @@ -2675,7 +2675,7 @@ export async function main(options = {}) { noStandingReason, noStandingOpenIssueCount ); - writeJson(path.join(resultsDir, 'router.json'), clearedRouter); + writeJson(path.join(resultsDir, 'router.json'), clearedRouter, { force: true }); if ( shouldPersistCacheUpdate(cache, clearedCache, { @@ -2805,7 +2805,7 @@ export async function main(options = {}) { const policy = loadRoutingPolicy(repoRoot); const router = buildRouter(snapshot, policy); - writeJson(path.join(resultsDir, 'router.json'), router); + writeJson(path.join(resultsDir, 'router.json'), router, { force: true }); const autoPromotedReportPath = path.join(resultsDir, AUTO_PROMOTED_STANDING_REPORT_FILENAME); if (standingPriority?.source === 'auto-select' && autoPromotedStandingReport) { writeAutoPromotedStandingPriorityReport(resultsDir, autoPromotedStandingReport); diff --git a/tools/priority/template-agent-verification-report.mjs b/tools/priority/template-agent-verification-report.mjs index c4ffc1cd3..2df97b20a 100644 --- a/tools/priority/template-agent-verification-report.mjs +++ b/tools/priority/template-agent-verification-report.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; export const REPORT_SCHEMA = 'priority/template-agent-verification-report@v1'; export const DEFAULT_POLICY_PATH = path.join('tools', 'priority', 'delivery-agent.policy.json'); +export const DEFAULT_TEMPLATE_POLICY_PATH = path.join('tools', 'policy', 'template-dependency.json'); export const DEFAULT_OUTPUT_PATH = path.join( 'tests', 'results', @@ -20,6 +21,7 @@ function printUsage() { console.log(''); console.log('Options:'); console.log(` --policy Policy path (default: ${DEFAULT_POLICY_PATH}).`); + console.log(` --template-policy Template dependency policy path (default: ${DEFAULT_TEMPLATE_POLICY_PATH}).`); console.log(` --output Output path (default: ${DEFAULT_OUTPUT_PATH}).`); console.log(' --repo Repository slug override.'); console.log(' --iteration-label Human-readable iteration label (required).'); @@ -30,6 +32,16 @@ function printUsage() { console.log(' --provider Verification provider id (default: hosted-github-workflow).'); console.log(' --run-url Hosted workflow or run URL.'); console.log(' --template-repo Override target template repository.'); + console.log(' --template-version Template release/version provenance.'); + console.log(' --template-ref Template release/tag/ref provenance.'); + console.log(' --cookiecutter-version Cookiecutter runtime provenance.'); + console.log(' --execution-plane Execution plane provenance.'); + console.log(' --container-image Container image provenance.'); + console.log(' --generated-consumer-workspace-root '); + console.log(' Generated consumer workspace root provenance.'); + console.log(' --lane-id Logical lane id provenance.'); + console.log(' --agent-id Agent id provenance.'); + console.log(' --funding-window-id Funding window provenance.'); console.log(' --fail-on-blockers Exit non-zero when blockers exist (default true).'); console.log(' --no-fail-on-blockers Emit report without failing process exit.'); console.log(' -h, --help Show this message and exit.'); @@ -100,6 +112,7 @@ export function parseArgs(argv = process.argv) { const args = argv.slice(2); const options = { policyPath: DEFAULT_POLICY_PATH, + templatePolicyPath: DEFAULT_TEMPLATE_POLICY_PATH, outputPath: DEFAULT_OUTPUT_PATH, repo: null, iterationLabel: null, @@ -110,6 +123,15 @@ export function parseArgs(argv = process.argv) { provider: 'hosted-github-workflow', runUrl: null, templateRepo: null, + templateVersion: null, + templateRef: null, + cookiecutterVersion: null, + executionPlane: null, + containerImage: null, + generatedConsumerWorkspaceRoot: null, + laneId: null, + agentId: null, + fundingWindowId: null, failOnBlockers: true, help: false }; @@ -133,6 +155,7 @@ export function parseArgs(argv = process.argv) { if ( [ '--policy', + '--template-policy', '--output', '--repo', '--iteration-label', @@ -142,7 +165,16 @@ export function parseArgs(argv = process.argv) { '--duration-seconds', '--provider', '--run-url', - '--template-repo' + '--template-repo', + '--template-version', + '--template-ref', + '--cookiecutter-version', + '--execution-plane', + '--container-image', + '--generated-consumer-workspace-root', + '--lane-id', + '--agent-id', + '--funding-window-id' ].includes(token) ) { if (!next || next.startsWith('-')) { @@ -150,6 +182,7 @@ export function parseArgs(argv = process.argv) { } index += 1; if (token === '--policy') options.policyPath = next; + if (token === '--template-policy') options.templatePolicyPath = next; if (token === '--output') options.outputPath = next; if (token === '--repo') options.repo = next; if (token === '--iteration-label') options.iterationLabel = next; @@ -160,6 +193,15 @@ export function parseArgs(argv = process.argv) { if (token === '--provider') options.provider = next; if (token === '--run-url') options.runUrl = next; if (token === '--template-repo') options.templateRepo = next; + if (token === '--template-version') options.templateVersion = next; + if (token === '--template-ref') options.templateRef = next; + if (token === '--cookiecutter-version') options.cookiecutterVersion = next; + if (token === '--execution-plane') options.executionPlane = next; + if (token === '--container-image') options.containerImage = next; + if (token === '--generated-consumer-workspace-root') options.generatedConsumerWorkspaceRoot = next; + if (token === '--lane-id') options.laneId = next; + if (token === '--agent-id') options.agentId = next; + if (token === '--funding-window-id') options.fundingWindowId = next; continue; } @@ -194,7 +236,16 @@ export function evaluateTemplateAgentVerificationReport({ durationSeconds, provider, runUrl, - templateRepo + templateRepo, + templateVersion, + templateRef, + cookiecutterVersion, + executionPlane, + containerImage, + generatedConsumerWorkspaceRoot, + laneId, + agentId, + fundingWindowId }) { const blockers = []; if (policy?.schema !== 'priority/delivery-agent-policy@v1') { @@ -285,6 +336,22 @@ export function evaluateTemplateAgentVerificationReport({ durationSeconds, runUrl: asOptional(runUrl) }, + provenance: { + templateDependency: { + repository: asOptional(templateRepo) ?? asOptional(lane.targetRepository), + version: asOptional(templateVersion), + ref: asOptional(templateRef), + cookiecutterVersion: asOptional(cookiecutterVersion) + }, + execution: { + executionPlane: asOptional(executionPlane), + containerImage: asOptional(containerImage), + generatedConsumerWorkspaceRoot: asOptional(generatedConsumerWorkspaceRoot), + laneId: asOptional(laneId), + agentId: asOptional(agentId), + fundingWindowId: asOptional(fundingWindowId) + } + }, goals: { maxVerificationLagIterations: toNonNegativeInteger(lane?.metrics?.maxVerificationLagIterations), maxHostedDurationMinutes: durationGoalMinutes, @@ -310,6 +377,14 @@ export function runTemplateAgentVerificationReport( throw new Error('Unable to determine repository slug.'); } + const templatePolicy = readJsonFn(options.templatePolicyPath || DEFAULT_TEMPLATE_POLICY_PATH); + const templateRepository = asOptional(options.templateRepo) ?? asOptional(templatePolicy?.templateRepositorySlug); + const templateVersion = asOptional(options.templateVersion) ?? asOptional(templatePolicy?.templateReleaseRef); + const templateRef = asOptional(options.templateRef) ?? asOptional(templatePolicy?.rendering?.checkout); + const cookiecutterVersion = asOptional(options.cookiecutterVersion) ?? asOptional(templatePolicy?.cookiecutterVersion); + const executionPlane = asOptional(options.executionPlane) ?? asOptional(templatePolicy?.container?.executionPlane); + const containerImage = asOptional(options.containerImage) ?? asOptional(templatePolicy?.container?.image); + const report = evaluateTemplateAgentVerificationReport({ policy: readJsonFn(options.policyPath || DEFAULT_POLICY_PATH), repo, @@ -320,7 +395,16 @@ export function runTemplateAgentVerificationReport( durationSeconds: options.durationSeconds, provider: options.provider, runUrl: options.runUrl, - templateRepo: options.templateRepo + templateRepo: templateRepository, + templateVersion, + templateRef, + cookiecutterVersion, + executionPlane, + containerImage, + generatedConsumerWorkspaceRoot: options.generatedConsumerWorkspaceRoot, + laneId: options.laneId, + agentId: options.agentId, + fundingWindowId: options.fundingWindowId }); const outputPath = writeJsonFn(options.outputPath || DEFAULT_OUTPUT_PATH, report); diff --git a/tools/priority/template-cookiecutter-container.mjs b/tools/priority/template-cookiecutter-container.mjs new file mode 100644 index 000000000..19293232c --- /dev/null +++ b/tools/priority/template-cookiecutter-container.mjs @@ -0,0 +1,463 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const modulePath = fileURLToPath(import.meta.url); +const moduleDir = path.dirname(modulePath); +const repoRoot = path.resolve(moduleDir, '..', '..'); + +export const DEFAULT_POLICY_PATH = path.join(repoRoot, 'tools', 'policy', 'template-dependency.json'); +export const DEFAULT_OUTPUT_PATH = path.join( + repoRoot, + 'tests', + 'results', + '_agent', + 'template-cookiecutter', + 'template-cookiecutter-container.json' +); +const DEFAULT_CONTAINER_MOUNT_ROOT = '/workspace'; + +function getPlatformPath(platform = os.platform()) { + return platform === 'win32' ? path.win32 : path.posix; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +export function isDirectExecution(currentModulePath = modulePath, currentArgvPath = process.argv[1] ?? null) { + if (!currentArgvPath) { + return false; + } + + return path.resolve(currentArgvPath) === path.resolve(currentModulePath); +} + +export function parseArgs(argv = process.argv) { + const options = { + policyPath: DEFAULT_POLICY_PATH, + outputPath: DEFAULT_OUTPUT_PATH, + workspaceRoot: null, + laneId: 'template-cookiecutter', + runId: null, + contextFilePath: null, + containerImage: null, + dryRun: false, + failOnError: true + }; + + for (let index = 2; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--policy-path') { + options.policyPath = argv[++index]; + } else if (token === '--output') { + options.outputPath = argv[++index]; + } else if (token === '--workspace-root') { + options.workspaceRoot = argv[++index]; + } else if (token === '--lane-id') { + options.laneId = argv[++index]; + } else if (token === '--run-id') { + options.runId = argv[++index]; + } else if (token === '--context-file') { + options.contextFilePath = argv[++index]; + } else if (token === '--container-image') { + options.containerImage = argv[++index]; + } else if (token === '--dry-run') { + options.dryRun = true; + } else if (token === '--no-fail-on-error') { + options.failOnError = false; + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + + return options; +} + +export function loadTemplateDependencyPolicy(policyPath = DEFAULT_POLICY_PATH) { + const policy = readJson(policyPath); + if (!policy || typeof policy !== 'object') { + throw new Error(`Template dependency policy must be a JSON object: ${policyPath}`); + } + return policy; +} + +export function slugifySegment(value, fallback = 'template-cookiecutter') { + const normalized = String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || fallback; +} + +export function resolveWorkspaceRoot(policy, platform = os.platform(), overrideRoot = null) { + const platformPath = getPlatformPath(platform); + if (overrideRoot) { + return platformPath.normalize(overrideRoot); + } + + if (platform === 'win32') { + return platformPath.normalize( + policy.workspaceRoots?.win32 ?? + policy.workspaceRoots?.posix ?? + platformPath.join(os.tmpdir(), 'comparevi-template-consumers') + ); + } + + return platformPath.normalize( + policy.workspaceRoots?.posix ?? + policy.workspaceRoots?.win32 ?? + platformPath.join(os.tmpdir(), 'comparevi-template-consumers') + ); +} + +export function createRunToken(now = new Date(), uniqueSuffix = crypto.randomBytes(3).toString('hex')) { + const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').replace('T', 't').replace('Z', 'z'); + return `${stamp}-${process.pid}-${uniqueSuffix}`; +} + +export function buildContainerName(policy, laneId, runToken) { + const prefix = slugifySegment(policy.rendering?.uniqueContainerNamePrefix ?? 'comparevi-template', 'comparevi-template'); + const laneSlug = slugifySegment(laneId); + const tokenSlug = slugifySegment(runToken, 'run'); + return `${prefix}-${laneSlug}-${tokenSlug}`.slice(0, 120); +} + +export function loadTemplateContext(options = {}) { + if (options.context) { + return options.context; + } + + const contextFilePath = options.contextFilePath ?? options.defaultContextFilePath ?? null; + if (contextFilePath) { + return readJson(contextFilePath); + } + + return {}; +} + +export function resolveContainerUser(platform = os.platform(), currentProcess = process) { + if (platform === 'win32') { + return null; + } + + if (typeof currentProcess?.getuid !== 'function' || typeof currentProcess?.getgid !== 'function') { + return null; + } + + return `${currentProcess.getuid()}:${currentProcess.getgid()}`; +} + +export function buildCookiecutterPythonScript({ + templateRepositoryUrl, + templateDirectory, + checkout, + outputDir, + context +}) { + const contextJson = JSON.stringify(context ?? {}); + const lines = [ + 'import json', + 'import os', + 'from cookiecutter.main import cookiecutter', + 'template_repo = os.environ["COMPAREVI_TEMPLATE_REPOSITORY_URL"]', + 'template_directory = os.environ.get("COMPAREVI_TEMPLATE_DIRECTORY") or None', + 'output_dir = os.environ["COMPAREVI_TEMPLATE_OUTPUT_DIR"]', + 'extra_context = json.loads(os.environ.get("COMPAREVI_TEMPLATE_EXTRA_CONTEXT_JSON", "{}"))', + `checkout = ${JSON.stringify(checkout)}`, + 'kwargs = {', + ' "checkout": checkout,', + ' "no_input": True,', + ' "overwrite_if_exists": True,', + ' "output_dir": output_dir,', + ' "extra_context": extra_context,', + ' "accept_hooks": "yes",', + '}', + 'if template_directory:', + ' kwargs["directory"] = template_directory', + 'project_dir = cookiecutter(template_repo, **kwargs)', + 'print(json.dumps({', + ' "schema": "comparevi-cookiecutter-run@v1",', + ' "project_dir": project_dir,', + ' "template_repository_url": template_repo,', + ' "template_directory": template_directory,', + ' "checkout": checkout,', + ' "output_dir": output_dir,', + ' "context_keys": sorted(extra_context.keys()),', + '}, indent=None))' + ]; + + return { + script: lines.join('\n'), + contextJson, + templateRepositoryUrl, + templateDirectory, + checkout, + outputDir + }; +} + +export function resolveHostProjectDir(plan, containerProjectDir) { + if (!containerProjectDir) { + return null; + } + + const hostPlatformPath = plan.platformPath ?? getPlatformPath(plan.platform); + + const normalizedContainerProjectDir = String(containerProjectDir).replace(/\\/g, '/'); + const normalizedContainerOutputRoot = String(plan.containerOutputRoot).replace(/\\/g, '/'); + + if (normalizedContainerProjectDir === normalizedContainerOutputRoot) { + return plan.hostOutputRoot; + } + + if (normalizedContainerProjectDir.startsWith(`${normalizedContainerOutputRoot}/`)) { + const relativeProjectDir = normalizedContainerProjectDir.slice(normalizedContainerOutputRoot.length + 1); + return hostPlatformPath.join(plan.hostOutputRoot, ...relativeProjectDir.split('/').filter(Boolean)); + } + + const projectLeaf = path.posix.basename(normalizedContainerProjectDir); + return hostPlatformPath.join(plan.hostOutputRoot, projectLeaf); +} + +export function buildTemplateCookiecutterContainerPlan(options = {}, deps = {}) { + const policy = loadTemplateDependencyPolicy(options.policyPath ?? DEFAULT_POLICY_PATH); + const platform = deps.platform ?? os.platform(); + const currentProcess = deps.currentProcess ?? process; + const now = deps.now ?? new Date(); + const uniqueSuffixFn = deps.uniqueSuffixFn ?? (() => crypto.randomBytes(3).toString('hex')); + const uniqueSuffix = uniqueSuffixFn(); + const platformPath = getPlatformPath(platform); + const runToken = options.runId ? slugifySegment(options.runId, 'run') : createRunToken(now, uniqueSuffix); + const laneId = options.laneId ?? 'template-cookiecutter'; + const laneSlug = slugifySegment(laneId); + const hostWorkspaceRoot = resolveWorkspaceRoot(policy, platform, options.workspaceRoot); + const hostRunRoot = platformPath.join(hostWorkspaceRoot, laneSlug, runToken); + const hostHomeRoot = platformPath.join(hostRunRoot, '.home'); + const hostOutputRoot = platformPath.join(hostRunRoot, 'output'); + const containerWorkspaceRoot = path.posix.join(DEFAULT_CONTAINER_MOUNT_ROOT, laneSlug, runToken); + const containerHomeRoot = path.posix.join(containerWorkspaceRoot, '.home'); + const containerOutputRoot = path.posix.join(containerWorkspaceRoot, 'output'); + const templateDirectory = options.templateDirectory ?? policy.templateDirectory ?? null; + const defaultContextFilePath = policy.rendering?.defaultContextPath + ? path.resolve(repoRoot, policy.rendering.defaultContextPath) + : null; + const context = loadTemplateContext({ + ...options, + defaultContextFilePath + }); + const containerName = buildContainerName(policy, laneSlug, runToken); + const templateRepositoryUrl = policy.templateRepositoryUrl; + const checkout = policy.rendering?.checkout ?? policy.templateReleaseRef; + const containerImage = options.containerImage ?? policy.container?.image; + const containerUser = resolveContainerUser(platform, currentProcess); + + if (!templateRepositoryUrl) { + throw new Error('Template dependency policy is missing templateRepositoryUrl.'); + } + if (!containerImage) { + throw new Error('Template dependency policy is missing container.image.'); + } + if (!checkout) { + throw new Error('Template dependency policy is missing rendering.checkout.'); + } + + const script = buildCookiecutterPythonScript({ + templateRepositoryUrl, + templateDirectory, + checkout, + outputDir: containerOutputRoot, + context + }); + + const dockerArgs = [ + 'run', + '--rm', + '--name', + containerName + ]; + + if (containerUser) { + dockerArgs.push('--user', containerUser); + } + + dockerArgs.push( + '--volume', + `${hostWorkspaceRoot}:${DEFAULT_CONTAINER_MOUNT_ROOT}`, + '--workdir', + containerWorkspaceRoot, + '--env', + `COMPAREVI_TEMPLATE_REPOSITORY_URL=${templateRepositoryUrl}`, + '--env', + `COMPAREVI_TEMPLATE_OUTPUT_DIR=${containerOutputRoot}`, + '--env', + `HOME=${containerHomeRoot}`, + '--env', + `COMPAREVI_TEMPLATE_EXTRA_CONTEXT_JSON=${script.contextJson}`, + '--env', + `COMPAREVI_TEMPLATE_DIRECTORY=${templateDirectory ?? ''}`, + '--env', + `COMPAREVI_TEMPLATE_CHECKOUT=${checkout}`, + containerImage, + 'python3', + '-c', + script.script + ); + + return { + platform, + platformPath, + policyPath: options.policyPath ?? DEFAULT_POLICY_PATH, + policy, + laneId, + runToken, + hostWorkspaceRoot, + hostRunRoot, + hostHomeRoot, + hostOutputRoot, + containerName, + containerWorkspaceRoot, + containerHomeRoot, + containerOutputRoot, + templateRepositoryUrl, + templateDirectory, + checkout, + containerImage, + context, + containerUser, + dockerArgs, + command: 'docker', + now: now.toISOString(), + dryRun: Boolean(options.dryRun), + outputPath: options.outputPath ?? DEFAULT_OUTPUT_PATH, + failOnError: options.failOnError ?? true + }; +} + +export function runTemplateCookiecutterContainer(options = {}, deps = {}) { + const plan = buildTemplateCookiecutterContainerPlan(options, deps); + fs.mkdirSync(plan.hostOutputRoot, { recursive: true }); + fs.mkdirSync(plan.hostHomeRoot, { recursive: true }); + const defaultContextPath = plan.policy.rendering?.defaultContextPath ?? null; + const contextSource = options.context + ? 'inline' + : options.contextFilePath + ? 'file' + : defaultContextPath + ? 'policy-default-file' + : 'default-empty'; + + const receipt = { + schema: 'priority/template-cookiecutter-container@v1', + generatedAt: plan.now, + status: plan.dryRun ? 'dry-run' : 'pending', + policyPath: plan.policyPath, + policy: { + schema: plan.policy.schema, + schemaVersion: plan.policy.schemaVersion, + templateRepositorySlug: plan.policy.templateRepositorySlug, + templateRepositoryUrl: plan.policy.templateRepositoryUrl, + templateReleaseRef: plan.policy.templateReleaseRef, + templateDirectory: plan.policy.templateDirectory, + cookiecutterVersion: plan.policy.cookiecutterVersion, + executionPlane: plan.policy.container?.executionPlane ?? null, + containerRuntime: plan.policy.container?.runtime ?? null, + containerImage: plan.policy.container?.image ?? null, + effectiveContainerImage: plan.containerImage, + workspaceRoots: plan.policy.workspaceRoots, + rendering: plan.policy.rendering + }, + run: { + laneId: plan.laneId, + runToken: plan.runToken, + hostWorkspaceRoot: plan.hostWorkspaceRoot, + hostRunRoot: plan.hostRunRoot, + hostHomeRoot: plan.hostHomeRoot, + hostOutputRoot: plan.hostOutputRoot, + containerName: plan.containerName, + containerWorkspaceRoot: plan.containerWorkspaceRoot, + containerHomeRoot: plan.containerHomeRoot, + containerOutputRoot: plan.containerOutputRoot, + containerUser: plan.containerUser, + contextSource, + contextFilePath: options.contextFilePath ?? defaultContextPath, + contextKeys: Object.keys(plan.context).sort(), + deterministicInput: true, + uniqueContainerName: true, + uniqueWorkspaceRoot: true + }, + command: { + executable: plan.command, + args: plan.dockerArgs, + containerImage: plan.containerImage + }, + result: { + exitCode: null, + projectDir: null, + hostProjectDir: null, + stdout: null, + stderr: null + } + }; + + if (plan.dryRun) { + writeJson(plan.outputPath, receipt); + return { plan, receipt }; + } + + const result = (deps.spawnSyncFn ?? spawnSync)(plan.command, plan.dockerArgs, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const stdout = String(result.stdout ?? '').trim(); + const stderr = String(result.stderr ?? '').trim(); + let childReceipt = null; + + if (stdout) { + const lastLine = stdout.split(/\r?\n/).filter(Boolean).at(-1); + try { + childReceipt = JSON.parse(lastLine); + } catch (error) { + throw new Error(`Cookiecutter container produced non-JSON stdout: ${error.message}`); + } + } + + receipt.status = result.status === 0 ? 'pass' : 'failed'; + const containerProjectDir = childReceipt?.project_dir ?? childReceipt?.projectDir ?? null; + receipt.result = { + exitCode: result.status, + projectDir: containerProjectDir, + hostProjectDir: resolveHostProjectDir(plan, containerProjectDir), + stdout: stdout || null, + stderr: stderr || null + }; + + writeJson(plan.outputPath, receipt); + + if (result.status !== 0 && plan.failOnError) { + const error = new Error(`docker run failed with exit code ${result.status}`); + error.receipt = receipt; + throw error; + } + + return { plan, receipt, spawnResult: result }; +} + +if (isDirectExecution()) { + const options = parseArgs(process.argv); + const { receipt } = runTemplateCookiecutterContainer(options); + process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); +} diff --git a/tools/priority/template-pivot-gate.mjs b/tools/priority/template-pivot-gate.mjs index 3d5b421ac..b7a8778fa 100644 --- a/tools/priority/template-pivot-gate.mjs +++ b/tools/priority/template-pivot-gate.mjs @@ -144,6 +144,19 @@ function createBlocker(code, message) { return { code, message }; } +function normalizeTemplateDependency(value) { + return { + repository: asOptional(value?.repository), + version: asOptional(value?.version), + ref: asOptional(value?.ref), + cookiecutterVersion: asOptional(value?.cookiecutterVersion) + }; +} + +function allFieldsPresent(object, fields) { + return fields.every((field) => asOptional(object?.[field]) != null); +} + export function parseArgs(argv = process.argv) { const args = argv.slice(2); const options = { @@ -237,6 +250,35 @@ export async function runTemplatePivotGate( const blockers = []; const releaseCandidateRegex = new RegExp(policy.releaseCandidate.versionPattern); + const policyTemplateDependency = normalizeTemplateDependency(policy.templateDependency); + const templateDependencyFields = ['repository', 'version', 'ref', 'cookiecutterVersion']; + const templateDependencyPolicyReady = allFieldsPresent(policyTemplateDependency, templateDependencyFields); + const templateAgentVerificationTemplateDependency = normalizeTemplateDependency( + templateAgentVerification?.provenance?.templateDependency + ); + const templateAgentVerificationExecution = { + executionPlane: asOptional(templateAgentVerification?.provenance?.execution?.executionPlane), + containerImage: asOptional(templateAgentVerification?.provenance?.execution?.containerImage), + generatedConsumerWorkspaceRoot: asOptional( + templateAgentVerification?.provenance?.execution?.generatedConsumerWorkspaceRoot + ), + laneId: asOptional(templateAgentVerification?.provenance?.execution?.laneId), + agentId: asOptional(templateAgentVerification?.provenance?.execution?.agentId), + fundingWindowId: asOptional(templateAgentVerification?.provenance?.execution?.fundingWindowId) + }; + const templateAgentVerificationExecutionReady = allFieldsPresent(templateAgentVerificationExecution, [ + 'executionPlane', + 'containerImage', + 'generatedConsumerWorkspaceRoot', + 'laneId', + 'agentId', + 'fundingWindowId' + ]); + const templateAgentVerificationTemplateDependencyReady = + templateDependencyPolicyReady && + templateDependencyFields.every( + (field) => templateAgentVerificationTemplateDependency[field] === policyTemplateDependency[field] + ); const queueEmptyReady = queueEmpty?.schema === policy.queueEmpty.requiredSchema && @@ -337,7 +379,9 @@ export async function runTemplatePivotGate( templateAgentVerification?.schema === 'priority/template-agent-verification-report@v1' && templateAgentVerification?.summary?.status === 'pass' && templateAgentVerification?.verification?.status === 'pass' && - asOptional(templateAgentVerification?.lane?.targetRepository) === targetRepository; + asOptional(templateAgentVerification?.lane?.targetRepository) === targetRepository && + templateAgentVerificationTemplateDependencyReady && + templateAgentVerificationExecutionReady; if (!templateAgentVerification) { blockers.push( createBlocker( @@ -375,6 +419,58 @@ export async function runTemplatePivotGate( }.` ) ); + } else if (!templateDependencyPolicyReady) { + blockers.push( + createBlocker( + 'template-dependency-policy-missing', + 'Template pivot gate policy must pin the template dependency repository, version, ref, and cookiecutter version.' + ) + ); + } else if (templateAgentVerificationTemplateDependency.repository !== policyTemplateDependency.repository) { + blockers.push( + createBlocker( + 'template-dependency-repository-mismatch', + `Template dependency repository must be ${policyTemplateDependency.repository}; received ${ + templateAgentVerificationTemplateDependency.repository ?? 'null' + }.` + ) + ); + } else if (templateAgentVerificationTemplateDependency.version !== policyTemplateDependency.version) { + blockers.push( + createBlocker( + 'template-dependency-version-mismatch', + `Template dependency version must be ${policyTemplateDependency.version}; received ${ + templateAgentVerificationTemplateDependency.version ?? 'null' + }.` + ) + ); + } else if (templateAgentVerificationTemplateDependency.ref !== policyTemplateDependency.ref) { + blockers.push( + createBlocker( + 'template-dependency-ref-mismatch', + `Template dependency ref must be ${policyTemplateDependency.ref}; received ${ + templateAgentVerificationTemplateDependency.ref ?? 'null' + }.` + ) + ); + } else if ( + templateAgentVerificationTemplateDependency.cookiecutterVersion !== policyTemplateDependency.cookiecutterVersion + ) { + blockers.push( + createBlocker( + 'template-dependency-cookiecutter-version-mismatch', + `Template dependency cookiecutter version must be ${policyTemplateDependency.cookiecutterVersion}; received ${ + templateAgentVerificationTemplateDependency.cookiecutterVersion ?? 'null' + }.` + ) + ); + } else if (!templateAgentVerificationExecutionReady) { + blockers.push( + createBlocker( + 'template-agent-execution-provenance-incomplete', + 'Template-agent verification report must include execution-plane, container-image, consumer-workspace-root, lane-id, agent-id, and funding-window provenance.' + ) + ); } const ready = queueEmptyReady && releaseCandidateReady && handoffReady && templateAgentVerificationReady; @@ -389,6 +485,12 @@ export async function runTemplatePivotGate( futureAgentOnly: policy.decision.futureAgentOnly, operatorSteeringAllowed: policy.decision.operatorSteeringAllowed, requirePreciseSessionFeedback: policy.decision.requirePreciseSessionFeedback, + templateDependency: { + repository: policyTemplateDependency.repository, + version: policyTemplateDependency.version, + ref: policyTemplateDependency.ref, + cookiecutterVersion: policyTemplateDependency.cookiecutterVersion + }, releaseCandidateVersionPattern: policy.releaseCandidate.versionPattern, releaseCandidateVersionPatternDescription: policy.releaseCandidate.versionPatternDescription }, @@ -431,6 +533,22 @@ export async function runTemplatePivotGate( verificationStatus: templateAgentVerification?.verification?.status ?? null, targetRepository: asOptional(templateAgentVerification?.lane?.targetRepository), consumerRailBranch: asOptional(templateAgentVerification?.lane?.consumerRailBranch), + templateDependency: { + repository: templateAgentVerificationTemplateDependency.repository, + version: templateAgentVerificationTemplateDependency.version, + ref: templateAgentVerificationTemplateDependency.ref, + cookiecutterVersion: templateAgentVerificationTemplateDependency.cookiecutterVersion, + matchesPolicy: templateAgentVerificationTemplateDependencyReady + }, + execution: { + executionPlane: templateAgentVerificationExecution.executionPlane, + containerImage: templateAgentVerificationExecution.containerImage, + generatedConsumerWorkspaceRoot: templateAgentVerificationExecution.generatedConsumerWorkspaceRoot, + laneId: templateAgentVerificationExecution.laneId, + agentId: templateAgentVerificationExecution.agentId, + fundingWindowId: templateAgentVerificationExecution.fundingWindowId, + complete: templateAgentVerificationExecutionReady + }, ready: templateAgentVerificationReady } },