@yemi33/minions 0.1.2448 → 0.1.2449

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.
Files changed (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +58 -4
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +179 -20
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +13 -2
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -0,0 +1,654 @@
1
+ /**
2
+ * engine/qa/visual-journey.js — the DRAFT-phase visual-journey manifest
3
+ * (W-msb9kgs402813a97-b).
4
+ *
5
+ * Before this module, DRAFT's only structured output was
6
+ * `structuredCompletion.testFile`, so an API-only test file passed straight
7
+ * through `engine/qa/sessions.js#handleDraftComplete` into EXECUTE. A session
8
+ * that asked for screenshots or video could therefore reach a green qa-run
9
+ * having never opened a browser, and on a multi-project session it could
10
+ * silently ignore every co-service origin.
11
+ *
12
+ * The manifest is owned by the SESSION layer, NOT inferred from the drafted
13
+ * test file: a source-string scan of a runner-native test is a guess, and a
14
+ * guess cannot be a gate. The DRAFT agent DECLARES what it drafted in a
15
+ * single-use sidecar; the engine validates that declaration against the
16
+ * session's own spec and fails closed before EXECUTE is ever queued.
17
+ *
18
+ * Sidecar contract (modelled on engine/qa/runs.js#prepareResultSidecar /
19
+ * #consumeResultSidecar):
20
+ *
21
+ * agents/<agentId>/qa-session-draft-result.json
22
+ *
23
+ * `prepareDraftResultSidecar` clears stale content at spawn time;
24
+ * `consumeDraftResultSidecar` does an atomic read-then-unlink and returns
25
+ * `{ ok: false, reason }` for missing / unreadable / malformed /
26
+ * session-id-mismatched files. Exactly-once, like every other Minions sidecar.
27
+ *
28
+ * Manifest shape:
29
+ *
30
+ * {
31
+ * "sessionId": "qas-...",
32
+ * "journeys": [{
33
+ * "id": "login",
34
+ * "name": "Operator signs in and lands on the dashboard",
35
+ * "kind": "browser" | "api",
36
+ * "projects": ["web"],
37
+ * "services": ["qa-session-qas-...", "qa-session-qas-...-api"],
38
+ * "origins": ["http://localhost:7346"],
39
+ * "steps": ["open /login", "submit credentials"],
40
+ * "assertions": ["dashboard header is visible"],
41
+ * "evidence": { "screenshots": ["login.png"], "video": ["login.webm"] }
42
+ * }]
43
+ * }
44
+ *
45
+ * Every list is capped in length AND per-entry byte size, and the encoded
46
+ * manifest itself is byte-capped, so a runaway agent cannot inflate the
47
+ * session record.
48
+ *
49
+ * ── EXECUTE side (W-msb9kgs402813a97-c) ────────────────────────────────────
50
+ *
51
+ * The manifest is a PLAN; `validateEvidenceCoverage` at the bottom of this
52
+ * file is the PROOF. Before `engine/qa/sessions.js#handleExecuteComplete` is
53
+ * allowed to choose DONE, it checks the accepted manifest against what the
54
+ * qa-run actually produced: registered artifacts per requested capture type,
55
+ * those artifacts existing on disk (non-zero, inside the run's own artifacts
56
+ * dir), and per-journey `journeyCoverage` covering every project and
57
+ * managed-spawn the session owns. A green run that cannot show its evidence
58
+ * fails with `qa-session-evidence-incomplete` instead of closing the session.
59
+ */
60
+
61
+ const fs = require('fs');
62
+ const path = require('path');
63
+ const shared = require('../core/shared');
64
+
65
+ const DRAFT_RESULT_SIDECAR_FILENAME = 'qa-session-draft-result.json';
66
+
67
+ const JOURNEY_KINDS = Object.freeze({ BROWSER: 'browser', API: 'api' });
68
+ const VALID_JOURNEY_KINDS = Object.freeze(new Set(Object.values(JOURNEY_KINDS)));
69
+
70
+ // Which capture toggle demands which planned artifact extension, and which
71
+ // registered artifact `type` values count as that kind of evidence at EXECUTE
72
+ // time. Kept as data so a new capture type is one row, not a new branch in
73
+ // either gate.
74
+ const EVIDENCE_CONTRACT = Object.freeze({
75
+ screenshots: {
76
+ field: 'screenshots',
77
+ ext: '.png',
78
+ artifactTypes: Object.freeze(['screenshot', 'screenshots', 'image']),
79
+ },
80
+ video: {
81
+ field: 'video',
82
+ ext: '.webm',
83
+ artifactTypes: Object.freeze(['video', 'recording']),
84
+ },
85
+ });
86
+
87
+ // Failure class stamped on the session when the gate rejects. Operators grep
88
+ // this out of engine.log, so it is a constant rather than an inline literal.
89
+ const VISUAL_COVERAGE_FAILURE_CLASS = 'qa-session-draft-visual-coverage';
90
+
91
+ // W-msb9kgs402813a97-c — the EXECUTE-side sibling: a green qa-run whose
92
+ // evidence does not actually exist (or only covers one project) fails the
93
+ // session with this class instead of transitioning it to done.
94
+ const EVIDENCE_COVERAGE_FAILURE_CLASS = 'qa-session-evidence-incomplete';
95
+
96
+ const LIMITS = Object.freeze({
97
+ journeysMax: 20,
98
+ idMaxBytes: 64,
99
+ nameMaxBytes: 200,
100
+ // projects / services / origins entries per journey
101
+ listMax: 20,
102
+ stepsMax: 50,
103
+ assertionsMax: 50,
104
+ // planned evidence entries per capture type per journey
105
+ evidenceMax: 20,
106
+ // per-entry cap for every string inside a list
107
+ entryMaxBytes: 500,
108
+ // whole-sidecar cap, checked before anything is walked
109
+ manifestMaxBytes: 64 * 1024,
110
+ agentIdMax: 128,
111
+ });
112
+
113
+ const _AGENT_ID_RE = /^[a-zA-Z0-9._-]+$/;
114
+
115
+ function _isNonEmptyString(v) {
116
+ return typeof v === 'string' && v.length > 0;
117
+ }
118
+
119
+ function _bytes(str) {
120
+ return Buffer.byteLength(String(str), 'utf8');
121
+ }
122
+
123
+ // ── Sidecar ────────────────────────────────────────────────────────────────
124
+
125
+ function draftResultSidecarPath(agentId) {
126
+ const segment = typeof agentId === 'string' ? agentId : '';
127
+ if (!segment || segment.length > LIMITS.agentIdMax || !_AGENT_ID_RE.test(segment)
128
+ || segment === '.' || segment === '..') {
129
+ throw new Error(`qa-visual-journey: invalid agent id for draft result sidecar: ${agentId}`);
130
+ }
131
+ return path.join(shared.MINIONS_DIR, 'agents', segment, DRAFT_RESULT_SIDECAR_FILENAME);
132
+ }
133
+
134
+ /**
135
+ * Clear any stale draft-result sidecar before a DRAFT dispatch starts.
136
+ *
137
+ * Load-bearing on the re-draft path: `editDraft` re-queues DRAFT for the SAME
138
+ * session id, so a leftover manifest from a previous attempt would pass the
139
+ * session-id check and be consumed as if it described the new draft.
140
+ */
141
+ function prepareDraftResultSidecar(agentId, sessionId) {
142
+ if (!_isNonEmptyString(sessionId)) {
143
+ throw new Error(`qa-visual-journey: invalid session id for draft result sidecar: ${sessionId}`);
144
+ }
145
+ const filePath = draftResultSidecarPath(agentId);
146
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
147
+ try {
148
+ fs.unlinkSync(filePath);
149
+ } catch (err) {
150
+ if (err.code !== 'ENOENT') {
151
+ throw new Error(`qa-visual-journey: could not clear draft result sidecar for ${sessionId}: ${err.message}`);
152
+ }
153
+ }
154
+ return filePath;
155
+ }
156
+
157
+ /**
158
+ * Read-then-unlink the draft-result sidecar exactly once.
159
+ *
160
+ * @returns {{ok:true, manifest:object, filePath:string, cleanupError?:string}
161
+ * |{ok:false, reason:string, filePath:string, detail?:string,
162
+ * actualSessionId?:string, expectedSessionId?:string,
163
+ * cleanupError?:string}}
164
+ */
165
+ function consumeDraftResultSidecar(agentId, expectedSessionId) {
166
+ if (!_isNonEmptyString(expectedSessionId)) {
167
+ throw new Error(`qa-visual-journey: invalid expected session id for draft result sidecar: ${expectedSessionId}`);
168
+ }
169
+ const filePath = draftResultSidecarPath(agentId);
170
+ let raw;
171
+ try {
172
+ raw = fs.readFileSync(filePath, 'utf8');
173
+ } catch (err) {
174
+ return {
175
+ ok: false,
176
+ reason: err.code === 'ENOENT' ? 'missing' : 'read-error',
177
+ detail: err.code === 'ENOENT' ? '' : err.message,
178
+ filePath,
179
+ };
180
+ }
181
+
182
+ let outcome;
183
+ try {
184
+ const parsed = JSON.parse(raw);
185
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
186
+ outcome = { ok: false, reason: 'malformed', detail: 'expected a JSON object', filePath };
187
+ } else if (parsed.sessionId !== expectedSessionId) {
188
+ outcome = {
189
+ ok: false,
190
+ reason: 'session-id-mismatch',
191
+ actualSessionId: typeof parsed.sessionId === 'string' ? parsed.sessionId : '',
192
+ expectedSessionId,
193
+ filePath,
194
+ };
195
+ } else {
196
+ outcome = { ok: true, manifest: parsed, filePath };
197
+ }
198
+ } catch (err) {
199
+ outcome = { ok: false, reason: 'malformed', detail: err.message, filePath };
200
+ }
201
+
202
+ try {
203
+ fs.unlinkSync(filePath);
204
+ } catch (err) {
205
+ if (err.code !== 'ENOENT') outcome.cleanupError = err.message;
206
+ }
207
+ return outcome;
208
+ }
209
+
210
+ // ── Manifest validation ────────────────────────────────────────────────────
211
+
212
+ function _requestedCaptureTypes(session) {
213
+ const capture = (session && session.spec && session.spec.capture) || {};
214
+ return Object.keys(EVIDENCE_CONTRACT).filter(key => !!capture[key]);
215
+ }
216
+
217
+ /**
218
+ * True when the session asked for screenshots or video — the ONLY condition
219
+ * under which a visual journey is demanded. A logs-only session (or a session
220
+ * with no capture at all) keeps its historical pass-through behaviour.
221
+ */
222
+ function isVisualCaptureRequested(session) {
223
+ return _requestedCaptureTypes(session).length > 0;
224
+ }
225
+
226
+ function _sessionProjects(session) {
227
+ const projects = session && session.spec && session.spec.projects;
228
+ return Array.isArray(projects) ? projects.filter(_isNonEmptyString) : [];
229
+ }
230
+
231
+ /**
232
+ * The managed-spawn names the manifest must cover.
233
+ *
234
+ * Callers may inject an inventory (`opts.services`, either bare names or the
235
+ * `{name, project, ...}` rows `engine.js#buildQaSessionServices` produces);
236
+ * otherwise it is derived from the session itself. A lookup that throws is
237
+ * FAIL-CLOSED — an unknown inventory rejects rather than silently waiving the
238
+ * coverage requirement.
239
+ */
240
+ function _requiredServiceNames(session, opts) {
241
+ if (Array.isArray(opts && opts.services)) {
242
+ const names = [];
243
+ for (const entry of opts.services) {
244
+ if (_isNonEmptyString(entry)) names.push(entry);
245
+ else if (entry && typeof entry === 'object' && _isNonEmptyString(entry.name)) names.push(entry.name);
246
+ }
247
+ return { ok: true, names };
248
+ }
249
+ try {
250
+ const names = require('./sessions').managedSpawnNamesForSession(session);
251
+ return { ok: true, names: Array.isArray(names) ? names.filter(_isNonEmptyString) : [] };
252
+ } catch (err) {
253
+ return { ok: false, names: [], error: err.message };
254
+ }
255
+ }
256
+
257
+ function _normalizeStringList(value, label, max, errors) {
258
+ if (value === undefined || value === null) return [];
259
+ if (!Array.isArray(value)) {
260
+ errors.push(`${label} must be an array of strings`);
261
+ return [];
262
+ }
263
+ if (value.length > max) {
264
+ errors.push(`${label} has ${value.length} entries (cap ${max})`);
265
+ return [];
266
+ }
267
+ const out = [];
268
+ for (const entry of value) {
269
+ if (!_isNonEmptyString(entry)) {
270
+ errors.push(`${label} entries must be non-empty strings`);
271
+ return [];
272
+ }
273
+ if (_bytes(entry) > LIMITS.entryMaxBytes) {
274
+ errors.push(`${label} entry exceeds ${LIMITS.entryMaxBytes} bytes`);
275
+ return [];
276
+ }
277
+ out.push(entry);
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function _normalizeJourney(raw, index, errors) {
283
+ const label = `journeys[${index}]`;
284
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
285
+ errors.push(`${label} must be a plain object`);
286
+ return null;
287
+ }
288
+ if (!_isNonEmptyString(raw.id) || _bytes(raw.id) > LIMITS.idMaxBytes) {
289
+ errors.push(`${label}.id must be a non-empty string of at most ${LIMITS.idMaxBytes} bytes`);
290
+ return null;
291
+ }
292
+ if (!_isNonEmptyString(raw.name) || _bytes(raw.name) > LIMITS.nameMaxBytes) {
293
+ errors.push(`${label}.name must be a non-empty string of at most ${LIMITS.nameMaxBytes} bytes`);
294
+ return null;
295
+ }
296
+ if (!VALID_JOURNEY_KINDS.has(raw.kind)) {
297
+ errors.push(`${label}.kind must be one of: ${[...VALID_JOURNEY_KINDS].join(', ')}`);
298
+ return null;
299
+ }
300
+
301
+ const before = errors.length;
302
+ const journey = {
303
+ id: raw.id,
304
+ name: raw.name,
305
+ kind: raw.kind,
306
+ projects: _normalizeStringList(raw.projects, `${label}.projects`, LIMITS.listMax, errors),
307
+ services: _normalizeStringList(raw.services, `${label}.services`, LIMITS.listMax, errors),
308
+ origins: _normalizeStringList(raw.origins, `${label}.origins`, LIMITS.listMax, errors),
309
+ steps: _normalizeStringList(raw.steps, `${label}.steps`, LIMITS.stepsMax, errors),
310
+ assertions: _normalizeStringList(raw.assertions, `${label}.assertions`, LIMITS.assertionsMax, errors),
311
+ evidence: { screenshots: [], video: [] },
312
+ };
313
+
314
+ const evidence = raw.evidence;
315
+ if (evidence !== undefined && evidence !== null) {
316
+ if (typeof evidence !== 'object' || Array.isArray(evidence)) {
317
+ errors.push(`${label}.evidence must be a plain object`);
318
+ } else {
319
+ for (const key of Object.keys(EVIDENCE_CONTRACT)) {
320
+ journey.evidence[key] = _normalizeStringList(
321
+ evidence[key], `${label}.evidence.${key}`, LIMITS.evidenceMax, errors,
322
+ );
323
+ }
324
+ }
325
+ }
326
+ return errors.length === before ? journey : null;
327
+ }
328
+
329
+ /**
330
+ * Validate (and normalize) a DRAFT visual-journey manifest against its session.
331
+ *
332
+ * Shape errors short-circuit — coverage cannot be judged on a manifest whose
333
+ * journeys did not parse. Coverage errors are collected together so one
334
+ * re-draft can fix everything the gate objects to.
335
+ *
336
+ * @param {object} session - the qa-sessions record
337
+ * @param {object|null} manifest - parsed sidecar contents (null when absent)
338
+ * @param {object} [opts]
339
+ * @param {Array} [opts.services] - injected managed-spawn inventory
340
+ * @returns {{ok:true, required:boolean, manifest:object|null}
341
+ * |{ok:false, required:boolean, errors:string[], error:string}}
342
+ */
343
+ function validateVisualJourneyManifest(session, manifest, opts = {}) {
344
+ const sessionId = (session && session.id) || '(unknown session)';
345
+ const requestedCapture = _requestedCaptureTypes(session);
346
+ const required = requestedCapture.length > 0;
347
+ const reject = (errors) => ({ ok: false, required, errors, error: errors.join(' ') });
348
+
349
+ if (manifest === undefined || manifest === null) {
350
+ if (!required) return { ok: true, required, manifest: null };
351
+ return reject([
352
+ `QA Session ${sessionId} requested visual capture (${requestedCapture.join(', ')}) but DRAFT ` +
353
+ `declared no visual-journey manifest — write the ${DRAFT_RESULT_SIDECAR_FILENAME} sidecar with a ` +
354
+ '`journeys` array describing what the drafted test actually exercises.',
355
+ ]);
356
+ }
357
+
358
+ // ── Shape ────────────────────────────────────────────────────────────────
359
+ const shapeErrors = [];
360
+ if (typeof manifest !== 'object' || Array.isArray(manifest)) {
361
+ return reject([`visual-journey manifest for ${sessionId} must be a plain object.`]);
362
+ }
363
+ let encodedBytes = 0;
364
+ try {
365
+ encodedBytes = _bytes(JSON.stringify(manifest));
366
+ } catch (err) {
367
+ return reject([`visual-journey manifest for ${sessionId} is not JSON-encodable: ${err.message}`]);
368
+ }
369
+ if (encodedBytes > LIMITS.manifestMaxBytes) {
370
+ return reject([
371
+ `visual-journey manifest for ${sessionId} is ${encodedBytes} bytes ` +
372
+ `(cap ${LIMITS.manifestMaxBytes}) — trim steps/assertions and keep the journey list to what the test covers.`,
373
+ ]);
374
+ }
375
+ if (manifest.sessionId !== sessionId) {
376
+ return reject([
377
+ `visual-journey manifest declares sessionId "${manifest.sessionId || ''}" but this DRAFT belongs to ` +
378
+ `${sessionId} — the sidecar must name its own session.`,
379
+ ]);
380
+ }
381
+ if (!Array.isArray(manifest.journeys)) {
382
+ return reject([`visual-journey manifest for ${sessionId} must carry a \`journeys\` array.`]);
383
+ }
384
+ if (manifest.journeys.length > LIMITS.journeysMax) {
385
+ return reject([
386
+ `visual-journey manifest for ${sessionId} declares ${manifest.journeys.length} journeys ` +
387
+ `(cap ${LIMITS.journeysMax}).`,
388
+ ]);
389
+ }
390
+
391
+ const journeys = [];
392
+ const seenIds = new Set();
393
+ for (let i = 0; i < manifest.journeys.length; i++) {
394
+ const journey = _normalizeJourney(manifest.journeys[i], i, shapeErrors);
395
+ if (!journey) continue;
396
+ if (seenIds.has(journey.id)) {
397
+ shapeErrors.push(`journeys[${i}].id "${journey.id}" is a duplicate — journey ids must be unique.`);
398
+ continue;
399
+ }
400
+ seenIds.add(journey.id);
401
+ journeys.push(journey);
402
+ }
403
+ if (shapeErrors.length) {
404
+ return reject([`visual-journey manifest for ${sessionId} is malformed: ${shapeErrors.join('; ')}.`]);
405
+ }
406
+
407
+ const normalized = { sessionId, journeys };
408
+ if (!required) return { ok: true, required, manifest: normalized };
409
+
410
+ // ── Coverage gate (visual capture requested) ─────────────────────────────
411
+ const errors = [];
412
+
413
+ const browserJourneys = journeys.filter(j => j.kind === JOURNEY_KINDS.BROWSER);
414
+ if (browserJourneys.length === 0) {
415
+ const kinds = journeys.length ? [...new Set(journeys.map(j => j.kind))].join(', ') : 'none';
416
+ errors.push(
417
+ `QA Session ${sessionId} requested visual capture (${requestedCapture.join(', ')}) but the manifest ` +
418
+ `declares no kind:"browser" journey (declared kinds: ${kinds}) — an API-only draft cannot produce ` +
419
+ 'screenshots or video.',
420
+ );
421
+ }
422
+
423
+ const declaredProjects = new Set(journeys.flatMap(j => j.projects));
424
+ const missingProjects = _sessionProjects(session).filter(p => !declaredProjects.has(p));
425
+ if (missingProjects.length) {
426
+ errors.push(
427
+ `visual-journey manifest does not cover project(s): ${missingProjects.join(', ')} — every project in ` +
428
+ `the session (${_sessionProjects(session).join(', ')}) must appear in some journey's \`projects\`.`,
429
+ );
430
+ }
431
+
432
+ const serviceLookup = _requiredServiceNames(session, opts);
433
+ if (!serviceLookup.ok) {
434
+ errors.push(
435
+ `visual-journey manifest could not be checked against session ${sessionId}'s managed-spawn inventory ` +
436
+ `(${serviceLookup.error}) — refusing to advance to EXECUTE without service coverage.`,
437
+ );
438
+ } else {
439
+ const declaredServices = new Set(journeys.flatMap(j => j.services));
440
+ const missingServices = serviceLookup.names.filter(n => !declaredServices.has(n));
441
+ if (missingServices.length) {
442
+ errors.push(
443
+ `visual-journey manifest does not cover service(s): ${missingServices.join(', ')} — every ` +
444
+ `managed-spawn the session owns (${serviceLookup.names.join(', ')}) must appear in some journey's ` +
445
+ '`services`, addressed at its OWN origin.',
446
+ );
447
+ }
448
+ }
449
+
450
+ for (const type of requestedCapture) {
451
+ const { field, ext } = EVIDENCE_CONTRACT[type];
452
+ const planned = journeys.some(j => j.evidence[field].some(
453
+ entry => entry.toLowerCase().endsWith(ext),
454
+ ));
455
+ if (!planned) {
456
+ errors.push(
457
+ `visual-journey manifest plans no ${type} evidence — capture.${type} was requested, so at least one ` +
458
+ `journey must plan a \`${ext}\` file under \`evidence.${field}\`.`,
459
+ );
460
+ }
461
+ }
462
+
463
+ if (errors.length) return reject(errors);
464
+ return { ok: true, required, manifest: normalized };
465
+ }
466
+
467
+ // ── EXECUTE-side evidence coverage (W-msb9kgs402813a97-c) ──────────────────
468
+
469
+ function _emptyMissing() {
470
+ return { files: [], journeys: [], projects: [], services: [], evidenceTypes: [] };
471
+ }
472
+
473
+ /**
474
+ * Does a registered run artifact count as evidence for `type`?
475
+ *
476
+ * Accepts either the declared artifact `type` (the sidecar contract) or the
477
+ * file extension, so a run that labelled its capture `image` instead of
478
+ * `screenshot` is not rejected on a vocabulary technicality. The file-existence
479
+ * check below is what actually makes this load-bearing.
480
+ */
481
+ function _artifactIsEvidenceFor(artifact, contract) {
482
+ const declared = typeof artifact.type === 'string' ? artifact.type.trim().toLowerCase() : '';
483
+ if (declared && contract.artifactTypes.includes(declared)) return true;
484
+ const p = typeof artifact.path === 'string' ? artifact.path.toLowerCase() : '';
485
+ return !!p && p.endsWith(contract.ext);
486
+ }
487
+
488
+ /**
489
+ * Prove one registered artifact is a real file inside the run's artifacts dir.
490
+ *
491
+ * Registration is a claim; this is the check. Paths are resolved relative to
492
+ * the linked run's directory (the sidecar contract), must be strictly INSIDE
493
+ * it (`shared.isPathInside`), and must be a non-empty regular file.
494
+ */
495
+ function _checkArtifactFile(artifact, artifactsDir) {
496
+ const rel = typeof artifact.path === 'string' ? artifact.path : '';
497
+ if (!rel) return { ok: false, reason: 'has no path' };
498
+ if (!_isNonEmptyString(artifactsDir)) {
499
+ return { ok: false, reason: 'has no resolvable run artifacts directory' };
500
+ }
501
+ const resolved = path.resolve(artifactsDir, rel);
502
+ if (!shared.isPathInside(resolved, artifactsDir)) {
503
+ return { ok: false, reason: 'resolves outside the run artifacts directory' };
504
+ }
505
+ let stat;
506
+ try {
507
+ stat = fs.statSync(resolved);
508
+ } catch (err) {
509
+ return { ok: false, reason: err.code === 'ENOENT' ? 'does not exist on disk' : `is unreadable (${err.code || err.message})` };
510
+ }
511
+ if (!stat.isFile()) return { ok: false, reason: 'is not a regular file' };
512
+ if (!(stat.size > 0)) return { ok: false, reason: 'is a zero-byte file' };
513
+ return { ok: true, resolved };
514
+ }
515
+
516
+ /**
517
+ * EXECUTE-side evidence gate: does a green qa-run actually carry the visual
518
+ * evidence its session asked for, across every project and service?
519
+ *
520
+ * Runs only when the session requested screenshots or video AND a DRAFT
521
+ * visual-journey manifest was persisted on the record. A logs-only session, or
522
+ * a legacy session created before the manifest existed, is skipped entirely
523
+ * (`{ ok: true, required: false }`) so historical completions are untouched.
524
+ *
525
+ * When required, all three checks must hold:
526
+ * (a) every requested capture type has at least one registered artifact;
527
+ * (b) every such artifact resolves INSIDE the run's artifacts dir and exists
528
+ * on disk with non-zero size;
529
+ * (c) the union of `journeyCoverage[].projects` for PASSED journeys covers
530
+ * every `session.spec.projects`, the union of `.services` covers every
531
+ * `managedSpawnNamesForSession(session)` name, and every journey the
532
+ * manifest declared actually reported `passed`.
533
+ *
534
+ * @param {object} args
535
+ * @param {object} args.session - the qa-sessions record
536
+ * @param {object|null} args.manifest - the persisted visual-journey manifest
537
+ * @param {object|null} args.run - the linked qa-runs record
538
+ * @param {string|null} args.artifactsDir - qaRuns.qaArtifactsDirForRun(runId)
539
+ * @param {Array} [args.services] - injected managed-spawn inventory
540
+ * @returns {{ok:boolean, required:boolean,
541
+ * missing:{files:string[],journeys:string[],projects:string[],
542
+ * services:string[],evidenceTypes:string[]},
543
+ * errors:string[], error:string}}
544
+ */
545
+ function validateEvidenceCoverage({ session, manifest, run, artifactsDir, services } = {}) {
546
+ const sessionId = (session && session.id) || '(unknown session)';
547
+ const requestedCapture = _requestedCaptureTypes(session);
548
+ const hasManifest = !!manifest && typeof manifest === 'object' && !Array.isArray(manifest);
549
+ if (requestedCapture.length === 0 || !hasManifest) {
550
+ return { ok: true, required: false, missing: _emptyMissing(), errors: [], error: '' };
551
+ }
552
+
553
+ const missing = _emptyMissing();
554
+ const errors = [];
555
+ const journeys = Array.isArray(manifest.journeys)
556
+ ? manifest.journeys.filter(j => j && typeof j === 'object' && _isNonEmptyString(j.id))
557
+ : [];
558
+ const artifacts = Array.isArray(run && run.artifacts)
559
+ ? run.artifacts.filter(a => a && typeof a === 'object')
560
+ : [];
561
+ const coverage = Array.isArray(run && run.journeyCoverage)
562
+ ? run.journeyCoverage.filter(c => c && typeof c === 'object' && _isNonEmptyString(c.journeyId))
563
+ : [];
564
+
565
+ // ── (a) + (b) registered, present, in-directory evidence ─────────────────
566
+ for (const type of requestedCapture) {
567
+ const contract = EVIDENCE_CONTRACT[type];
568
+ const candidates = artifacts.filter(a => _artifactIsEvidenceFor(a, contract));
569
+ if (candidates.length === 0) {
570
+ missing.evidenceTypes.push(type);
571
+ errors.push(
572
+ `QA Session ${sessionId} requested capture.${type} but the qa-run registered no ${type} artifact — ` +
573
+ `a green run with no ${type} evidence cannot close a visual session.`,
574
+ );
575
+ continue;
576
+ }
577
+ let usable = 0;
578
+ for (const artifact of candidates) {
579
+ const check = _checkArtifactFile(artifact, artifactsDir);
580
+ if (check.ok) { usable += 1; continue; }
581
+ const label = _isNonEmptyString(artifact.path) ? artifact.path : '(no path)';
582
+ if (!missing.files.includes(label)) missing.files.push(label);
583
+ errors.push(`registered ${type} artifact "${label}" ${check.reason}.`);
584
+ }
585
+ if (usable === 0 && !missing.evidenceTypes.includes(type)) {
586
+ missing.evidenceTypes.push(type);
587
+ errors.push(`no usable ${type} evidence survived verification for QA Session ${sessionId}.`);
588
+ }
589
+ }
590
+
591
+ // ── (c) journey / project / service coverage ─────────────────────────────
592
+ const passed = coverage.filter(c => c.status === 'passed');
593
+ const passedIds = new Set(passed.map(c => c.journeyId));
594
+ const unproven = journeys.map(j => j.id).filter(id => !passedIds.has(id));
595
+ if (unproven.length) {
596
+ missing.journeys = unproven;
597
+ errors.push(
598
+ `journey(s) ${unproven.join(', ')} declared by the DRAFT manifest never reported ` +
599
+ `\`status: "passed"\` in the qa-run's journeyCoverage — the run cannot be green on evidence it did not produce.`,
600
+ );
601
+ }
602
+
603
+ const coveredProjects = new Set(passed.flatMap(c => (Array.isArray(c.projects) ? c.projects : [])));
604
+ const sessionProjects = _sessionProjects(session);
605
+ const missingProjects = sessionProjects.filter(p => !coveredProjects.has(p));
606
+ if (missingProjects.length) {
607
+ missing.projects = missingProjects;
608
+ errors.push(
609
+ `no passed journey produced evidence for project(s): ${missingProjects.join(', ')} — every project in ` +
610
+ `the session (${sessionProjects.join(', ')}) must appear in some passed journey's \`projects\`.`,
611
+ );
612
+ }
613
+
614
+ const serviceLookup = _requiredServiceNames(session, { services });
615
+ if (!serviceLookup.ok) {
616
+ errors.push(
617
+ `evidence coverage could not be checked against session ${sessionId}'s managed-spawn inventory ` +
618
+ `(${serviceLookup.error}) — refusing to close the session as done.`,
619
+ );
620
+ } else {
621
+ const coveredServices = new Set(passed.flatMap(c => (Array.isArray(c.services) ? c.services : [])));
622
+ const missingServices = serviceLookup.names.filter(n => !coveredServices.has(n));
623
+ if (missingServices.length) {
624
+ missing.services = missingServices;
625
+ errors.push(
626
+ `no passed journey exercised service(s): ${missingServices.join(', ')} — every managed-spawn the ` +
627
+ `session owns (${serviceLookup.names.join(', ')}) must appear in some passed journey's \`services\`.`,
628
+ );
629
+ }
630
+ }
631
+
632
+ return {
633
+ ok: errors.length === 0,
634
+ required: true,
635
+ missing,
636
+ errors,
637
+ error: errors.join(' '),
638
+ };
639
+ }
640
+
641
+ module.exports = {
642
+ DRAFT_RESULT_SIDECAR_FILENAME,
643
+ VISUAL_COVERAGE_FAILURE_CLASS,
644
+ EVIDENCE_COVERAGE_FAILURE_CLASS,
645
+ JOURNEY_KINDS,
646
+ EVIDENCE_CONTRACT,
647
+ LIMITS,
648
+ draftResultSidecarPath,
649
+ prepareDraftResultSidecar,
650
+ consumeDraftResultSidecar,
651
+ isVisualCaptureRequested,
652
+ validateVisualJourneyManifest,
653
+ validateEvidenceCoverage,
654
+ };