harnery 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +10 -1
- package/dist/commands/admission.d.ts +21 -0
- package/dist/commands/admission.d.ts.map +1 -0
- package/dist/commands/admission.js +565 -0
- package/dist/commands/agents.d.ts +7 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +148 -47
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +73 -2
- package/dist/commands/browse-ai.d.ts +2 -2
- package/dist/commands/browse-ai.d.ts.map +1 -1
- package/dist/commands/browse-ai.js +6 -4
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +360 -21
- package/dist/commands/diagnostics.d.ts +4 -0
- package/dist/commands/diagnostics.d.ts.map +1 -0
- package/dist/commands/diagnostics.js +229 -0
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/files.d.ts +12 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +54 -1
- package/dist/commands/governor.d.ts.map +1 -1
- package/dist/commands/governor.js +4 -0
- package/dist/commands/ledger-v3.d.ts.map +1 -1
- package/dist/commands/ledger-v3.js +71 -2
- package/dist/commands/qa-record.d.ts +144 -0
- package/dist/commands/qa-record.d.ts.map +1 -0
- package/dist/commands/qa-record.js +0 -0
- package/dist/commands/qa-run.d.ts +8 -0
- package/dist/commands/qa-run.d.ts.map +1 -0
- package/dist/commands/qa-run.js +396 -0
- package/dist/commands/qa-status.d.ts +71 -0
- package/dist/commands/qa-status.d.ts.map +1 -0
- package/dist/commands/qa-status.js +490 -0
- package/dist/commands/qa-verify.d.ts +40 -0
- package/dist/commands/qa-verify.d.ts.map +1 -0
- package/dist/commands/qa-verify.js +180 -0
- package/dist/commands/resources.d.ts +4 -0
- package/dist/commands/resources.d.ts.map +1 -0
- package/dist/commands/resources.js +29 -0
- package/dist/commands/review-pack.d.ts +4 -0
- package/dist/commands/review-pack.d.ts.map +1 -0
- package/dist/commands/review-pack.js +1001 -0
- package/dist/commands/supervisor.d.ts +4 -0
- package/dist/commands/supervisor.d.ts.map +1 -0
- package/dist/commands/supervisor.js +101 -0
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +4 -0
- package/dist/core/agents/cli.js +7 -3
- package/dist/core/agents/health.d.ts +17 -0
- package/dist/core/agents/health.d.ts.map +1 -0
- package/dist/core/agents/health.js +109 -0
- package/dist/core/agents/mailbox.d.ts +92 -0
- package/dist/core/agents/mailbox.d.ts.map +1 -0
- package/dist/core/agents/mailbox.js +325 -0
- package/dist/core/agents/qa-signal.d.ts +111 -0
- package/dist/core/agents/qa-signal.d.ts.map +1 -0
- package/dist/core/agents/qa-signal.js +231 -0
- package/dist/core/agents/reconcile-coordination-v3.d.ts +44 -0
- package/dist/core/agents/reconcile-coordination-v3.d.ts.map +1 -0
- package/dist/core/agents/reconcile-coordination-v3.js +58 -0
- package/dist/core/agents/render/prompt-context.d.ts +5 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +34 -13
- package/dist/core/agents/render/session-context.d.ts +8 -0
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +34 -3
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +50 -1
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +6 -10
- package/dist/core/agents/session-finalizer-v3.js +4 -1
- package/dist/core/agents/session-name-display.d.ts +20 -5
- package/dist/core/agents/session-name-display.d.ts.map +1 -1
- package/dist/core/agents/session-name-display.js +67 -7
- package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
- package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +17 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +1 -0
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/artifacts/constants.d.ts +1 -1
- package/dist/core/artifacts/constants.js +1 -1
- package/dist/core/artifacts/index.d.ts +82 -6
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +410 -15
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +59 -0
- package/dist/core/diagnostics/advice.d.ts +9 -0
- package/dist/core/diagnostics/advice.d.ts.map +1 -0
- package/dist/core/diagnostics/advice.js +112 -0
- package/dist/core/diagnostics/bundle.d.ts +51 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -0
- package/dist/core/diagnostics/bundle.js +968 -0
- package/dist/core/diagnostics/comparison.d.ts +5 -0
- package/dist/core/diagnostics/comparison.d.ts.map +1 -0
- package/dist/core/diagnostics/comparison.js +444 -0
- package/dist/core/diagnostics/contract.d.ts +245 -0
- package/dist/core/diagnostics/contract.d.ts.map +1 -0
- package/dist/core/diagnostics/contract.js +18 -0
- package/dist/core/diagnostics/identity.d.ts +3 -0
- package/dist/core/diagnostics/identity.d.ts.map +1 -0
- package/dist/core/diagnostics/identity.js +10 -0
- package/dist/core/diagnostics/index.d.ts +8 -0
- package/dist/core/diagnostics/index.d.ts.map +1 -0
- package/dist/core/diagnostics/index.js +7 -0
- package/dist/core/diagnostics/replay.d.ts +8 -0
- package/dist/core/diagnostics/replay.d.ts.map +1 -0
- package/dist/core/diagnostics/replay.js +213 -0
- package/dist/core/diagnostics/sanitize.d.ts +9 -0
- package/dist/core/diagnostics/sanitize.d.ts.map +1 -0
- package/dist/core/diagnostics/sanitize.js +84 -0
- package/dist/core/events/legacy-storage/compression.d.ts +13 -0
- package/dist/core/events/legacy-storage/compression.d.ts.map +1 -0
- package/dist/core/events/legacy-storage/compression.js +107 -0
- package/dist/core/events/legacy-storage/index.d.ts +1 -0
- package/dist/core/events/legacy-storage/index.d.ts.map +1 -1
- package/dist/core/events/legacy-storage/index.js +1 -0
- package/dist/core/events/v3/archive-retention.d.ts +33 -0
- package/dist/core/events/v3/archive-retention.d.ts.map +1 -0
- package/dist/core/events/v3/archive-retention.js +195 -0
- package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
- package/dist/core/events/v3/bootstrap.js +10 -0
- package/dist/core/events/v3/capabilities.d.ts +1 -1
- package/dist/core/events/v3/capabilities.d.ts.map +1 -1
- package/dist/core/events/v3/capabilities.js +1 -0
- package/dist/core/events/v3/coordination-view.d.ts +3 -0
- package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
- package/dist/core/events/v3/coordination-view.js +69 -8
- package/dist/core/events/v3/index.d.ts +1 -0
- package/dist/core/events/v3/index.d.ts.map +1 -1
- package/dist/core/events/v3/index.js +1 -0
- package/dist/core/events/v3/live-routing.d.ts.map +1 -1
- package/dist/core/events/v3/live-routing.js +1 -0
- package/dist/core/events/v3/producers/hook-base.d.ts +1 -1
- package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -1
- package/dist/core/events/v3/producers/hook-base.js +13 -2
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +10 -2
- package/dist/core/events/v3/producers/recorder.d.ts +39 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +350 -17
- package/dist/core/hooks/adapter/output.d.ts +8 -0
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +11 -1
- package/dist/core/hooks/cli.js +303 -219
- package/dist/core/hooks/health.d.ts +59 -0
- package/dist/core/hooks/health.d.ts.map +1 -0
- package/dist/core/hooks/health.js +84 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +10 -3
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
- package/dist/core/hooks/session-name-presence.js +4 -1
- package/dist/core/qa-artifacts.d.ts +20 -0
- package/dist/core/qa-artifacts.d.ts.map +1 -0
- package/dist/core/qa-artifacts.js +110 -0
- package/dist/core/resources/contract.d.ts +96 -0
- package/dist/core/resources/contract.d.ts.map +1 -0
- package/dist/core/resources/contract.js +2 -0
- package/dist/core/resources/index.d.ts +6 -0
- package/dist/core/resources/index.d.ts.map +1 -0
- package/dist/core/resources/index.js +5 -0
- package/dist/core/resources/sampler.d.ts +34 -0
- package/dist/core/resources/sampler.d.ts.map +1 -0
- package/dist/core/resources/sampler.js +458 -0
- package/dist/core/resources/service-status.d.ts +3 -0
- package/dist/core/resources/service-status.d.ts.map +1 -0
- package/dist/core/resources/service-status.js +48 -0
- package/dist/core/resources/service.d.ts +26 -0
- package/dist/core/resources/service.d.ts.map +1 -0
- package/dist/core/resources/service.js +292 -0
- package/dist/core/resources/storage.d.ts +13 -0
- package/dist/core/resources/storage.d.ts.map +1 -0
- package/dist/core/resources/storage.js +30 -0
- package/dist/core/storage/atomic-json.d.ts +3 -0
- package/dist/core/storage/atomic-json.d.ts.map +1 -0
- package/dist/core/storage/atomic-json.js +15 -0
- package/dist/core/storage/builtins.d.ts.map +1 -1
- package/dist/core/storage/builtins.js +45 -6
- package/dist/core/storage/logger.d.ts +2 -0
- package/dist/core/storage/logger.d.ts.map +1 -1
- package/dist/core/storage/logger.js +2 -0
- package/dist/core/storage/query.d.ts +14 -0
- package/dist/core/storage/query.d.ts.map +1 -1
- package/dist/core/storage/query.js +51 -0
- package/dist/core/supervisor/activity.d.ts +4 -0
- package/dist/core/supervisor/activity.d.ts.map +1 -0
- package/dist/core/supervisor/activity.js +141 -0
- package/dist/core/supervisor/contract.d.ts +268 -0
- package/dist/core/supervisor/contract.d.ts.map +1 -0
- package/dist/core/supervisor/contract.js +35 -0
- package/dist/core/supervisor/explanations.d.ts +4 -0
- package/dist/core/supervisor/explanations.d.ts.map +1 -0
- package/dist/core/supervisor/explanations.js +71 -0
- package/dist/core/supervisor/findings.d.ts +20 -0
- package/dist/core/supervisor/findings.d.ts.map +1 -0
- package/dist/core/supervisor/findings.js +399 -0
- package/dist/core/supervisor/history.d.ts +9 -0
- package/dist/core/supervisor/history.d.ts.map +1 -0
- package/dist/core/supervisor/history.js +46 -0
- package/dist/core/supervisor/hook-health-alerts.d.ts +14 -0
- package/dist/core/supervisor/hook-health-alerts.d.ts.map +1 -0
- package/dist/core/supervisor/hook-health-alerts.js +70 -0
- package/dist/core/supervisor/hook-health-storage.d.ts +5 -0
- package/dist/core/supervisor/hook-health-storage.d.ts.map +1 -0
- package/dist/core/supervisor/hook-health-storage.js +24 -0
- package/dist/core/supervisor/hook-health.d.ts +72 -0
- package/dist/core/supervisor/hook-health.d.ts.map +1 -0
- package/dist/core/supervisor/hook-health.js +203 -0
- package/dist/core/supervisor/hooks.d.ts +10 -0
- package/dist/core/supervisor/hooks.d.ts.map +1 -0
- package/dist/core/supervisor/hooks.js +28 -0
- package/dist/core/supervisor/index.d.ts +15 -0
- package/dist/core/supervisor/index.d.ts.map +1 -0
- package/dist/core/supervisor/index.js +14 -0
- package/dist/core/supervisor/log-feed.d.ts +9 -0
- package/dist/core/supervisor/log-feed.d.ts.map +1 -0
- package/dist/core/supervisor/log-feed.js +103 -0
- package/dist/core/supervisor/service.d.ts +29 -0
- package/dist/core/supervisor/service.d.ts.map +1 -0
- package/dist/core/supervisor/service.js +450 -0
- package/dist/core/supervisor/services.d.ts +11 -0
- package/dist/core/supervisor/services.d.ts.map +1 -0
- package/dist/core/supervisor/services.js +155 -0
- package/dist/core/supervisor/status.d.ts +3 -0
- package/dist/core/supervisor/status.d.ts.map +1 -0
- package/dist/core/supervisor/status.js +48 -0
- package/dist/core/supervisor/storage.d.ts +30 -0
- package/dist/core/supervisor/storage.d.ts.map +1 -0
- package/dist/core/supervisor/storage.js +73 -0
- package/dist/core/supervisor/timeline.d.ts +3 -0
- package/dist/core/supervisor/timeline.d.ts.map +1 -0
- package/dist/core/supervisor/timeline.js +87 -0
- package/dist/core/workflow/admission.d.ts +23 -0
- package/dist/core/workflow/admission.d.ts.map +1 -0
- package/dist/core/workflow/admission.js +136 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +77 -2
- package/dist/core/workflow/index.d.ts +3 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/proof.d.ts +2 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +39 -1
- package/dist/core/workflow/run-state.d.ts +3 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +8 -1
- package/dist/core/workflow/types.d.ts +33 -1
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +2 -1
- package/dist/lib/admission.d.ts +71 -0
- package/dist/lib/admission.d.ts.map +1 -0
- package/dist/lib/admission.js +264 -0
- package/dist/lib/agent-browser/client.d.ts +1 -1
- package/dist/lib/agent-browser/client.d.ts.map +1 -1
- package/dist/lib/agent-browser/client.js +1 -5
- package/dist/lib/browser/capture-fidelity.d.ts +39 -0
- package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
- package/dist/lib/browser/capture-fidelity.js +84 -0
- package/dist/lib/browser/client.d.ts +46 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +168 -9
- package/dist/lib/browser/critique.d.ts +38 -1
- package/dist/lib/browser/critique.d.ts.map +1 -1
- package/dist/lib/browser/critique.js +34 -6
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +4 -0
- package/dist/lib/browser/page-review-judge.d.ts +64 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
- package/dist/lib/browser/page-review-judge.js +270 -0
- package/dist/lib/browser/page-review-pack.d.ts +613 -0
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
- package/dist/lib/browser/page-review-pack.js +1751 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +377 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -0
- package/dist/lib/browser/qa-run-contracts.js +361 -0
- package/dist/lib/browser/qa-run.d.ts +141 -0
- package/dist/lib/browser/qa-run.d.ts.map +1 -0
- package/dist/lib/browser/qa-run.js +1283 -0
- package/dist/lib/browser/request-diagnostics.d.ts +13 -0
- package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
- package/dist/lib/browser/request-diagnostics.js +18 -0
- package/dist/lib/browser/tiling.d.ts +19 -0
- package/dist/lib/browser/tiling.d.ts.map +1 -1
- package/dist/lib/browser/tiling.js +28 -0
- package/dist/lib/cookies/client.d.ts +9 -0
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +197 -44
- package/dist/lib/cookies/extra.d.ts +18 -0
- package/dist/lib/cookies/extra.d.ts.map +1 -0
- package/dist/lib/cookies/extra.js +14 -0
- package/dist/lib/cookies/index.d.ts +2 -1
- package/dist/lib/cookies/index.d.ts.map +1 -1
- package/dist/lib/cookies/index.js +2 -1
- package/dist/lib/coord-root-id.d.ts +9 -0
- package/dist/lib/coord-root-id.d.ts.map +1 -0
- package/dist/lib/coord-root-id.js +17 -0
- package/dist/lib/durable-job.d.ts +124 -0
- package/dist/lib/durable-job.d.ts.map +1 -0
- package/dist/lib/durable-job.js +296 -0
- package/dist/lib/http/client.d.ts +7 -1
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +2 -0
- package/dist/lib/instructions/apply.d.ts +3 -1
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +74 -3
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +11 -2
- package/package.json +19 -2
- package/src/commander.ts +75 -1
- package/src/commands/admission.ts +699 -0
- package/src/commands/agents.ts +189 -50
- package/src/commands/artifacts.ts +132 -18
- package/src/commands/browse-ai.ts +10 -5
- package/src/commands/browse.ts +496 -20
- package/src/commands/diagnostics.ts +271 -0
- package/src/commands/fetch.ts +1 -0
- package/src/commands/files.ts +102 -2
- package/src/commands/governor.ts +8 -0
- package/src/commands/ledger-v3.ts +82 -0
- package/src/commands/qa-record.ts +682 -0
- package/src/commands/qa-run.ts +503 -0
- package/src/commands/qa-status.ts +608 -0
- package/src/commands/qa-verify.ts +238 -0
- package/src/commands/resources.ts +38 -0
- package/src/commands/review-pack.ts +1281 -0
- package/src/commands/supervisor.ts +141 -0
- package/src/commands/web.ts +19 -0
- package/src/commands/workflow.ts +8 -0
- package/src/core/agents/cli.ts +7 -3
- package/src/core/agents/health.ts +142 -0
- package/src/core/agents/mailbox.ts +373 -0
- package/src/core/agents/qa-signal.ts +261 -0
- package/src/core/agents/reconcile-coordination-v3.ts +77 -0
- package/src/core/agents/render/prompt-context.ts +42 -12
- package/src/core/agents/render/session-context.ts +37 -3
- package/src/core/agents/rules/commit-conflict.ts +58 -1
- package/src/core/agents/rules/stop-hook.ts +6 -12
- package/src/core/agents/session-finalizer-v3.ts +4 -1
- package/src/core/agents/session-name-display.ts +78 -7
- package/src/core/agents/state/heartbeat-reader.ts +7 -0
- package/src/core/agents/state/heartbeat-writer.ts +23 -0
- package/src/core/agents/state/live-coordination-view.ts +1 -0
- package/src/core/agents/state/live-coordination-writer.ts +5 -0
- package/src/core/artifacts/constants.ts +1 -1
- package/src/core/artifacts/index.ts +565 -30
- package/src/core/config.ts +134 -2
- package/src/core/diagnostics/advice.ts +147 -0
- package/src/core/diagnostics/bundle.ts +1250 -0
- package/src/core/diagnostics/comparison.ts +565 -0
- package/src/core/diagnostics/contract.ts +311 -0
- package/src/core/diagnostics/identity.ts +11 -0
- package/src/core/diagnostics/index.ts +7 -0
- package/src/core/diagnostics/replay.ts +314 -0
- package/src/core/diagnostics/sanitize.ts +97 -0
- package/src/core/events/legacy-storage/compression.ts +130 -0
- package/src/core/events/legacy-storage/index.ts +1 -0
- package/src/core/events/v3/archive-retention.ts +251 -0
- package/src/core/events/v3/bootstrap.ts +10 -0
- package/src/core/events/v3/capabilities.ts +2 -0
- package/src/core/events/v3/coordination-view.ts +100 -11
- package/src/core/events/v3/index.ts +9 -0
- package/src/core/events/v3/live-routing.ts +1 -0
- package/src/core/events/v3/producers/hook-base.ts +14 -2
- package/src/core/events/v3/producers/intake.ts +10 -2
- package/src/core/events/v3/producers/recorder.ts +420 -17
- package/src/core/hooks/adapter/output.ts +12 -1
- package/src/core/hooks/cli.ts +332 -247
- package/src/core/hooks/health.ts +146 -0
- package/src/core/hooks/resolve/transcript.ts +10 -3
- package/src/core/hooks/session-name-presence.ts +6 -1
- package/src/core/qa-artifacts.ts +126 -0
- package/src/core/resources/contract.ts +105 -0
- package/src/core/resources/index.ts +5 -0
- package/src/core/resources/sampler.ts +590 -0
- package/src/core/resources/service-status.ts +62 -0
- package/src/core/resources/service.ts +337 -0
- package/src/core/resources/storage.ts +44 -0
- package/src/core/storage/atomic-json.ts +15 -0
- package/src/core/storage/builtins.ts +62 -6
- package/src/core/storage/logger.ts +2 -0
- package/src/core/storage/query.ts +58 -0
- package/src/core/supervisor/activity.ts +193 -0
- package/src/core/supervisor/contract.ts +312 -0
- package/src/core/supervisor/explanations.ts +104 -0
- package/src/core/supervisor/findings.ts +788 -0
- package/src/core/supervisor/history.ts +61 -0
- package/src/core/supervisor/hook-health-alerts.ts +108 -0
- package/src/core/supervisor/hook-health-storage.ts +25 -0
- package/src/core/supervisor/hook-health.ts +301 -0
- package/src/core/supervisor/hooks.ts +35 -0
- package/src/core/supervisor/index.ts +14 -0
- package/src/core/supervisor/log-feed.ts +131 -0
- package/src/core/supervisor/service.ts +511 -0
- package/src/core/supervisor/services.ts +203 -0
- package/src/core/supervisor/status.ts +59 -0
- package/src/core/supervisor/storage.ts +126 -0
- package/src/core/supervisor/timeline.ts +133 -0
- package/src/core/workflow/admission.ts +215 -0
- package/src/core/workflow/engine.ts +93 -1
- package/src/core/workflow/index.ts +10 -0
- package/src/core/workflow/proof.ts +54 -1
- package/src/core/workflow/run-state.ts +13 -1
- package/src/core/workflow/types.ts +39 -1
- package/src/lib/admission.ts +347 -0
- package/src/lib/agent-browser/client.ts +2 -10
- package/src/lib/browser/capture-fidelity.ts +98 -0
- package/src/lib/browser/client.ts +212 -10
- package/src/lib/browser/critique.ts +62 -7
- package/src/lib/browser/index.ts +65 -0
- package/src/lib/browser/page-review-judge.ts +360 -0
- package/src/lib/browser/page-review-pack.ts +2384 -0
- package/src/lib/browser/qa-run-contracts.ts +743 -0
- package/src/lib/browser/qa-run.ts +1462 -0
- package/src/lib/browser/request-diagnostics.ts +27 -0
- package/src/lib/browser/tiling.ts +32 -0
- package/src/lib/cookies/client.ts +228 -42
- package/src/lib/cookies/extra.ts +28 -0
- package/src/lib/cookies/index.ts +2 -0
- package/src/lib/coord-root-id.ts +23 -0
- package/src/lib/durable-job.ts +407 -0
- package/src/lib/http/client.ts +13 -1
- package/src/lib/instructions/apply.ts +81 -2
- package/src/lib/instructions/templates.ts +11 -2
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
// One-command page-QA matrix runner: planner, deterministic gates per
|
|
2
|
+
// context, interaction assertions, manifest-required critique, and the QA
|
|
3
|
+
// snapshot — executed as child `browse` processes through a bounded pool, and
|
|
4
|
+
// reported as one fail-closed machine-readable result.
|
|
5
|
+
//
|
|
6
|
+
// The runner never builds a shell string: every child is an argv array handed
|
|
7
|
+
// to an injectable exec function (default: node:child_process execFile with a
|
|
8
|
+
// closed stdin, bounded output, and an explicit timeout). Every child that
|
|
9
|
+
// fails, times out, or dies silently becomes a named blocker or a failed
|
|
10
|
+
// outcome in the result — nothing is filtered away.
|
|
11
|
+
//
|
|
12
|
+
// Toolkit tier: this module must not import src/core (layering check).
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { cpus, freemem, loadavg, totalmem } from "node:os";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { DEFAULT_CRITIQUE_RUBRIC } from "./critique.js";
|
|
19
|
+
import { judgePageReviewPack, toCritiqueRecords } from "./page-review-judge.js";
|
|
20
|
+
import { finalizePageReviewPack, gateHitsFromEnvelope, PAGE_REVIEW_DEFAULT_RETENTION_MINUTES, PAGE_REVIEW_FINDINGS_FILENAME, PAGE_REVIEW_PACK_DIRNAME, PAGE_REVIEW_PACK_SCHEMA, PAGE_REVIEW_REVIEW_FILENAME, readPackContext, readPackDom, readPackFullPage, readPackManifest, readPackSignature, } from "./page-review-pack.js";
|
|
21
|
+
import { QA_CRITIQUE_CONTRACT_VERSION, rubricDigest } from "./qa-reuse.js";
|
|
22
|
+
import { computeJobDigest, computeVerdict, mergeCoverage, QA_RUN_RESULT_SCHEMA_VERSION, QA_RUN_STATUS_SCHEMA_VERSION, } from "./qa-run-contracts.js";
|
|
23
|
+
import { saveQaSnapshot } from "./qa-snapshot.js";
|
|
24
|
+
/** Set on the runner's own environment before the host's critique provider is
|
|
25
|
+
* loaded for the judge stage, unless the job permits metered critique: the
|
|
26
|
+
* provider must stay on subscription-backed headless harnesses and surface
|
|
27
|
+
* exhaustion instead of falling back to a metered API. */
|
|
28
|
+
export const QA_RUN_HEADLESS_ONLY_ENV = "HARNERY_CRITIQUE_HEADLESS_ONLY";
|
|
29
|
+
/** Result document written into the run's output directory. */
|
|
30
|
+
export const QA_RUN_RESULT_FILENAME = "page-qa-result.json";
|
|
31
|
+
/** Pointer document written into the parent output directory after every
|
|
32
|
+
* run, naming the newest run's directory and verdict. Consumers resolve the
|
|
33
|
+
* current result through this pointer instead of guessing at loose files. */
|
|
34
|
+
export const QA_RUN_LATEST_FILENAME = "latest.json";
|
|
35
|
+
/**
|
|
36
|
+
* Publish the parent directory's latest-result pointer without allowing an
|
|
37
|
+
* older completion to replace a newer one. Both runner and manual evidence
|
|
38
|
+
* use this writer so every producer preserves the same ordering invariant.
|
|
39
|
+
*/
|
|
40
|
+
export function writeLatestPointer(outParent, input) {
|
|
41
|
+
const pointerPath = join(outParent, QA_RUN_LATEST_FILENAME);
|
|
42
|
+
let pointerIsNewer = true;
|
|
43
|
+
try {
|
|
44
|
+
const existing = JSON.parse(readFileSync(pointerPath, "utf8"));
|
|
45
|
+
if (typeof existing.completed_at === "string") {
|
|
46
|
+
const existingCompletedAt = Date.parse(existing.completed_at);
|
|
47
|
+
const candidateCompletedAt = Date.parse(input.completed_at);
|
|
48
|
+
pointerIsNewer =
|
|
49
|
+
Number.isNaN(existingCompletedAt) || candidateCompletedAt >= existingCompletedAt;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No readable pointer yet: this result becomes the first one.
|
|
54
|
+
}
|
|
55
|
+
if (pointerIsNewer) {
|
|
56
|
+
const pointer = {
|
|
57
|
+
schema_version: 1,
|
|
58
|
+
...input,
|
|
59
|
+
result: join(input.dir, QA_RUN_RESULT_FILENAME),
|
|
60
|
+
};
|
|
61
|
+
const pointerTmp = join(outParent, `.${QA_RUN_LATEST_FILENAME}.${input.run_id}.tmp`);
|
|
62
|
+
writeFileSync(pointerTmp, `${JSON.stringify(pointer, null, 2)}\n`);
|
|
63
|
+
renameSync(pointerTmp, pointerPath);
|
|
64
|
+
}
|
|
65
|
+
return pointerPath;
|
|
66
|
+
}
|
|
67
|
+
/** Live status document beside the result (QaRunStatusDocument): written at
|
|
68
|
+
* start, every stage boundary, and on a heartbeat timer, so a disconnected
|
|
69
|
+
* client can tell a running job from a dead one without guessing. */
|
|
70
|
+
export const QA_RUN_STATUS_FILENAME = "run-status.json";
|
|
71
|
+
/** The effective validated job, written into the run directory so a
|
|
72
|
+
* reconnecting client can re-derive the job digest (`qa-verify --job`). */
|
|
73
|
+
export const QA_RUN_JOB_FILENAME = "job.json";
|
|
74
|
+
const STATUS_HEARTBEAT_MS = 15_000;
|
|
75
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
76
|
+
const DEFAULT_RUN_DEADLINE_MS = 900_000;
|
|
77
|
+
const DEFAULT_COMMAND_CONCURRENCY = 2;
|
|
78
|
+
/** Gate-hit rectangles forwarded to one context's capture child (`--review-pack-hit-rect`). */
|
|
79
|
+
const REVIEW_PACK_HIT_RECTS_PER_CONTEXT = 50;
|
|
80
|
+
const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
|
|
81
|
+
/** The planner's deterministic vocabulary is an executable contract, not a
|
|
82
|
+
* report-only hint. A job may add checks, but these arguments always run for
|
|
83
|
+
* every manifest context. `console` is enforced from the browse envelope
|
|
84
|
+
* because browse captures diagnostics without a separate fail flag. */
|
|
85
|
+
const MANIFEST_DETERMINISTIC_ARGS = {
|
|
86
|
+
console: [],
|
|
87
|
+
overflow: ["--check-overflow", "--check-overflow-fail"],
|
|
88
|
+
truncation: ["--check-truncation", "--check-truncation-fail"],
|
|
89
|
+
placeholder: ["--check-placeholder", "--check-placeholder-fail"],
|
|
90
|
+
};
|
|
91
|
+
/** Grace between the timeout's SIGTERM and the follow-up SIGKILL. A child
|
|
92
|
+
* that catches SIGTERM (Bun installs a handler by default) gets this long to
|
|
93
|
+
* exit before the kill is made non-negotiable. */
|
|
94
|
+
export const QA_RUN_KILL_GRACE_MS = 5_000;
|
|
95
|
+
/** After the group SIGKILL, how long to wait for stdio to drain before
|
|
96
|
+
* destroying the streams and settling anyway. An escaped grandchild (setsid)
|
|
97
|
+
* can hold the pipes open forever; the result must not wait on it. */
|
|
98
|
+
const KILL_DRAIN_MS = 2_000;
|
|
99
|
+
/** Default executor: spawn with argv arrays only (never a shell string),
|
|
100
|
+
* closed stdin, bounded output buffers, and the policy timeout.
|
|
101
|
+
*
|
|
102
|
+
* Timeout enforcement is escalated and group-wide, via `spawn` rather than
|
|
103
|
+
* `execFile` for two live-verified reasons. First, a child that catches
|
|
104
|
+
* SIGTERM while awaiting its own grandchildren turns a single polite kill
|
|
105
|
+
* into an unbounded wait (a critique command outlived its 120s cap by 10x).
|
|
106
|
+
* Second, `execFile` resolves only when the child's stdio closes, and an
|
|
107
|
+
* orphaned grandchild inheriting the pipe keeps it open after the child is
|
|
108
|
+
* dead — so even a delivered kill did not settle the call. The child is
|
|
109
|
+
* therefore spawned detached into its own process group; at the deadline the
|
|
110
|
+
* whole group gets SIGTERM, then SIGKILL after a grace, and the result
|
|
111
|
+
* settles on exit with whatever output drained, never waiting on a pipe an
|
|
112
|
+
* orphan still holds. A timed-out command reports an error even if the child
|
|
113
|
+
* then exits 0 — a result produced after the deadline cannot be trusted. */
|
|
114
|
+
export const defaultQaRunExec = (argv, options) => new Promise((resolvePromise) => {
|
|
115
|
+
const [command, ...args] = argv;
|
|
116
|
+
if (!command) {
|
|
117
|
+
resolvePromise({ exitCode: null, stdout: "", stderr: "", error: "empty argv" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Windows has no process groups; the direct-child kill is the best
|
|
121
|
+
// available fallback there.
|
|
122
|
+
const groupKill = process.platform !== "win32";
|
|
123
|
+
const child = spawn(command, args, {
|
|
124
|
+
env: options.env,
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
...(groupKill ? { detached: true } : {}),
|
|
127
|
+
});
|
|
128
|
+
let stdout = "";
|
|
129
|
+
let stderr = "";
|
|
130
|
+
let outBytes = 0;
|
|
131
|
+
let timedOut = false;
|
|
132
|
+
let overflowed = false;
|
|
133
|
+
let settled = false;
|
|
134
|
+
let termTimer;
|
|
135
|
+
let killTimer;
|
|
136
|
+
let drainTimer;
|
|
137
|
+
const settle = (result) => {
|
|
138
|
+
if (settled)
|
|
139
|
+
return;
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimeout(termTimer);
|
|
142
|
+
clearTimeout(killTimer);
|
|
143
|
+
clearTimeout(drainTimer);
|
|
144
|
+
resolvePromise(result);
|
|
145
|
+
};
|
|
146
|
+
const signalGroup = (signal) => {
|
|
147
|
+
const pid = child.pid;
|
|
148
|
+
if (!pid)
|
|
149
|
+
return;
|
|
150
|
+
if (groupKill) {
|
|
151
|
+
try {
|
|
152
|
+
process.kill(-pid, signal);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Group already gone, or the leader died before setpgid: fall
|
|
157
|
+
// through to the direct child so the kill still lands somewhere.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
child.kill(signal);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Nothing left to kill.
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const collect = (chunk, sink) => {
|
|
168
|
+
const text = String(chunk);
|
|
169
|
+
outBytes += text.length;
|
|
170
|
+
if (sink === "stdout")
|
|
171
|
+
stdout += text;
|
|
172
|
+
else
|
|
173
|
+
stderr += text;
|
|
174
|
+
if (outBytes > EXEC_MAX_BUFFER && !overflowed) {
|
|
175
|
+
overflowed = true;
|
|
176
|
+
signalGroup("SIGKILL");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
child.stdout?.on("data", (chunk) => collect(chunk, "stdout"));
|
|
180
|
+
child.stderr?.on("data", (chunk) => collect(chunk, "stderr"));
|
|
181
|
+
child.on("error", (err) => {
|
|
182
|
+
settle({ exitCode: null, stdout, stderr, error: err.message || "spawn failed" });
|
|
183
|
+
});
|
|
184
|
+
const finish = (code, signal) => {
|
|
185
|
+
if (timedOut) {
|
|
186
|
+
settle({
|
|
187
|
+
exitCode: null,
|
|
188
|
+
stdout,
|
|
189
|
+
stderr,
|
|
190
|
+
error: `timed out after ${options.timeoutMs}ms (process group killed)`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else if (overflowed) {
|
|
194
|
+
settle({
|
|
195
|
+
exitCode: null,
|
|
196
|
+
stdout,
|
|
197
|
+
stderr,
|
|
198
|
+
error: `output exceeded ${EXEC_MAX_BUFFER} bytes (process group killed)`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (signal) {
|
|
202
|
+
settle({ exitCode: null, stdout, stderr, error: `killed by ${signal}` });
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
settle({ exitCode: code, stdout, stderr });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
// "close" is the clean path: process exited AND stdio drained. "exit"
|
|
209
|
+
// arms the drain failsafe so an orphan holding the pipes cannot postpone
|
|
210
|
+
// the result forever.
|
|
211
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
212
|
+
child.on("exit", (code, signal) => {
|
|
213
|
+
drainTimer = setTimeout(() => {
|
|
214
|
+
child.stdout?.destroy();
|
|
215
|
+
child.stderr?.destroy();
|
|
216
|
+
finish(code, signal);
|
|
217
|
+
}, KILL_DRAIN_MS);
|
|
218
|
+
drainTimer.unref?.();
|
|
219
|
+
});
|
|
220
|
+
termTimer = setTimeout(() => {
|
|
221
|
+
timedOut = true;
|
|
222
|
+
signalGroup("SIGTERM");
|
|
223
|
+
}, options.timeoutMs);
|
|
224
|
+
killTimer = setTimeout(() => signalGroup("SIGKILL"), options.timeoutMs + QA_RUN_KILL_GRACE_MS);
|
|
225
|
+
termTimer.unref?.();
|
|
226
|
+
killTimer.unref?.();
|
|
227
|
+
});
|
|
228
|
+
/** One-line tail excerpt of a failed child's output for blockers and
|
|
229
|
+
* failures, preferring stderr and falling back to stdout (browse prints its
|
|
230
|
+
* JSON error envelope there). Without this, a blocker like "planner exited 1"
|
|
231
|
+
* gives the reader nothing to act on. */
|
|
232
|
+
function execErrorExcerpt(res, maxLength = 240) {
|
|
233
|
+
const source = res.stderr.trim().length > 0 ? res.stderr : res.stdout;
|
|
234
|
+
const flat = source.trim().replace(/\s+/g, " ");
|
|
235
|
+
if (flat.length === 0)
|
|
236
|
+
return undefined;
|
|
237
|
+
return flat.length > maxLength ? `…${flat.slice(-maxLength)}` : flat;
|
|
238
|
+
}
|
|
239
|
+
/** Fixed-size promise pool: at most `limit` workers in flight, items claimed
|
|
240
|
+
* in order, results written by index so completion order never matters. */
|
|
241
|
+
async function runPool(items, limit, worker) {
|
|
242
|
+
let next = 0;
|
|
243
|
+
const lanes = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
244
|
+
while (true) {
|
|
245
|
+
const index = next;
|
|
246
|
+
next += 1;
|
|
247
|
+
if (index >= items.length)
|
|
248
|
+
return;
|
|
249
|
+
const item = items[index];
|
|
250
|
+
await worker(item, index);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
await Promise.all(lanes);
|
|
254
|
+
}
|
|
255
|
+
function readEnvelope(jsonPath) {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
|
|
258
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
259
|
+
return parsed;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Best-effort: a missing or unparseable artifact is handled by the caller.
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
function gatherArtifacts(outPrefix) {
|
|
268
|
+
const artifacts = {};
|
|
269
|
+
const png = `${outPrefix}.png`;
|
|
270
|
+
const html = `${outPrefix}.html`;
|
|
271
|
+
const json = `${outPrefix}.json`;
|
|
272
|
+
if (existsSync(png))
|
|
273
|
+
artifacts.png = png;
|
|
274
|
+
if (existsSync(html))
|
|
275
|
+
artifacts.html = html;
|
|
276
|
+
if (existsSync(json))
|
|
277
|
+
artifacts.json = json;
|
|
278
|
+
return artifacts;
|
|
279
|
+
}
|
|
280
|
+
/** Best-effort extraction of failing-check details from a browse JSON
|
|
281
|
+
* envelope. The exit-code entry the caller records is the guaranteed
|
|
282
|
+
* minimum; these lines add human-readable specifics when parseable. */
|
|
283
|
+
function parseGateFailures(envelope) {
|
|
284
|
+
if (!envelope)
|
|
285
|
+
return [];
|
|
286
|
+
const failures = [];
|
|
287
|
+
const overflow = envelope.overflow;
|
|
288
|
+
if (overflow?.hasHorizontalOverflow) {
|
|
289
|
+
failures.push(`overflow: +${overflow.overflowPx ?? "?"}px horizontal overflow`);
|
|
290
|
+
}
|
|
291
|
+
const asserts = envelope.asserts;
|
|
292
|
+
for (const entry of asserts ?? []) {
|
|
293
|
+
if (entry.outcome === "fail") {
|
|
294
|
+
failures.push(`assert ${entry.op ?? "?"} ${entry.selector ?? "?"} → "${entry.actual ?? ""}"`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const visibility = envelope.visibility;
|
|
298
|
+
for (const entry of visibility ?? []) {
|
|
299
|
+
if (entry.found === false)
|
|
300
|
+
failures.push(`visibility ${entry.selector ?? "?"}: not found`);
|
|
301
|
+
else if (entry.cssVisible === false) {
|
|
302
|
+
failures.push(`visibility ${entry.selector ?? "?"}: CSS-hidden`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const critique = envelope.critique;
|
|
306
|
+
if (critique?.outcome === "fail") {
|
|
307
|
+
for (const finding of critique.findings ?? []) {
|
|
308
|
+
failures.push(`critique [${finding.severity ?? "?"}]: ${finding.description ?? ""}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return failures;
|
|
312
|
+
}
|
|
313
|
+
function diagnosticSummary(entry) {
|
|
314
|
+
if (typeof entry === "string")
|
|
315
|
+
return entry;
|
|
316
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
317
|
+
const record = entry;
|
|
318
|
+
for (const key of ["text", "message", "errorText", "failure", "url"]) {
|
|
319
|
+
const value = record[key];
|
|
320
|
+
if (typeof value === "string" && value.length > 0)
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return JSON.stringify(entry);
|
|
325
|
+
}
|
|
326
|
+
/** `console` in the planner contract covers every browser diagnostic that the
|
|
327
|
+
* browse envelope records. Keep the first three details actionable and name
|
|
328
|
+
* any remainder without allowing an unbounded page log to bloat the result. */
|
|
329
|
+
function parseConsoleFailures(envelope) {
|
|
330
|
+
if (!envelope)
|
|
331
|
+
return [];
|
|
332
|
+
const failures = [];
|
|
333
|
+
const groups = [
|
|
334
|
+
["console", envelope.consoleErrors],
|
|
335
|
+
["page", envelope.pageErrors],
|
|
336
|
+
["request", envelope.failedRequests],
|
|
337
|
+
];
|
|
338
|
+
for (const [label, value] of groups) {
|
|
339
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
340
|
+
continue;
|
|
341
|
+
for (const entry of value.slice(0, 3))
|
|
342
|
+
failures.push(`${label}: ${diagnosticSummary(entry)}`);
|
|
343
|
+
if (value.length > 3)
|
|
344
|
+
failures.push(`${label}: ${value.length - 3} additional failure(s)`);
|
|
345
|
+
}
|
|
346
|
+
return failures;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Lift per-backend vision latency out of the envelope's provider_meta. The
|
|
350
|
+
* shape is host-owned (`providers[name].latency_ms = {count, p50, p95}`), so
|
|
351
|
+
* read defensively: a malformed entry or one with zero calls is dropped.
|
|
352
|
+
* Without this the runner kept only the provider label, and a slow tile was
|
|
353
|
+
* visible in `ps` and nowhere in the result document.
|
|
354
|
+
*/
|
|
355
|
+
function critiqueLatency(providerMeta) {
|
|
356
|
+
const providers = providerMeta?.providers;
|
|
357
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers))
|
|
358
|
+
return undefined;
|
|
359
|
+
const out = {};
|
|
360
|
+
for (const [name, state] of Object.entries(providers)) {
|
|
361
|
+
const lat = state?.latency_ms;
|
|
362
|
+
if (!lat || typeof lat !== "object")
|
|
363
|
+
continue;
|
|
364
|
+
const { count, p50, p95 } = lat;
|
|
365
|
+
if (typeof count !== "number" || count <= 0)
|
|
366
|
+
continue;
|
|
367
|
+
if (typeof p50 !== "number" || typeof p95 !== "number")
|
|
368
|
+
continue;
|
|
369
|
+
out[name] = { count, p50, p95 };
|
|
370
|
+
}
|
|
371
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Execute the whole QA matrix for one validated job and return the result
|
|
375
|
+
* (also written to `<outDir>/page-qa-result.json`). Stages: plan →
|
|
376
|
+
* deterministic gates (bounded pool) → interactions (serial) → capture (each
|
|
377
|
+
* context rendered once into the run's page review pack through the same
|
|
378
|
+
* pool, browser closed) → critique (one in-process pool of vision calls over
|
|
379
|
+
* every tile of every context, no browser open) → snapshot (persisted from
|
|
380
|
+
* the pack's files in signoff). The verdict is computeVerdict over
|
|
381
|
+
* everything recorded — fail-closed.
|
|
382
|
+
*/
|
|
383
|
+
export async function runQaMatrix(options) {
|
|
384
|
+
const { job, outParent, browseArgv } = options;
|
|
385
|
+
const exec = options.exec ?? defaultQaRunExec;
|
|
386
|
+
const log = options.onLog ?? (() => { });
|
|
387
|
+
const timeoutMs = job.policy?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
388
|
+
const concurrency = job.policy?.command_concurrency ?? DEFAULT_COMMAND_CONCURRENCY;
|
|
389
|
+
const hostSample = () => {
|
|
390
|
+
const holders = options.admission?.holders?.() ?? undefined;
|
|
391
|
+
return {
|
|
392
|
+
captured_at: new Date().toISOString(),
|
|
393
|
+
loadavg_1m: loadavg()[0] ?? 0,
|
|
394
|
+
free_mem_bytes: freemem(),
|
|
395
|
+
total_mem_bytes: totalmem(),
|
|
396
|
+
cpu_count: cpus().length,
|
|
397
|
+
// Exclude this run itself: a holder list that names the sampler tells
|
|
398
|
+
// the reader nothing about contention.
|
|
399
|
+
...(holders ? { competing: holders.filter((holder) => holder.pid !== process.pid) } : {}),
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
const stageHostSamples = {};
|
|
403
|
+
const runId = options.runId ?? randomUUID();
|
|
404
|
+
const outDir = join(outParent, `run-${runId}`);
|
|
405
|
+
mkdirSync(outDir, { recursive: true });
|
|
406
|
+
const startedAtIso = new Date().toISOString();
|
|
407
|
+
writeFileSync(join(outDir, QA_RUN_JOB_FILENAME), `${JSON.stringify(job, null, 2)}\n`);
|
|
408
|
+
// Live status: state + stage + heartbeat, written atomically so a client
|
|
409
|
+
// that lost its terminal can distinguish this run being alive from dead.
|
|
410
|
+
let statusState = "running";
|
|
411
|
+
let statusStage = null;
|
|
412
|
+
let statusQueue;
|
|
413
|
+
let statusVerdict;
|
|
414
|
+
const writeStatus = () => {
|
|
415
|
+
const status = {
|
|
416
|
+
schema_version: QA_RUN_STATUS_SCHEMA_VERSION,
|
|
417
|
+
run_id: runId,
|
|
418
|
+
pid: process.pid,
|
|
419
|
+
state: statusState,
|
|
420
|
+
stage: statusStage,
|
|
421
|
+
started_at: startedAtIso,
|
|
422
|
+
updated_at: new Date().toISOString(),
|
|
423
|
+
...(statusQueue ? { queue: statusQueue } : {}),
|
|
424
|
+
...(statusVerdict ? { verdict: statusVerdict } : {}),
|
|
425
|
+
};
|
|
426
|
+
try {
|
|
427
|
+
const tmp = join(outDir, `.${QA_RUN_STATUS_FILENAME}.tmp`);
|
|
428
|
+
writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
|
|
429
|
+
renameSync(tmp, join(outDir, QA_RUN_STATUS_FILENAME));
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// Status is advisory; the result document is the authoritative record.
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const heartbeat = setInterval(writeStatus, STATUS_HEARTBEAT_MS);
|
|
436
|
+
heartbeat.unref();
|
|
437
|
+
/** Enter a stage: record the host pressure it starts under and refresh the
|
|
438
|
+
* live status document in one place. */
|
|
439
|
+
const enterStage = (stage) => {
|
|
440
|
+
statusStage = stage;
|
|
441
|
+
stageHostSamples[stage] = hostSample();
|
|
442
|
+
writeStatus();
|
|
443
|
+
};
|
|
444
|
+
const revision = job.tested_revision !== undefined
|
|
445
|
+
? { tested_revision: job.tested_revision, revision_source: "job" }
|
|
446
|
+
: options.revisionProbe?.tested_revision !== undefined
|
|
447
|
+
? {
|
|
448
|
+
tested_revision: options.revisionProbe.tested_revision,
|
|
449
|
+
revision_source: "git",
|
|
450
|
+
...(options.revisionProbe.worktree_dirty !== undefined
|
|
451
|
+
? { worktree_dirty: options.revisionProbe.worktree_dirty }
|
|
452
|
+
: {}),
|
|
453
|
+
}
|
|
454
|
+
: { revision_source: "unknown" };
|
|
455
|
+
const jobDigest = computeJobDigest(job);
|
|
456
|
+
const wall = {
|
|
457
|
+
plan: 0,
|
|
458
|
+
gates: 0,
|
|
459
|
+
interactions: 0,
|
|
460
|
+
capture: 0,
|
|
461
|
+
critique: 0,
|
|
462
|
+
snapshot: 0,
|
|
463
|
+
total: 0,
|
|
464
|
+
};
|
|
465
|
+
// ------------------------------------------------------------- admission
|
|
466
|
+
// Queue wait happens before the runner clock starts: wall_time_ms.total
|
|
467
|
+
// stays pure runner time and the wait is reported as wall_time_ms.queue.
|
|
468
|
+
let releaseAdmission;
|
|
469
|
+
let admissionFailure;
|
|
470
|
+
if (options.admission) {
|
|
471
|
+
statusState = "queued";
|
|
472
|
+
statusQueue = {
|
|
473
|
+
resource: options.admission.resource,
|
|
474
|
+
waiting_since: new Date().toISOString(),
|
|
475
|
+
};
|
|
476
|
+
writeStatus();
|
|
477
|
+
const queueStart = Date.now();
|
|
478
|
+
try {
|
|
479
|
+
releaseAdmission = await options.admission.acquire((message) => {
|
|
480
|
+
log(message);
|
|
481
|
+
writeStatus();
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
admissionFailure = err instanceof Error ? err.message : String(err);
|
|
486
|
+
}
|
|
487
|
+
wall.queue = Date.now() - queueStart;
|
|
488
|
+
statusQueue = undefined;
|
|
489
|
+
}
|
|
490
|
+
statusState = "running";
|
|
491
|
+
writeStatus();
|
|
492
|
+
const hostStart = hostSample();
|
|
493
|
+
const startedAt = Date.now();
|
|
494
|
+
const blockers = [];
|
|
495
|
+
// Overall deadline: the clock starts after admission (pure runner time) and
|
|
496
|
+
// is consulted before every command, so the worst overshoot is one command
|
|
497
|
+
// timeout plus the kill grace. Exceeding it fails closed as incomplete.
|
|
498
|
+
const runDeadlineMs = job.policy?.run_deadline_ms ?? DEFAULT_RUN_DEADLINE_MS;
|
|
499
|
+
let deadlineHit = false;
|
|
500
|
+
const pastDeadline = () => {
|
|
501
|
+
if (deadlineHit)
|
|
502
|
+
return true;
|
|
503
|
+
if (Date.now() - startedAt < runDeadlineMs)
|
|
504
|
+
return false;
|
|
505
|
+
deadlineHit = true;
|
|
506
|
+
blockers.push({
|
|
507
|
+
stage: "deadline",
|
|
508
|
+
reason: `run deadline of ${runDeadlineMs}ms exceeded — remaining commands were skipped and ` +
|
|
509
|
+
"the result finalized as incomplete (raise policy.run_deadline_ms for a legitimately " +
|
|
510
|
+
"larger matrix)",
|
|
511
|
+
});
|
|
512
|
+
log(`deadline: ${runDeadlineMs}ms exceeded, skipping remaining commands`);
|
|
513
|
+
return true;
|
|
514
|
+
};
|
|
515
|
+
const commands = [];
|
|
516
|
+
const critique = [];
|
|
517
|
+
const stagesRun = [];
|
|
518
|
+
let manifest = null;
|
|
519
|
+
let contexts = [];
|
|
520
|
+
let snapshot = { saved: false };
|
|
521
|
+
let critiquePool;
|
|
522
|
+
let reviewPack;
|
|
523
|
+
const baseEnv = { ...process.env, ...options.childEnv };
|
|
524
|
+
const timedExec = async (argv, env) => {
|
|
525
|
+
const started = Date.now();
|
|
526
|
+
let res;
|
|
527
|
+
try {
|
|
528
|
+
res = await exec(argv, { timeoutMs, env });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
res = {
|
|
532
|
+
exitCode: null,
|
|
533
|
+
stdout: "",
|
|
534
|
+
stderr: "",
|
|
535
|
+
error: err instanceof Error ? err.message : String(err),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
return { res, wallTimeMs: Date.now() - started };
|
|
539
|
+
};
|
|
540
|
+
const finalize = () => {
|
|
541
|
+
wall.total = Date.now() - startedAt;
|
|
542
|
+
// Prefix semantics: the last stage the run progressed THROUGH cleanly.
|
|
543
|
+
// Stages are pushed in execution order; the first blocked stage ends the
|
|
544
|
+
// clean prefix, so a gate-blocked run reports "plan" even though the
|
|
545
|
+
// (empty) interactions stage technically executed afterwards.
|
|
546
|
+
const blockedStages = new Set(blockers.map((blocker) => blocker.stage));
|
|
547
|
+
let lastCompletedStage = null;
|
|
548
|
+
for (const stage of stagesRun) {
|
|
549
|
+
if (blockedStages.has(stage))
|
|
550
|
+
break;
|
|
551
|
+
lastCompletedStage = stage;
|
|
552
|
+
}
|
|
553
|
+
const result = {
|
|
554
|
+
schema_version: QA_RUN_RESULT_SCHEMA_VERSION,
|
|
555
|
+
evidence_source: "runner",
|
|
556
|
+
run: {
|
|
557
|
+
run_id: runId,
|
|
558
|
+
started_at: startedAtIso,
|
|
559
|
+
completed_at: new Date().toISOString(),
|
|
560
|
+
...revision,
|
|
561
|
+
job_digest: jobDigest,
|
|
562
|
+
out_dir: outDir,
|
|
563
|
+
},
|
|
564
|
+
host: {
|
|
565
|
+
start: hostStart,
|
|
566
|
+
finish: hostSample(),
|
|
567
|
+
...(Object.keys(stageHostSamples).length > 0 ? { stages: stageHostSamples } : {}),
|
|
568
|
+
},
|
|
569
|
+
last_completed_stage: lastCompletedStage,
|
|
570
|
+
target: job.target,
|
|
571
|
+
...(revision.tested_revision !== undefined
|
|
572
|
+
? { tested_revision: revision.tested_revision }
|
|
573
|
+
: {}),
|
|
574
|
+
mode: job.mode,
|
|
575
|
+
qa_plan: manifest,
|
|
576
|
+
contexts,
|
|
577
|
+
commands,
|
|
578
|
+
critique,
|
|
579
|
+
...(critiquePool ? { critique_pool: critiquePool } : {}),
|
|
580
|
+
...(reviewPack ? { review_pack: reviewPack } : {}),
|
|
581
|
+
snapshot,
|
|
582
|
+
wall_time_ms: { ...wall },
|
|
583
|
+
blockers,
|
|
584
|
+
verdict: computeVerdict({
|
|
585
|
+
mode: job.mode,
|
|
586
|
+
blockers,
|
|
587
|
+
commands,
|
|
588
|
+
critique,
|
|
589
|
+
snapshotSaved: snapshot.saved,
|
|
590
|
+
}),
|
|
591
|
+
};
|
|
592
|
+
writeFileSync(join(outDir, QA_RUN_RESULT_FILENAME), `${JSON.stringify(result, null, 2)}\n`);
|
|
593
|
+
writeLatestPointer(outParent, {
|
|
594
|
+
run_id: runId,
|
|
595
|
+
dir: basename(outDir),
|
|
596
|
+
completed_at: result.run.completed_at,
|
|
597
|
+
verdict: result.verdict,
|
|
598
|
+
});
|
|
599
|
+
statusState = "completed";
|
|
600
|
+
statusStage = null;
|
|
601
|
+
statusVerdict = result.verdict;
|
|
602
|
+
writeStatus();
|
|
603
|
+
clearInterval(heartbeat);
|
|
604
|
+
releaseAdmission?.();
|
|
605
|
+
return result;
|
|
606
|
+
};
|
|
607
|
+
if (admissionFailure !== undefined) {
|
|
608
|
+
blockers.push({
|
|
609
|
+
stage: "admission",
|
|
610
|
+
reason: `no admission slot for browser work: ${admissionFailure}`,
|
|
611
|
+
});
|
|
612
|
+
return finalize();
|
|
613
|
+
}
|
|
614
|
+
// Base render arguments shared by every per-context invocation.
|
|
615
|
+
const contextRenderArgs = (ctx) => [
|
|
616
|
+
"--viewport",
|
|
617
|
+
ctx.viewport,
|
|
618
|
+
...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
|
|
619
|
+
...(ctx.args ?? []),
|
|
620
|
+
];
|
|
621
|
+
// The planner's tile ceiling and every critique child must agree on the
|
|
622
|
+
// per-context band cap, or the predicted cost and the real coverage drift.
|
|
623
|
+
const critiqueMaxTilesArgs = job.policy?.critique_max_tiles !== undefined
|
|
624
|
+
? ["--check-critique-max-tiles", String(job.policy.critique_max_tiles)]
|
|
625
|
+
: [];
|
|
626
|
+
// ------------------------------------------------------------------ plan
|
|
627
|
+
const planArgv = [
|
|
628
|
+
...browseArgv,
|
|
629
|
+
job.target,
|
|
630
|
+
"--qa-plan",
|
|
631
|
+
"--json",
|
|
632
|
+
"--no-screenshot",
|
|
633
|
+
...critiqueMaxTilesArgs,
|
|
634
|
+
...(job.qa_hints?.scopes ?? []).flatMap((selector) => ["--qa-scope", selector]),
|
|
635
|
+
...(job.qa_hints?.states?.length ? ["--qa-states", job.qa_hints.states.join(",")] : []),
|
|
636
|
+
];
|
|
637
|
+
log(`plan: ${job.target}`);
|
|
638
|
+
enterStage("plan");
|
|
639
|
+
const planStart = Date.now();
|
|
640
|
+
const plan = await timedExec(planArgv, baseEnv);
|
|
641
|
+
wall.plan = Date.now() - planStart;
|
|
642
|
+
stagesRun.push("plan");
|
|
643
|
+
let planEnvelope;
|
|
644
|
+
if (!plan.res.error && plan.res.exitCode === 0) {
|
|
645
|
+
try {
|
|
646
|
+
const parsed = JSON.parse(plan.res.stdout);
|
|
647
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
648
|
+
planEnvelope = parsed;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// handled below as a plan blocker
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const qaPlanReport = planEnvelope?.qaPlan;
|
|
656
|
+
manifest = qaPlanReport?.manifest ?? null;
|
|
657
|
+
const planUsable = manifest !== null && !manifest.incomplete && plan.res.exitCode === 0;
|
|
658
|
+
commands.push({
|
|
659
|
+
context_id: "plan",
|
|
660
|
+
check_id: "plan",
|
|
661
|
+
argv: planArgv,
|
|
662
|
+
exit_code: plan.res.exitCode,
|
|
663
|
+
outcome: planUsable ? "passed" : "unknown",
|
|
664
|
+
failures: planUsable
|
|
665
|
+
? []
|
|
666
|
+
: [plan.res.error, execErrorExcerpt(plan.res)].filter((entry) => Boolean(entry)),
|
|
667
|
+
artifacts: {},
|
|
668
|
+
wall_time_ms: plan.wallTimeMs,
|
|
669
|
+
});
|
|
670
|
+
if (plan.res.error) {
|
|
671
|
+
blockers.push({ stage: "plan", reason: `planner command did not complete: ${plan.res.error}` });
|
|
672
|
+
}
|
|
673
|
+
else if (plan.res.exitCode !== 0) {
|
|
674
|
+
const excerpt = execErrorExcerpt(plan.res);
|
|
675
|
+
blockers.push({
|
|
676
|
+
stage: "plan",
|
|
677
|
+
reason: `planner exited ${plan.res.exitCode}${excerpt ? `: ${excerpt}` : ""}`,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
else if (!manifest) {
|
|
681
|
+
blockers.push({
|
|
682
|
+
stage: "plan",
|
|
683
|
+
reason: "planner envelope carried no parseable qaPlan.manifest",
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
else if (manifest.incomplete) {
|
|
687
|
+
blockers.push({
|
|
688
|
+
stage: "plan",
|
|
689
|
+
reason: `planner manifest is incomplete: ${manifest.incomplete.reason}`,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (!planUsable || !manifest) {
|
|
693
|
+
// Stop before any further browser work: an untrusted plan cannot gate.
|
|
694
|
+
contexts = manifest ? mergeCoverage(manifest, job) : [];
|
|
695
|
+
return finalize();
|
|
696
|
+
}
|
|
697
|
+
// ----------------------------------------------------------------- gates
|
|
698
|
+
contexts = mergeCoverage(manifest, job);
|
|
699
|
+
const unsupportedDeterministic = manifest.checks.deterministic.filter((check) => MANIFEST_DETERMINISTIC_ARGS[check] === undefined);
|
|
700
|
+
if (unsupportedDeterministic.length > 0) {
|
|
701
|
+
for (const check of unsupportedDeterministic) {
|
|
702
|
+
blockers.push({
|
|
703
|
+
stage: "gates",
|
|
704
|
+
reason: `planner requires deterministic check "${check}" but qa-run has no executable ` +
|
|
705
|
+
"mapping for it — coverage cannot be narrowed",
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return finalize();
|
|
709
|
+
}
|
|
710
|
+
const manifestGateArgs = manifest.checks.deterministic.flatMap((check) => MANIFEST_DETERMINISTIC_ARGS[check] ?? []);
|
|
711
|
+
const enforceConsole = manifest.checks.deterministic.includes("console");
|
|
712
|
+
const checks = job.checks ?? [];
|
|
713
|
+
const gateOutcomes = new Array(contexts.length);
|
|
714
|
+
enterStage("gates");
|
|
715
|
+
const gatesStart = Date.now();
|
|
716
|
+
await runPool(contexts, concurrency, async (ctx, index) => {
|
|
717
|
+
if (pastDeadline())
|
|
718
|
+
return;
|
|
719
|
+
const applicable = checks.filter((check) => check.contexts === undefined || check.contexts.includes(ctx.id));
|
|
720
|
+
const checkId = [
|
|
721
|
+
...manifest.checks.deterministic.map((check) => `manifest:${check}`),
|
|
722
|
+
...applicable.map((check) => check.id),
|
|
723
|
+
].join("+") || "capture";
|
|
724
|
+
const outPrefix = join(outDir, ctx.id);
|
|
725
|
+
const argv = [
|
|
726
|
+
...browseArgv,
|
|
727
|
+
job.target,
|
|
728
|
+
...contextRenderArgs(ctx),
|
|
729
|
+
"--out",
|
|
730
|
+
outPrefix,
|
|
731
|
+
// When the manifest requires a visual pass, the capture stage writes the
|
|
732
|
+
// full-page PNG into the pack, so the gate trio carries no screenshot;
|
|
733
|
+
// nothing reads the gate PNG (the envelope JSON is the evidence).
|
|
734
|
+
...(manifest.checks.visual !== "none" ? ["--no-screenshot"] : []),
|
|
735
|
+
...manifestGateArgs,
|
|
736
|
+
...applicable.flatMap((check) => check.args),
|
|
737
|
+
];
|
|
738
|
+
log(`gate ${ctx.id}: ${checkId}`);
|
|
739
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
740
|
+
const jsonPath = `${outPrefix}.json`;
|
|
741
|
+
const failures = [];
|
|
742
|
+
let outcome;
|
|
743
|
+
const envelope = existsSync(jsonPath) ? readEnvelope(jsonPath) : undefined;
|
|
744
|
+
const consoleFailures = enforceConsole ? parseConsoleFailures(envelope) : [];
|
|
745
|
+
if (!res.error && res.exitCode === 0 && existsSync(jsonPath)) {
|
|
746
|
+
if (consoleFailures.length > 0) {
|
|
747
|
+
outcome = "failed";
|
|
748
|
+
failures.push(...consoleFailures);
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
outcome = "passed";
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
else if (!res.error && res.exitCode === 2 && existsSync(jsonPath)) {
|
|
755
|
+
outcome = "failed";
|
|
756
|
+
failures.push(`exit code 2 (${jsonPath})`);
|
|
757
|
+
failures.push(...parseGateFailures(envelope), ...consoleFailures);
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
outcome = "unknown";
|
|
761
|
+
const excerpt = execErrorExcerpt(res);
|
|
762
|
+
const base = res.error
|
|
763
|
+
? res.error
|
|
764
|
+
: !existsSync(jsonPath)
|
|
765
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
766
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
767
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
768
|
+
failures.push(reason);
|
|
769
|
+
blockers.push({
|
|
770
|
+
stage: "gates",
|
|
771
|
+
context_id: ctx.id,
|
|
772
|
+
reason: `gate command did not complete: ${reason}`,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
gateOutcomes[index] = {
|
|
776
|
+
context_id: ctx.id,
|
|
777
|
+
check_id: checkId,
|
|
778
|
+
argv,
|
|
779
|
+
exit_code: res.exitCode,
|
|
780
|
+
outcome,
|
|
781
|
+
failures,
|
|
782
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
783
|
+
wall_time_ms: wallTimeMs,
|
|
784
|
+
};
|
|
785
|
+
});
|
|
786
|
+
wall.gates = Date.now() - gatesStart;
|
|
787
|
+
stagesRun.push("gates");
|
|
788
|
+
// Manifest order regardless of completion order: outcomes were written by
|
|
789
|
+
// context index, so a straight push preserves it. Deadline-skipped slots
|
|
790
|
+
// are empty: the single deadline blocker is their record.
|
|
791
|
+
commands.push(...gateOutcomes.filter((outcome) => outcome !== undefined));
|
|
792
|
+
// ---------------------------------------------------------- interactions
|
|
793
|
+
enterStage("interactions");
|
|
794
|
+
const interactionsStart = Date.now();
|
|
795
|
+
const declaredStates = new Set((job.interaction_states ?? []).map((state) => state.name));
|
|
796
|
+
const manifestStates = [
|
|
797
|
+
...new Set(manifest.contexts.map((context) => context.state).filter((state) => state !== "default")),
|
|
798
|
+
];
|
|
799
|
+
for (const state of manifestStates) {
|
|
800
|
+
if (!declaredStates.has(state)) {
|
|
801
|
+
blockers.push({
|
|
802
|
+
stage: "interactions",
|
|
803
|
+
reason: `manifest requires interaction state "${state}" but the job declares no matching ` +
|
|
804
|
+
"interaction_states entry — job coverage may never be narrower than the manifest",
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
for (const state of job.interaction_states ?? []) {
|
|
809
|
+
if (pastDeadline())
|
|
810
|
+
break;
|
|
811
|
+
const outPrefix = join(outDir, `interaction-${state.name}`);
|
|
812
|
+
const argv = [
|
|
813
|
+
...browseArgv,
|
|
814
|
+
job.target,
|
|
815
|
+
"--out",
|
|
816
|
+
outPrefix,
|
|
817
|
+
...state.setup,
|
|
818
|
+
...state.assertions.flatMap((assertion) => ["--assert", assertion]),
|
|
819
|
+
"--assert-fail",
|
|
820
|
+
];
|
|
821
|
+
log(`interaction ${state.name}`);
|
|
822
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
823
|
+
const jsonPath = `${outPrefix}.json`;
|
|
824
|
+
const failures = [];
|
|
825
|
+
let outcome;
|
|
826
|
+
if (!res.error && res.exitCode === 0 && existsSync(jsonPath)) {
|
|
827
|
+
outcome = "passed";
|
|
828
|
+
}
|
|
829
|
+
else if (!res.error && res.exitCode === 2 && existsSync(jsonPath)) {
|
|
830
|
+
outcome = "failed";
|
|
831
|
+
failures.push(`exit code 2 (${jsonPath})`);
|
|
832
|
+
failures.push(...parseGateFailures(readEnvelope(jsonPath)));
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
outcome = "unknown";
|
|
836
|
+
const excerpt = execErrorExcerpt(res);
|
|
837
|
+
const base = res.error
|
|
838
|
+
? res.error
|
|
839
|
+
: !existsSync(jsonPath)
|
|
840
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
841
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
842
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
843
|
+
failures.push(reason);
|
|
844
|
+
blockers.push({
|
|
845
|
+
stage: "interactions",
|
|
846
|
+
context_id: state.name,
|
|
847
|
+
reason: `interaction command did not complete: ${reason}`,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
commands.push({
|
|
851
|
+
context_id: state.name,
|
|
852
|
+
check_id: `interaction:${state.name}`,
|
|
853
|
+
argv,
|
|
854
|
+
exit_code: res.exitCode,
|
|
855
|
+
outcome,
|
|
856
|
+
failures,
|
|
857
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
858
|
+
wall_time_ms: wallTimeMs,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
wall.interactions = Date.now() - interactionsStart;
|
|
862
|
+
stagesRun.push("interactions");
|
|
863
|
+
// -------------------------------------------------------------- capture
|
|
864
|
+
// Each context is rendered ONCE into the run's page review pack (full-page
|
|
865
|
+
// screenshot, tiles as PNG files, DOM, signature) through the same bounded
|
|
866
|
+
// pool the gates used, and its browser closes before any vision call.
|
|
867
|
+
const visual = manifest.checks.visual;
|
|
868
|
+
const cleanSoFar = blockers.length === 0 && commands.every((command) => command.outcome === "passed");
|
|
869
|
+
const packDir = join(outDir, PAGE_REVIEW_PACK_DIRNAME);
|
|
870
|
+
const capturedRecords = [];
|
|
871
|
+
const judgeCommand = options.reviewPackJudgeCommand?.(packDir);
|
|
872
|
+
// Gate rectangles ride along from each gate's JSON artifact so the pack's
|
|
873
|
+
// inspection plan can point a runt, a contrast miss, or a clipped element
|
|
874
|
+
// at the tiles that show it. A missing or unparseable artifact simply
|
|
875
|
+
// contributes no hits; the gate's `failures` still carry the text.
|
|
876
|
+
const gateRecords = () => commands
|
|
877
|
+
.filter((command) => command.check_id !== "plan" && command.check_id !== "review-pack")
|
|
878
|
+
.map((command) => {
|
|
879
|
+
const hits = command.artifacts.json
|
|
880
|
+
? gateHitsFromEnvelope(readEnvelope(command.artifacts.json))
|
|
881
|
+
: [];
|
|
882
|
+
return {
|
|
883
|
+
context_id: command.context_id,
|
|
884
|
+
check_id: command.check_id,
|
|
885
|
+
outcome: command.outcome,
|
|
886
|
+
failures: command.failures,
|
|
887
|
+
...(hits.length > 0 ? { hits } : {}),
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
const finalizePack = (stage, critiqueRecords, pool) => {
|
|
891
|
+
try {
|
|
892
|
+
finalizePageReviewPack({
|
|
893
|
+
packDir,
|
|
894
|
+
target: job.target,
|
|
895
|
+
...(revision.tested_revision !== undefined
|
|
896
|
+
? { tested_revision: revision.tested_revision }
|
|
897
|
+
: {}),
|
|
898
|
+
contexts: capturedRecords,
|
|
899
|
+
gates: gateRecords(),
|
|
900
|
+
critique: critiqueRecords,
|
|
901
|
+
...(pool ? { pool } : {}),
|
|
902
|
+
...(judgeCommand ? { judgeCommand } : {}),
|
|
903
|
+
createdAt: startedAtIso,
|
|
904
|
+
// The retention clock restarts at each finalize, so it ends
|
|
905
|
+
// `retention_minutes` after the judge (or after capture when the
|
|
906
|
+
// judge never runs).
|
|
907
|
+
retention: {
|
|
908
|
+
expires_at: new Date(Date.now() +
|
|
909
|
+
(job.policy?.review_pack_retention_minutes ?? PAGE_REVIEW_DEFAULT_RETENTION_MINUTES) *
|
|
910
|
+
60_000).toISOString(),
|
|
911
|
+
managed: options.reviewPackManaged === true,
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
const written = readPackManifest(packDir);
|
|
915
|
+
reviewPack = {
|
|
916
|
+
schema: PAGE_REVIEW_PACK_SCHEMA,
|
|
917
|
+
dir: packDir,
|
|
918
|
+
review: join(packDir, PAGE_REVIEW_REVIEW_FILENAME),
|
|
919
|
+
findings: join(packDir, PAGE_REVIEW_FINDINGS_FILENAME),
|
|
920
|
+
...(written.retention ? { expires_at: written.retention.expires_at } : {}),
|
|
921
|
+
...(written.size_bytes !== undefined ? { size_bytes: written.size_bytes } : {}),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
blockers.push({
|
|
926
|
+
stage,
|
|
927
|
+
reason: `page review pack could not be finalized: ${err instanceof Error ? err.message : String(err)}`,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
// The provider is loaded once, before capture, so the capture children can
|
|
932
|
+
// clamp band height to the routed model's vision budget (what browse does
|
|
933
|
+
// for --check-critique) and the judge reuses the same instance.
|
|
934
|
+
const priorHeadlessOnly = process.env[QA_RUN_HEADLESS_ONLY_ENV];
|
|
935
|
+
const restoreHeadlessOnly = () => {
|
|
936
|
+
if (priorHeadlessOnly === undefined)
|
|
937
|
+
delete process.env[QA_RUN_HEADLESS_ONLY_ENV];
|
|
938
|
+
else
|
|
939
|
+
process.env[QA_RUN_HEADLESS_ONLY_ENV] = priorHeadlessOnly;
|
|
940
|
+
};
|
|
941
|
+
let provider;
|
|
942
|
+
const runsVisual = cleanSoFar && visual !== "none";
|
|
943
|
+
if (runsVisual) {
|
|
944
|
+
if (!job.policy?.allow_metered_critique)
|
|
945
|
+
process.env[QA_RUN_HEADLESS_ONLY_ENV] = "1";
|
|
946
|
+
try {
|
|
947
|
+
provider =
|
|
948
|
+
options.critiqueProvider ??
|
|
949
|
+
(options.critiqueProviderLoader ? await options.critiqueProviderLoader() : undefined);
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
provider = undefined;
|
|
953
|
+
log(`critique provider failed to load: ${err instanceof Error ? err.message : String(err)}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const bandArgs = provider?.tileBudgetPx !== undefined
|
|
957
|
+
? ["--check-critique-band", String(Math.max(200, Math.min(1400, provider.tileBudgetPx)))]
|
|
958
|
+
: [];
|
|
959
|
+
const scopeArgs = visual === "full-page"
|
|
960
|
+
? []
|
|
961
|
+
: manifest.scopes.flatMap((s) => ["--review-pack-scope", s.selector]);
|
|
962
|
+
// Gate-hit rectangles ride into each context's capture child so the bands
|
|
963
|
+
// they land in are cut even past the tile cap (`hit band N` tiles). The
|
|
964
|
+
// gates ran first, so the rects are known before the page is captured.
|
|
965
|
+
const hitRectsByContext = new Map();
|
|
966
|
+
for (const gate of gateRecords()) {
|
|
967
|
+
for (const hit of gate.hits ?? []) {
|
|
968
|
+
const list = hitRectsByContext.get(gate.context_id) ?? [];
|
|
969
|
+
if (list.length >= REVIEW_PACK_HIT_RECTS_PER_CONTEXT)
|
|
970
|
+
break;
|
|
971
|
+
const r = hit.rect;
|
|
972
|
+
list.push([r.x, r.y, Math.max(0, r.width), Math.max(0, r.height)].map((n) => Math.round(n)).join(","));
|
|
973
|
+
hitRectsByContext.set(gate.context_id, list);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const hitRectArgs = (contextId) => (hitRectsByContext.get(contextId) ?? []).flatMap((spec) => ["--review-pack-hit-rect", spec]);
|
|
977
|
+
enterStage("capture");
|
|
978
|
+
const captureStart = Date.now();
|
|
979
|
+
if (runsVisual) {
|
|
980
|
+
const captureOutcomes = new Array(contexts.length);
|
|
981
|
+
const captureRecords = new Array(contexts.length);
|
|
982
|
+
await runPool(contexts, concurrency, async (ctx, index) => {
|
|
983
|
+
if (pastDeadline())
|
|
984
|
+
return;
|
|
985
|
+
const outPrefix = join(outDir, `${ctx.id}-capture`);
|
|
986
|
+
const argv = [
|
|
987
|
+
...browseArgv,
|
|
988
|
+
job.target,
|
|
989
|
+
...contextRenderArgs(ctx),
|
|
990
|
+
"--out",
|
|
991
|
+
outPrefix,
|
|
992
|
+
"--no-screenshot",
|
|
993
|
+
"--review-pack",
|
|
994
|
+
packDir,
|
|
995
|
+
"--review-pack-context",
|
|
996
|
+
ctx.id,
|
|
997
|
+
"--qa-theme",
|
|
998
|
+
ctx.theme,
|
|
999
|
+
"--qa-state",
|
|
1000
|
+
ctx.state,
|
|
1001
|
+
...(visual === "scoped" ? ["--no-review-pack-bands"] : []),
|
|
1002
|
+
...scopeArgs,
|
|
1003
|
+
...critiqueMaxTilesArgs,
|
|
1004
|
+
...bandArgs,
|
|
1005
|
+
...hitRectArgs(ctx.id),
|
|
1006
|
+
];
|
|
1007
|
+
log(`capture ${ctx.id}${scopeArgs.length > 0 ? ` [${manifest.scopes.map((s) => s.selector).join(",")}]` : ""}`);
|
|
1008
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
1009
|
+
const jsonPath = `${outPrefix}.json`;
|
|
1010
|
+
const envelope = readEnvelope(jsonPath);
|
|
1011
|
+
const report = envelope?.reviewPack;
|
|
1012
|
+
const failures = [];
|
|
1013
|
+
let outcome;
|
|
1014
|
+
let record;
|
|
1015
|
+
if (!res.error && res.exitCode === 0 && envelope && report?.context_id) {
|
|
1016
|
+
try {
|
|
1017
|
+
record = readPackContext(packDir, report.context_id);
|
|
1018
|
+
outcome = "passed";
|
|
1019
|
+
}
|
|
1020
|
+
catch (err) {
|
|
1021
|
+
outcome = "unknown";
|
|
1022
|
+
const reason = `pack context unreadable: ${err instanceof Error ? err.message : String(err)}`;
|
|
1023
|
+
failures.push(reason);
|
|
1024
|
+
blockers.push({ stage: "capture", context_id: ctx.id, reason });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
outcome = "unknown";
|
|
1029
|
+
const excerpt = execErrorExcerpt(res);
|
|
1030
|
+
const base = res.error
|
|
1031
|
+
? res.error
|
|
1032
|
+
: !envelope
|
|
1033
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
1034
|
+
: !report?.context_id
|
|
1035
|
+
? `exit code ${res.exitCode ?? "null"}, envelope carries no reviewPack record`
|
|
1036
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
1037
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
1038
|
+
failures.push(reason);
|
|
1039
|
+
blockers.push({
|
|
1040
|
+
stage: "capture",
|
|
1041
|
+
context_id: ctx.id,
|
|
1042
|
+
reason: `capture command did not complete: ${reason}`,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
captureOutcomes[index] = {
|
|
1046
|
+
context_id: ctx.id,
|
|
1047
|
+
check_id: "review-pack",
|
|
1048
|
+
argv,
|
|
1049
|
+
exit_code: res.exitCode,
|
|
1050
|
+
outcome,
|
|
1051
|
+
failures,
|
|
1052
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
1053
|
+
wall_time_ms: wallTimeMs,
|
|
1054
|
+
};
|
|
1055
|
+
captureRecords[index] = record;
|
|
1056
|
+
});
|
|
1057
|
+
commands.push(...captureOutcomes.filter((outcome) => outcome !== undefined));
|
|
1058
|
+
capturedRecords.push(...captureRecords.filter((record) => Boolean(record)));
|
|
1059
|
+
// The pack is readable from here on even if the judge never runs.
|
|
1060
|
+
if (capturedRecords.length > 0)
|
|
1061
|
+
finalizePack("capture", null);
|
|
1062
|
+
}
|
|
1063
|
+
wall.capture = Date.now() - captureStart;
|
|
1064
|
+
if (runsVisual)
|
|
1065
|
+
stagesRun.push("capture");
|
|
1066
|
+
// -------------------------------------------------------------- critique
|
|
1067
|
+
// One pool of vision calls across every captured context, from disk. No
|
|
1068
|
+
// browser is open during this stage.
|
|
1069
|
+
enterStage("critique");
|
|
1070
|
+
const critiqueStart = Date.now();
|
|
1071
|
+
const judgedById = new Map();
|
|
1072
|
+
if (runsVisual && capturedRecords.length > 0 && !pastDeadline()) {
|
|
1073
|
+
const judged = await judgePageReviewPack({
|
|
1074
|
+
packDir,
|
|
1075
|
+
provider,
|
|
1076
|
+
rubric: DEFAULT_CRITIQUE_RUBRIC,
|
|
1077
|
+
...(job.policy?.critique_pool !== undefined ? { concurrency: job.policy.critique_pool } : {}),
|
|
1078
|
+
contextIds: capturedRecords.map((record) => record.id),
|
|
1079
|
+
...(manifest.baseline_source !== "none"
|
|
1080
|
+
? {
|
|
1081
|
+
reuse: {
|
|
1082
|
+
target: job.target,
|
|
1083
|
+
...(options.snapshotStore ? { store: options.snapshotStore } : {}),
|
|
1084
|
+
},
|
|
1085
|
+
}
|
|
1086
|
+
: {}),
|
|
1087
|
+
deadlineAt: startedAt + runDeadlineMs,
|
|
1088
|
+
onLog: log,
|
|
1089
|
+
});
|
|
1090
|
+
const latency = critiqueLatency(judged.provider_meta);
|
|
1091
|
+
critiquePool = {
|
|
1092
|
+
concurrency: judged.pool.concurrency,
|
|
1093
|
+
tiles_total: judged.tiles_total,
|
|
1094
|
+
tiles_reviewed: judged.tiles_reviewed,
|
|
1095
|
+
tiles_reused: judged.tiles_reused,
|
|
1096
|
+
wall_time_ms: judged.pool.wall_time_ms,
|
|
1097
|
+
provider: judged.pool.provider,
|
|
1098
|
+
...(latency ? { latency_ms: latency } : {}),
|
|
1099
|
+
};
|
|
1100
|
+
for (const row of judged.contexts) {
|
|
1101
|
+
judgedById.set(row.context_id, row);
|
|
1102
|
+
let contextOutcome = row.outcome === "pass" ? "passed" : row.outcome === "fail" ? "failed" : "unknown";
|
|
1103
|
+
if (row.outcome === "skipped") {
|
|
1104
|
+
const detail = row.error ?? "critique reported no conclusive outcome";
|
|
1105
|
+
blockers.push({
|
|
1106
|
+
stage: "critique",
|
|
1107
|
+
context_id: row.context_id,
|
|
1108
|
+
reason: job.policy?.allow_metered_critique
|
|
1109
|
+
? `critique did not complete: ${detail}`
|
|
1110
|
+
: `critique did not complete under the headless-only policy ` +
|
|
1111
|
+
`(${QA_RUN_HEADLESS_ONLY_ENV}=1; permit metered fallback with ` +
|
|
1112
|
+
`policy.allow_metered_critique / --allow-metered): ${detail}`,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
else if (row.outcome === "incomplete") {
|
|
1116
|
+
blockers.push({
|
|
1117
|
+
stage: "critique",
|
|
1118
|
+
context_id: row.context_id,
|
|
1119
|
+
reason: `judge did not review ${row.tiles_unjudged} of ${row.tiles_total} tile(s) before the ` +
|
|
1120
|
+
"run deadline; nothing is proven for them",
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// A capped capture holds the top of the page and nothing below it.
|
|
1124
|
+
// Signoff cannot rest on that; review mode keeps the row honest via
|
|
1125
|
+
// `coverage` and leaves the verdict to the tiles that were seen.
|
|
1126
|
+
if (job.mode === "signoff" && row.coverage.capped && contextOutcome !== "failed") {
|
|
1127
|
+
contextOutcome = "unknown";
|
|
1128
|
+
blockers.push({
|
|
1129
|
+
stage: "critique",
|
|
1130
|
+
context_id: row.context_id,
|
|
1131
|
+
reason: `critique coverage capped: ${row.coverage.bands_reviewed} of ${row.coverage.bands_total} bands ` +
|
|
1132
|
+
`reviewed (${row.coverage.reviewed_height_px} of ${row.coverage.page_height_px} px); raise ` +
|
|
1133
|
+
"policy.critique_max_tiles to review the whole page in signoff mode",
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
const scopeById = new Map(row.record.tiles.map((tile) => [tile.id, tile.scope]));
|
|
1137
|
+
critique.push({
|
|
1138
|
+
context_id: row.context_id,
|
|
1139
|
+
provider: row.provider,
|
|
1140
|
+
tiles_total: row.tiles_total,
|
|
1141
|
+
tiles_reviewed: row.tiles_reviewed,
|
|
1142
|
+
tiles_reused: row.tiles_reused,
|
|
1143
|
+
outcome: contextOutcome,
|
|
1144
|
+
findings: row.findings.map((finding) => {
|
|
1145
|
+
const selector = scopeById.get(finding.tile_id);
|
|
1146
|
+
return {
|
|
1147
|
+
severity: finding.severity,
|
|
1148
|
+
summary: `${finding.category}: ${finding.description}`,
|
|
1149
|
+
tile: `${row.context_id}/${finding.tile_id}`,
|
|
1150
|
+
...(selector !== undefined ? { selector } : {}),
|
|
1151
|
+
};
|
|
1152
|
+
}),
|
|
1153
|
+
coverage: row.coverage,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
finalizePack("critique", toCritiqueRecords(judged), judged.pool);
|
|
1157
|
+
}
|
|
1158
|
+
if (runsVisual)
|
|
1159
|
+
restoreHeadlessOnly();
|
|
1160
|
+
wall.critique = Date.now() - critiqueStart;
|
|
1161
|
+
if (runsVisual)
|
|
1162
|
+
stagesRun.push("critique");
|
|
1163
|
+
// -------------------------------------------------------------- snapshot
|
|
1164
|
+
// Signoff persists each context's baseline from the pack's own files (no
|
|
1165
|
+
// browser). The critique rides along only when the whole tile set was
|
|
1166
|
+
// freshly judged, uncapped, and unscoped — a partial or scoped review must
|
|
1167
|
+
// never become the next baseline's finding record. When the manifest
|
|
1168
|
+
// required no visual pass, a dedicated browse --qa-snapshot pass still runs.
|
|
1169
|
+
enterStage("snapshot");
|
|
1170
|
+
const snapshotStart = Date.now();
|
|
1171
|
+
const savedSnapshots = new Map();
|
|
1172
|
+
if (job.mode === "signoff" && runsVisual) {
|
|
1173
|
+
for (const record of capturedRecords) {
|
|
1174
|
+
if (pastDeadline())
|
|
1175
|
+
break;
|
|
1176
|
+
const row = judgedById.get(record.id);
|
|
1177
|
+
const persistCritique = row !== undefined &&
|
|
1178
|
+
(row.outcome === "pass" || row.outcome === "fail") &&
|
|
1179
|
+
!record.coverage.capped &&
|
|
1180
|
+
row.tiles_reused === 0 &&
|
|
1181
|
+
row.tiles_unjudged === 0 &&
|
|
1182
|
+
record.scopes.length === 0;
|
|
1183
|
+
const persisted = persistCritique && row
|
|
1184
|
+
? {
|
|
1185
|
+
contract_version: QA_CRITIQUE_CONTRACT_VERSION,
|
|
1186
|
+
rubric_digest: rubricDigest(DEFAULT_CRITIQUE_RUBRIC),
|
|
1187
|
+
outcome: row.outcome,
|
|
1188
|
+
findings: row.findings.map(({ tile_id: _tileId, ...finding }) => finding),
|
|
1189
|
+
tiles: record.tiles.map((tile) => ({
|
|
1190
|
+
index: tile.index,
|
|
1191
|
+
label: tile.label,
|
|
1192
|
+
x: tile.x,
|
|
1193
|
+
scrollY: tile.scrollY,
|
|
1194
|
+
width: tile.width,
|
|
1195
|
+
height: tile.height,
|
|
1196
|
+
})),
|
|
1197
|
+
}
|
|
1198
|
+
: undefined;
|
|
1199
|
+
try {
|
|
1200
|
+
const saved = saveQaSnapshot(job.target, { viewport: record.viewport, theme: record.theme, state: record.state }, {
|
|
1201
|
+
signature: readPackSignature(packDir, record),
|
|
1202
|
+
domHtml: readPackDom(packDir, record),
|
|
1203
|
+
screenshotPng: readPackFullPage(packDir, record),
|
|
1204
|
+
...(persisted ? { critique: persisted } : {}),
|
|
1205
|
+
}, options.snapshotStore ?? {});
|
|
1206
|
+
savedSnapshots.set(record.id, saved.path);
|
|
1207
|
+
log(`snapshot ${record.id}: saved${persisted ? " with critique" : ""}`);
|
|
1208
|
+
}
|
|
1209
|
+
catch (err) {
|
|
1210
|
+
blockers.push({
|
|
1211
|
+
stage: "snapshot",
|
|
1212
|
+
context_id: record.id,
|
|
1213
|
+
reason: `snapshot could not be persisted from the pack: ${err instanceof Error ? err.message : String(err)}`,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
for (const row of critique) {
|
|
1218
|
+
if (row.outcome === "passed" && !savedSnapshots.has(row.context_id)) {
|
|
1219
|
+
blockers.push({
|
|
1220
|
+
stage: "snapshot",
|
|
1221
|
+
context_id: row.context_id,
|
|
1222
|
+
reason: "signoff critique passed but no QA snapshot was persisted for the context",
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
else if (job.mode === "signoff" && visual === "none" && blockers.length === 0) {
|
|
1228
|
+
const stillClean = commands.every((command) => command.outcome === "passed");
|
|
1229
|
+
if (stillClean) {
|
|
1230
|
+
for (const ctx of contexts) {
|
|
1231
|
+
if (pastDeadline())
|
|
1232
|
+
break;
|
|
1233
|
+
const outPrefix = join(outDir, `${ctx.id}-snapshot`);
|
|
1234
|
+
const argv = [
|
|
1235
|
+
...browseArgv,
|
|
1236
|
+
job.target,
|
|
1237
|
+
...contextRenderArgs(ctx),
|
|
1238
|
+
"--out",
|
|
1239
|
+
outPrefix,
|
|
1240
|
+
"--qa-snapshot",
|
|
1241
|
+
"--qa-theme",
|
|
1242
|
+
ctx.theme,
|
|
1243
|
+
"--qa-state",
|
|
1244
|
+
ctx.state,
|
|
1245
|
+
];
|
|
1246
|
+
log(`snapshot ${ctx.id}`);
|
|
1247
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
1248
|
+
const envelope = readEnvelope(`${outPrefix}.json`);
|
|
1249
|
+
const saved = envelope?.qaPlan
|
|
1250
|
+
?.snapshotSaved;
|
|
1251
|
+
const ok = !res.error && res.exitCode === 0 && Boolean(saved?.path);
|
|
1252
|
+
if (ok && saved?.path)
|
|
1253
|
+
savedSnapshots.set(ctx.id, saved.path);
|
|
1254
|
+
else {
|
|
1255
|
+
blockers.push({
|
|
1256
|
+
stage: "snapshot",
|
|
1257
|
+
context_id: ctx.id,
|
|
1258
|
+
reason: `snapshot command did not persist a baseline${res.error ? `: ${res.error}` : ` (exit code ${res.exitCode ?? "null"})`}`,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
commands.push({
|
|
1262
|
+
context_id: ctx.id,
|
|
1263
|
+
check_id: "snapshot",
|
|
1264
|
+
argv,
|
|
1265
|
+
exit_code: res.exitCode,
|
|
1266
|
+
outcome: ok ? "passed" : "unknown",
|
|
1267
|
+
failures: ok ? [] : [res.error ?? `exit code ${res.exitCode ?? "null"}`],
|
|
1268
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
1269
|
+
wall_time_ms: wallTimeMs,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
wall.snapshot = Date.now() - snapshotStart;
|
|
1275
|
+
if (job.mode === "signoff")
|
|
1276
|
+
stagesRun.push("snapshot");
|
|
1277
|
+
if (job.mode === "signoff") {
|
|
1278
|
+
const allSaved = contexts.length > 0 && contexts.every((ctx) => savedSnapshots.has(ctx.id));
|
|
1279
|
+
const lastPath = [...savedSnapshots.values()].pop();
|
|
1280
|
+
snapshot = { saved: allSaved, ...(lastPath ? { path: lastPath } : {}) };
|
|
1281
|
+
}
|
|
1282
|
+
return finalize();
|
|
1283
|
+
}
|