@yemi33/minions 0.1.2447 → 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.
- package/bin/cli-api-client.js +1 -1
- package/bin/install-internal-minions.js +1382 -44
- package/bin/install-layout.js +150 -0
- package/bin/minions.js +460 -167
- package/dashboard/docs/typography.md +65 -12
- package/dashboard/js/command-center.js +66 -7
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +186 -45
- package/dashboard/js/refresh.js +148 -12
- package/dashboard/js/render-dispatch.js +3 -4
- package/dashboard/js/render-inbox.js +2 -2
- package/dashboard/js/render-other.js +3 -3
- package/dashboard/js/render-pipelines.js +14 -0
- package/dashboard/js/render-plans.js +57 -9
- package/dashboard/js/render-prd.js +132 -23
- package/dashboard/js/render-prs.js +195 -166
- package/dashboard/js/render-schedules.js +63 -3
- package/dashboard/js/render-utils.js +3 -3
- package/dashboard/js/render-watches.js +19 -3
- package/dashboard/js/render-work-items.js +238 -30
- package/dashboard/js/settings.js +205 -54
- package/dashboard/js/utils.js +51 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/qa.html +1 -16
- package/dashboard/pages/work.html +40 -0
- package/dashboard/shared/cc-limits.js +79 -0
- package/dashboard/shared/pr-filters.js +21 -38
- package/dashboard/shared/project-git-summary.js +1 -1
- package/dashboard/shared/record-filters.js +169 -0
- package/dashboard/shared/watches-source.js +1 -1
- package/dashboard/shared/welcome-popup.js +1 -1
- package/dashboard/shared/wi-filters.js +302 -0
- package/dashboard/slim/body.html +1 -0
- package/dashboard/slim/js/command-send.js +26 -0
- package/dashboard/slim/js/modals-tiles.js +380 -39
- package/dashboard/slim/js/status.js +13 -21
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/panel-bootstrap.js +6 -2
- package/dashboard/slim/styles.css +38 -0
- package/dashboard/styles.css +159 -55
- package/dashboard-build.js +52 -6
- package/dashboard.js +956 -423
- package/docs/README.md +11 -6
- package/docs/api-errors.md +2 -2
- package/docs/architecture-review-2026-07-09.md +1 -1
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +18 -9
- package/docs/branch-derivation.md +4 -4
- package/docs/capture-demos.js +39 -2
- package/docs/ci-runner-canary.md +123 -0
- package/docs/claude-md-propagation.md +2 -2
- package/docs/cloud-agent-dispatch.md +204 -0
- package/docs/command-center.md +7 -7
- package/docs/completion-reports.md +43 -20
- package/docs/constants.md +10 -3
- package/docs/constellation-bridge.md +134 -6
- package/docs/constellation-style-telemetry.md +4 -4
- package/docs/contracts/capability-protocol.v1.json +165 -0
- package/docs/cooldown-merge-semantics.md +12 -12
- package/docs/copilot-cli-schema.md +7 -7
- package/docs/cross-repo-plans.md +10 -10
- package/docs/dead-code-audit-retractions.md +5 -5
- package/docs/default-branch-ci.md +173 -0
- package/docs/deprecated.json +31 -31
- package/docs/design-inbox-entries-schema.md +3 -3
- package/docs/design-language.md +1051 -0
- package/docs/design-state-storage.md +11 -11
- package/docs/diagnostics-crash-reports.md +9 -9
- package/docs/diagnostics-memory.md +5 -5
- package/docs/documentation-audit-2026-07-09.md +7 -7
- package/docs/engine-restart.md +90 -5
- package/docs/harness-mode.md +1 -1
- package/docs/internal-install.md +338 -39
- package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
- package/docs/kb-pr3223-cascade-archiving.md +1 -1
- package/docs/kb-pr696-merge-conflict-docs.md +6 -6
- package/docs/kb-sweep.md +35 -35
- package/docs/keep-processes.md +1 -1
- package/docs/live-checkout-mode.md +30 -30
- package/docs/managed-spawn.md +18 -14
- package/docs/named-agents.md +7 -7
- package/docs/plan-lifecycle.md +69 -2
- package/docs/pr-author-identity.md +114 -0
- package/docs/pr-auto-fix-dispatch.md +19 -4
- package/docs/pr-comment-followup.md +6 -6
- package/docs/pr-review-fix-loop.md +59 -10
- package/docs/process-termination.md +40 -0
- package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
- package/docs/qa-runbook-lifecycle.md +367 -17
- package/docs/qa-runbooks.md +3 -3
- package/docs/rfc-completion-json.md +18 -18
- package/docs/runtime-adapters.md +26 -21
- package/docs/security.md +6 -6
- package/docs/self-improvement.md +4 -4
- package/docs/shared-lifecycle-module-map.md +473 -472
- package/docs/skills.md +52 -3
- package/docs/slim-ux/concepts.md +121 -116
- package/docs/specs/agent-configurability.md +18 -18
- package/docs/specs/agent-rename.md +18 -18
- package/docs/team-memory.md +38 -21
- package/docs/timeouts-and-liveness.md +118 -10
- package/docs/tutorials/01-install-and-connect.md +1 -1
- package/docs/watches.md +40 -39
- package/docs/workspace-manifests.md +4 -4
- package/docs/worktree-lifecycle.md +293 -14
- package/engine/README.md +46 -0
- package/engine/{ado-comment.js → ado/comment.js} +8 -8
- package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
- package/engine/{ado.js → ado/index.js} +417 -63
- package/engine/{ado-status.js → ado/status.js} +6 -8
- package/engine/{ado-token.js → ado/token.js} +1 -1
- package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
- package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
- package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
- package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
- package/engine/{harness-context.js → agents/harness-context.js} +5 -5
- package/engine/{harness.js → agents/harness.js} +3 -3
- package/engine/{llm.js → agents/llm.js} +18 -14
- package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
- package/engine/{playbook.js → agents/playbook.js} +155 -22
- package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
- package/engine/{preflight.js → agents/preflight.js} +29 -10
- package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
- package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
- package/engine/{steering.js → agents/steering.js} +5 -5
- package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
- package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
- package/engine/{api-validation.js → api/api-validation.js} +1 -1
- package/engine/api/bridge.js +787 -0
- package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
- package/engine/api/companion.js +560 -0
- package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
- package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
- package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
- package/engine/api-contracts/agent-content.js +4 -4
- package/engine/api-contracts/capability-manifest.js +236 -0
- package/engine/api-contracts/capability-protocol.js +333 -0
- package/engine/api-contracts/cc-ops.js +1 -1
- package/engine/api-contracts/config-runtime.js +5 -0
- package/engine/api-contracts/core.js +28 -1
- package/engine/api-contracts/index.js +100 -0
- package/engine/api-contracts/orchestration.js +18 -5
- package/engine/api-contracts/pull-requests.js +37 -6
- package/engine/api-contracts/qa-process.js +29 -6
- package/engine/api-contracts/work-plan-prd.js +21 -1
- package/engine/cloud/contract.js +212 -0
- package/engine/cloud/index.js +159 -0
- package/engine/{execution-model.js → core/execution-model.js} +1 -1
- package/engine/{features.js → core/features.js} +4 -4
- package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
- package/engine/{queries.js → core/queries.js} +201 -36
- package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
- package/engine/{shared.js → core/shared.js} +1637 -175
- package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
- package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
- package/engine/db/index.js +11 -2
- package/engine/db/migrations/002-dispatches.js +3 -3
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/db/migrations/006-metrics.js +1 -1
- package/engine/db/migrations/007-watches.js +2 -2
- package/engine/db/migrations/008-small-state.js +1 -1
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/010-pr-links.js +1 -1
- package/engine/db/migrations/011-remaining-state.js +1 -1
- package/engine/db/migrations/012-steering-deliveries.js +2 -2
- package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
- package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/db/migrations/018-sql-only-cutover.js +2 -2
- package/engine/db/migrations/021-archived-work-items.js +1 -1
- package/engine/db/migrations/022-global-cc-session.js +1 -1
- package/engine/db/migrations/023-engine-state.js +1 -1
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
- package/engine/db/migrations/029-repair-reused-versions.js +20 -0
- package/engine/db/migrations/031-pr-author-identity.js +137 -0
- package/engine/{consolidation.js → memory/consolidation.js} +6 -6
- package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
- package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
- package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
- package/engine/{memory-store.js → memory/memory-store.js} +3 -3
- package/engine/{promotion.js → memory/promotion.js} +3 -3
- package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
- package/engine/{review-learning.js → memory/review-learning.js} +10 -5
- package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
- package/engine/{logs-store.js → observability/logs-store.js} +5 -5
- package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
- package/engine/{check-status.js → operations/check-status.js} +3 -3
- package/engine/{cli.js → operations/cli.js} +271 -113
- package/engine/{distribution.js → operations/distribution.js} +5 -6
- package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
- package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
- package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
- package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
- package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
- package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
- package/engine/{meeting.js → orchestration/meeting.js} +6 -16
- package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
- package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
- package/engine/{routing.js → orchestration/routing.js} +3 -3
- package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
- package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
- package/engine/{timeout.js → orchestration/timeout.js} +158 -109
- package/engine/{db-events.js → persistence/db-events.js} +2 -2
- package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
- package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
- package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
- package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
- package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
- package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
- package/engine/persistence/state-operations.js +350 -0
- package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
- package/engine/{issues.js → planning/issues.js} +2 -2
- package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
- package/engine/planning/prd-result-sidecar.js +190 -0
- package/engine/{prd-store.js → planning/prd-store.js} +17 -17
- package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
- package/engine/{projects.js → planning/projects.js} +10 -10
- package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
- package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
- package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
- package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
- package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
- package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
- package/engine/{process-utils.js → processes/process-utils.js} +599 -55
- package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
- package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
- package/engine/{comment-format.js → providers/comment-format.js} +5 -5
- package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
- package/engine/{gh-token.js → providers/gh-token.js} +4 -4
- package/engine/{github.js → providers/github.js} +131 -54
- package/engine/{pr-action.js → providers/pr-action.js} +13 -12
- package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
- package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
- package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
- package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
- package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
- package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
- package/engine/{pr-track.js → providers/pr-track.js} +11 -13
- package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
- package/engine/qa/auto-prd-qa.js +313 -0
- package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
- package/engine/qa/prd-session.js +240 -0
- package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
- package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
- package/engine/{qa-runs.js → qa/runs.js} +286 -15
- package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
- package/engine/qa/visual-journey.js +654 -0
- package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
- package/engine/qa-runners/maestro.js +3 -3
- package/engine/qa-runners/playwright.js +2 -2
- package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
- package/engine/recovery/stop-stack.js +607 -0
- package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
- package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
- package/engine/runtimes/claude.js +14 -12
- package/engine/runtimes/codex.js +8 -6
- package/engine/runtimes/copilot.js +17 -16
- package/engine/{watch-actions.js → watches/actions.js} +13 -13
- package/engine/{watches.js → watches/index.js} +43 -32
- package/engine/{watches-store.js → watches/store.js} +4 -4
- package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
- package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
- package/engine/worktrees/inventory.js +671 -0
- package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
- package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
- package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
- package/engine/worktrees/quarantine-refs.js +173 -0
- package/engine.js +1137 -208
- package/minions.js +147 -77
- package/package.json +10 -6
- package/playbooks/_pr-description-audit.md +110 -78
- package/playbooks/build-fix-complex.md +2 -0
- package/playbooks/fix.md +16 -12
- package/playbooks/implement-shared.md +2 -0
- package/playbooks/implement.md +19 -20
- package/playbooks/plan-to-prd.md +18 -3
- package/playbooks/qa-session-draft.md +136 -1
- package/playbooks/qa-session-execute.md +80 -2
- package/playbooks/qa-session-setup.md +17 -1
- package/playbooks/qa-validate.md +1 -1
- package/playbooks/setup.md +2 -0
- package/playbooks/shared-rules.md +25 -32
- package/playbooks/templates/followup-dispatch.md +4 -3
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +19 -27
- package/watch-plugins/README.md +92 -0
- package/watch-plugins/ado-author-prs.js +336 -0
- package/watch-plugins/gh-author-prs.js +375 -0
- package/watch-plugins/http.js +474 -0
- package/watch-plugins/teams-channel.js +869 -0
- package/docs/dev-composite-workflow.md +0 -101
- package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
- package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
- package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/visual-evidence-ci.md +0 -103
- package/engine/bridge.js +0 -379
- package/engine/quarantine-refs.js +0 -103
- package/engine/state-operations.js +0 -178
- /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* Concurrency: every mutation goes through shared.mutateQaSessions, which
|
|
32
32
|
* routes to small-state-store.applyQaSessionsMutation inside a SQLite
|
|
33
33
|
* transaction (BEGIN IMMEDIATE). Callbacks are synchronous and never await. Slow filesystem work
|
|
34
|
-
* (qa-tests/<id>/ scaffolding,
|
|
34
|
+
* (qa-tests/<id>/ scaffolding, engine wakeup requests) runs OUTSIDE the lock.
|
|
35
35
|
*
|
|
36
36
|
* Path-traversal hardening: sessionId is generated by createSession() with a
|
|
37
37
|
* uid suffix, but the module still treats every callsite as untrusted —
|
|
@@ -40,16 +40,18 @@
|
|
|
40
40
|
* _isSafeId (PR #2694 review feedback).
|
|
41
41
|
*
|
|
42
42
|
* Persistence: SQLite (engine/state.db, `qa_sessions` table). Capped at
|
|
43
|
-
* QA_SESSIONS_MAX_RECORDS via createSession-time rotation.
|
|
43
|
+
* QA_SESSIONS_MAX_RECORDS via createSession-time rotation. Records otherwise
|
|
44
|
+
* leave only through deleteSession(), which is terminal-only (W-msb4vgj500zbb008).
|
|
44
45
|
*/
|
|
45
46
|
|
|
46
47
|
const fs = require('fs');
|
|
47
48
|
const path = require('path');
|
|
48
|
-
const shared = require('
|
|
49
|
+
const shared = require('../core/shared');
|
|
50
|
+
const visualJourney = require('./visual-journey');
|
|
49
51
|
const { mutateQaSessions, uid, ts, log } = shared;
|
|
50
52
|
|
|
51
53
|
function _readSessions() {
|
|
52
|
-
return require('
|
|
54
|
+
return require('../persistence/small-state-store').readQaSessions();
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// Sessions cost more than runs (3 WIs, a
|
|
@@ -247,6 +249,70 @@ function _validateCapture(capture) {
|
|
|
247
249
|
return errors;
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The canonical project list a spec declares, in declaration order.
|
|
254
|
+
*
|
|
255
|
+
* `spec.projects` (multi) wins over the legacy single-string `spec.project`,
|
|
256
|
+
* matching createSession's normalization. Returns `[]` for a central session.
|
|
257
|
+
* Shared by validateSpec and createSession so "which projects does this spec
|
|
258
|
+
* name?" cannot drift between validation and persistence.
|
|
259
|
+
*/
|
|
260
|
+
function _specProjects(spec) {
|
|
261
|
+
if (Array.isArray(spec && spec.projects) && spec.projects.length > 0) {
|
|
262
|
+
return spec.projects.filter(_isNonEmptyString);
|
|
263
|
+
}
|
|
264
|
+
if (_isNonEmptyString(spec && spec.project)) return [spec.project];
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* W-msb9kgs402813a97-a — resolve which project owns DRAFT/EXECUTE.
|
|
270
|
+
*
|
|
271
|
+
* Explicit `spec.primaryProject` wins; otherwise the first declared project.
|
|
272
|
+
* Returns null for a central (project-less) session.
|
|
273
|
+
*/
|
|
274
|
+
function _resolvePrimaryProject(spec, projects) {
|
|
275
|
+
const list = Array.isArray(projects) ? projects : [];
|
|
276
|
+
if (_isNonEmptyString(spec && spec.primaryProject) && list.includes(spec.primaryProject)) {
|
|
277
|
+
return spec.primaryProject;
|
|
278
|
+
}
|
|
279
|
+
return list.length > 0 ? list[0] : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Keep only the projectTargets entries that name a declared project, so a
|
|
284
|
+
* persisted map can never route a work item to a project the session does not
|
|
285
|
+
* own. validateSpec already rejects unknown keys — this is the persistence-side
|
|
286
|
+
* belt to that suspenders.
|
|
287
|
+
*/
|
|
288
|
+
function _normalizeProjectTargets(spec, projects) {
|
|
289
|
+
const raw = spec && spec.projectTargets;
|
|
290
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const project of projects) {
|
|
293
|
+
if (Object.prototype.hasOwnProperty.call(raw, project) && raw[project]) {
|
|
294
|
+
out[project] = { ...raw[project] };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The QA target a given project's work item should be built against.
|
|
302
|
+
* `spec.projectTargets[project]` when declared, else the session-wide
|
|
303
|
+
* `spec.target`. Used by every work-item builder so a fanned-out SETUP no
|
|
304
|
+
* longer stamps the primary's target onto every co-service.
|
|
305
|
+
*/
|
|
306
|
+
function _targetForProject(session, project) {
|
|
307
|
+
const spec = (session && session.spec) || {};
|
|
308
|
+
const map = spec.projectTargets;
|
|
309
|
+
if (_isNonEmptyString(project) && map && typeof map === 'object' && !Array.isArray(map)
|
|
310
|
+
&& map[project] && typeof map[project] === 'object') {
|
|
311
|
+
return map[project];
|
|
312
|
+
}
|
|
313
|
+
return spec.target;
|
|
314
|
+
}
|
|
315
|
+
|
|
250
316
|
/**
|
|
251
317
|
* Validate a createSession spec. Returns { ok, errors }. Never throws.
|
|
252
318
|
*/
|
|
@@ -320,6 +386,45 @@ function validateSpec(spec) {
|
|
|
320
386
|
}
|
|
321
387
|
}
|
|
322
388
|
|
|
389
|
+
// W-msb9kgs402813a97-a — explicit primary + per-project targets. Both are
|
|
390
|
+
// validated against the SAME project list createSession normalizes to, so a
|
|
391
|
+
// spec can never persist a primary or a target keyed to a project the
|
|
392
|
+
// session does not own. Errors name the offending value AND the known
|
|
393
|
+
// projects — an operator hitting POST /api/qa/session gets a fix, not a
|
|
394
|
+
// shrug.
|
|
395
|
+
const declaredProjects = _specProjects(spec);
|
|
396
|
+
const knownProjects = declaredProjects.length ? declaredProjects.join(', ') : '(none)';
|
|
397
|
+
|
|
398
|
+
if (spec.primaryProject !== undefined && spec.primaryProject !== null) {
|
|
399
|
+
if (!_isNonEmptyString(spec.primaryProject)) {
|
|
400
|
+
errors.push('primaryProject must be a non-empty string when present');
|
|
401
|
+
} else if (spec.primaryProject.length > LIMITS.projectMax) {
|
|
402
|
+
errors.push(`primaryProject exceeds ${LIMITS.projectMax} chars`);
|
|
403
|
+
} else if (!declaredProjects.includes(spec.primaryProject)) {
|
|
404
|
+
errors.push(`primaryProject "${spec.primaryProject}" must be one of the session projects: ${knownProjects}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (spec.projectTargets !== undefined && spec.projectTargets !== null) {
|
|
409
|
+
if (typeof spec.projectTargets !== 'object' || Array.isArray(spec.projectTargets)) {
|
|
410
|
+
errors.push('projectTargets must be a plain object keyed by project name');
|
|
411
|
+
} else {
|
|
412
|
+
const keys = Object.keys(spec.projectTargets);
|
|
413
|
+
if (keys.length > LIMITS.projectsMax) {
|
|
414
|
+
errors.push(`projectTargets exceeds ${LIMITS.projectsMax} entries`);
|
|
415
|
+
} else {
|
|
416
|
+
for (const key of keys) {
|
|
417
|
+
if (!declaredProjects.includes(key)) {
|
|
418
|
+
errors.push(`projectTargets["${key}"] must be one of the session projects: ${knownProjects}`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
errors.push(..._validateTarget(spec.projectTargets[key])
|
|
422
|
+
.map(e => `projectTargets["${key}"]: ${e}`));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
323
428
|
return { ok: errors.length === 0, errors };
|
|
324
429
|
}
|
|
325
430
|
|
|
@@ -341,6 +446,11 @@ function validateSpec(spec) {
|
|
|
341
446
|
* @param {object} [spec.capture] - { video?, screenshots?, logs? }
|
|
342
447
|
* @param {string} [spec.runner] - explicit runner name, or null to auto-detect
|
|
343
448
|
* @param {string} [spec.project] - project name (used to scope artifacts)
|
|
449
|
+
* @param {string[]} [spec.projects] - multi-project fan-out (≤ LIMITS.projectsMax)
|
|
450
|
+
* @param {string} [spec.primaryProject] - which of `projects` owns DRAFT/EXECUTE
|
|
451
|
+
* and the unsuffixed managed-spawn name. Defaults to `projects[0]`.
|
|
452
|
+
* @param {object} [spec.projectTargets] - per-project `{ [projectName]: target }`
|
|
453
|
+
* overrides. A project without an entry falls back to `spec.target`.
|
|
344
454
|
* @param {string} [spec.createdBy] - operator identity for audit
|
|
345
455
|
* @param {object} [spec.provenance] - P-c5a2f8b7. Optional "where this spec
|
|
346
456
|
* came from" audit slot (e.g. { source:'prd', prd, flowSource,
|
|
@@ -362,14 +472,14 @@ function createSession(spec) {
|
|
|
362
472
|
// single canonical `projects: string[] | null` (null = central). When both
|
|
363
473
|
// are present, `projects` wins. Empty string in legacy `project` is treated
|
|
364
474
|
// as central (matches the dashboard input convention).
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
const
|
|
475
|
+
const declaredProjects = _specProjects(spec);
|
|
476
|
+
const projects = declaredProjects.length > 0 ? declaredProjects.slice() : null;
|
|
477
|
+
// W-msb9kgs402813a97-a — the primary is DECLARED, not positional. coServices
|
|
478
|
+
// is therefore "every other project" rather than projects.slice(1), so a
|
|
479
|
+
// primary in the middle of the list still fans the rest out correctly.
|
|
480
|
+
const primaryProject = _resolvePrimaryProject(spec, declaredProjects);
|
|
481
|
+
const coServices = projects ? projects.filter(p => p !== primaryProject) : [];
|
|
482
|
+
const projectTargets = _normalizeProjectTargets(spec, declaredProjects);
|
|
373
483
|
|
|
374
484
|
const id = 'qas-' + uid();
|
|
375
485
|
const now = ts();
|
|
@@ -391,6 +501,10 @@ function createSession(spec) {
|
|
|
391
501
|
// older lifecycle hooks); new code keys off `projects`.
|
|
392
502
|
projects,
|
|
393
503
|
project: primaryProject,
|
|
504
|
+
// Per-project target overrides. null (not {}) when the caller declared
|
|
505
|
+
// none, so `_targetForProject` short-circuits to spec.target and the
|
|
506
|
+
// record shape stays honest about "no per-project routing".
|
|
507
|
+
projectTargets,
|
|
394
508
|
},
|
|
395
509
|
// W-mpq6xqzj000606d0 — denormalized fast-path fields used by the dispatch
|
|
396
510
|
// chain (queueSetup / handleSetupComplete) without re-deriving from
|
|
@@ -527,10 +641,56 @@ function getSessionTestFile(sessionId) {
|
|
|
527
641
|
return { path: target, content, language, truncated: false, size: stat.size };
|
|
528
642
|
}
|
|
529
643
|
|
|
644
|
+
/**
|
|
645
|
+
* W-msb9kgs402813a97-d — bounded read-side projection of the qa-run a session
|
|
646
|
+
* spawned. Returns a Map of runId → { id, status, artifacts } for the ids
|
|
647
|
+
* asked about, built from ONE runs read.
|
|
648
|
+
*
|
|
649
|
+
* Deliberately NOT `runs.getRun(id)` per session: getRun re-reads and re-parses
|
|
650
|
+
* the whole qa_runs table on every call, so a 50-session list on a 3s poll
|
|
651
|
+
* would be a 50× full-table scan. One read + an id index is the same answer at
|
|
652
|
+
* 1/50th the cost.
|
|
653
|
+
*
|
|
654
|
+
* Lazy-required and fully try/catch'd — the sessions list must survive an
|
|
655
|
+
* unreadable runs store (it degrades to `linkedRun: null`, never a 500).
|
|
656
|
+
*/
|
|
657
|
+
function _projectLinkedRuns(runIds) {
|
|
658
|
+
const out = new Map();
|
|
659
|
+
if (!runIds || !runIds.size) return out;
|
|
660
|
+
let runs;
|
|
661
|
+
try {
|
|
662
|
+
const qaRuns = require('./runs');
|
|
663
|
+
runs = qaRuns.listRuns({});
|
|
664
|
+
if (!Array.isArray(runs)) return out;
|
|
665
|
+
for (const run of runs) {
|
|
666
|
+
if (!run || !runIds.has(run.id)) continue;
|
|
667
|
+
out.set(run.id, {
|
|
668
|
+
id: run.id,
|
|
669
|
+
status: run.status || null,
|
|
670
|
+
artifacts: qaRuns.selectPrimaryArtifacts(run.artifacts),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
} catch (e) {
|
|
674
|
+
log('warn', `qa-sessions: linked-run projection unavailable: ${e.message}`);
|
|
675
|
+
return new Map();
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
|
|
530
680
|
/**
|
|
531
681
|
* List sessions, newest first, optionally filtered by state, capped by limit.
|
|
682
|
+
*
|
|
683
|
+
* @param {object} [opts]
|
|
684
|
+
* @param {number} [opts.limit]
|
|
685
|
+
* @param {string} [opts.state]
|
|
686
|
+
* @param {boolean} [opts.includeLinkedRun] - attach a bounded `linkedRun`
|
|
687
|
+
* projection ({ id, status, artifacts: [{type, path, label}] }, primary
|
|
688
|
+
* artifacts only) for sessions carrying a qaRunId, else `linkedRun: null`.
|
|
689
|
+
* Opt-in on purpose: the default read path (including
|
|
690
|
+
* runs.js#_runSessionOwnership, called on every run delete) must not pay for
|
|
691
|
+
* the join. The projection is attached to a COPY — it is never persisted.
|
|
532
692
|
*/
|
|
533
|
-
function listSessions({ limit, state } = {}) {
|
|
693
|
+
function listSessions({ limit, state, includeLinkedRun } = {}) {
|
|
534
694
|
let sessions = _readSessions();
|
|
535
695
|
if (!Array.isArray(sessions)) return [];
|
|
536
696
|
if (state) {
|
|
@@ -545,7 +705,16 @@ function listSessions({ limit, state } = {}) {
|
|
|
545
705
|
});
|
|
546
706
|
const n = Number(limit);
|
|
547
707
|
if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, Math.floor(n));
|
|
548
|
-
return sessions;
|
|
708
|
+
if (!includeLinkedRun) return sessions;
|
|
709
|
+
// Index once for the page, after limit/filter so we only join what we return.
|
|
710
|
+
const runIds = new Set();
|
|
711
|
+
for (const s of sessions) {
|
|
712
|
+
if (s && typeof s.qaRunId === 'string' && s.qaRunId) runIds.add(s.qaRunId);
|
|
713
|
+
}
|
|
714
|
+
const linked = _projectLinkedRuns(runIds);
|
|
715
|
+
return sessions.map(s => (
|
|
716
|
+
s ? { ...s, linkedRun: (s.qaRunId && linked.get(s.qaRunId)) || null } : s
|
|
717
|
+
));
|
|
549
718
|
}
|
|
550
719
|
|
|
551
720
|
/**
|
|
@@ -642,7 +811,10 @@ function transitionSession(id, toState, patch = {}, options = {}) {
|
|
|
642
811
|
// W-mpq6xqzj000606d0 — `setupStatus` and `primaryScope` let the
|
|
643
812
|
// multi-project fan-in handler can stamp per-project completion state
|
|
644
813
|
// through the same locked path used by every other mutation.
|
|
645
|
-
|
|
814
|
+
// W-msb9kgs402813a97-b — `visualJourneyManifest` is the accepted DRAFT
|
|
815
|
+
// manifest, persisted so EXECUTE can validate captured evidence against
|
|
816
|
+
// what DRAFT actually promised.
|
|
817
|
+
for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryScope', 'visualJourneyManifest']) {
|
|
646
818
|
if (Object.prototype.hasOwnProperty.call(patch, field)) {
|
|
647
819
|
session[field] = patch[field];
|
|
648
820
|
}
|
|
@@ -674,7 +846,7 @@ function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.K
|
|
|
674
846
|
// timeout already marked the session failed. Before this guard, that late
|
|
675
847
|
// completion fell straight into `transitionSession`, which throws on the
|
|
676
848
|
// illegal `failed -> drafting` (etc.) transition; the throw was swallowed by
|
|
677
|
-
// the caller's try/catch (engine/lifecycle.js) and logged as a bare warning,
|
|
849
|
+
// the caller's try/catch (engine/orchestration/lifecycle.js) and logged as a bare warning,
|
|
678
850
|
// silently discarding real completed work (e.g. a working managed-spawn
|
|
679
851
|
// setup) with no trace for a human to act on. This guard intercepts BEFORE
|
|
680
852
|
// the throw, logs loudly, and writes an inbox note so the orphaned success
|
|
@@ -693,19 +865,21 @@ function _guardAlreadyTerminal(sessionId, session, phase, opts) {
|
|
|
693
865
|
: '');
|
|
694
866
|
log('warn', detail);
|
|
695
867
|
try {
|
|
696
|
-
// Lazy require (mirrors engine/live-checkout.js#_defaultWriteInboxNote) to
|
|
868
|
+
// Lazy require (mirrors engine/worktrees/live-checkout.js#_defaultWriteInboxNote) to
|
|
697
869
|
// sidestep any require-cycle between dispatch.js and qa-sessions.js.
|
|
698
|
-
require('
|
|
870
|
+
require('../orchestration/dispatch').writeInboxAlert(`qa-session-late-completion-${sessionId}`, detail);
|
|
699
871
|
} catch (e) { log('warn', `qa-sessions: writeInboxAlert for late completion ${sessionId} failed: ${e.message}`); }
|
|
700
872
|
return true;
|
|
701
873
|
}
|
|
702
874
|
|
|
703
875
|
// ── Work-item builders (pure) ──────────────────────────────────────────────
|
|
704
876
|
//
|
|
705
|
-
// Each builder returns a WI spec ready for mutateWorkItems().push
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
877
|
+
// Each builder returns a WI spec ready for mutateWorkItems().push. Keeping
|
|
878
|
+
// these pure lets the dashboard endpoints (PR4) reuse them without pulling
|
|
879
|
+
// dispatch into the unit test path. They're also called by the lifecycle chain
|
|
880
|
+
// helpers below to queue the next phase. `project` is a hint only —
|
|
881
|
+
// _persistWorkItem binds the field to the scope the item is stored in
|
|
882
|
+
// (W-ms9nq2pu00cd60f3).
|
|
709
883
|
|
|
710
884
|
function _baseWorkItem(session, phase, { title, description, project, primary, coServices, primaryProject }) {
|
|
711
885
|
const wiId = 'W-' + uid();
|
|
@@ -715,8 +889,13 @@ function _baseWorkItem(session, phase, { title, description, project, primary, c
|
|
|
715
889
|
// coServices=[] so existing single-target prompts render unchanged.
|
|
716
890
|
// DRAFT/EXECUTE always omit these (primary-only) — pass `primary:
|
|
717
891
|
// undefined` to keep meta clean.
|
|
892
|
+
//
|
|
893
|
+
// W-msb9kgs402813a97-a — each WI is routed its OWN target. Before this,
|
|
894
|
+
// every fanned-out SETUP was stamped with the session-wide
|
|
895
|
+
// `spec.target`, so a co-service could only ever be checked out at the
|
|
896
|
+
// primary's branch/PR/commit.
|
|
718
897
|
const qaMeta = {
|
|
719
|
-
target: session
|
|
898
|
+
target: _targetForProject(session, project),
|
|
720
899
|
flowsRaw: session.spec.flowsRaw,
|
|
721
900
|
mode: session.spec.mode,
|
|
722
901
|
capture: session.spec.capture,
|
|
@@ -764,12 +943,13 @@ function _baseWorkItem(session, phase, { title, description, project, primary, c
|
|
|
764
943
|
function buildSetupWorkItem(session, { project, primary, coServices, primaryProject } = {}) {
|
|
765
944
|
const isPrimary = primary === undefined ? true : !!primary;
|
|
766
945
|
const co = Array.isArray(coServices) ? coServices.slice() : [];
|
|
946
|
+
const target = _targetForProject(session, project);
|
|
767
947
|
return _baseWorkItem(session, SESSION_PHASE.SETUP, {
|
|
768
|
-
title: `QA Session SETUP: ${_summarizeTarget(
|
|
948
|
+
title: `QA Session SETUP: ${_summarizeTarget(target)}${isPrimary && co.length ? ` (primary +${co.length} co-services)` : (!isPrimary ? ' (co-service)' : '')}`,
|
|
769
949
|
description: [
|
|
770
950
|
`QA Session ${session.id} — SETUP phase.`,
|
|
771
951
|
'',
|
|
772
|
-
`Target: ${JSON.stringify(
|
|
952
|
+
`Target: ${JSON.stringify(target)}`,
|
|
773
953
|
`Flows: ${session.spec.flowsRaw}`,
|
|
774
954
|
isPrimary
|
|
775
955
|
? (co.length ? `Role: PRIMARY (co-services: ${co.join(', ')})` : 'Role: PRIMARY (single-project session)')
|
|
@@ -814,7 +994,7 @@ function buildDraftWorkItem(session, { project, feedback } = {}) {
|
|
|
814
994
|
lines.push('', '## Reviewer feedback on previous draft', '', String(feedback));
|
|
815
995
|
}
|
|
816
996
|
return _baseWorkItem(session, SESSION_PHASE.DRAFT, {
|
|
817
|
-
title: `QA Session DRAFT: ${_summarizeTarget(session
|
|
997
|
+
title: `QA Session DRAFT: ${_summarizeTarget(_targetForProject(session, project))}`,
|
|
818
998
|
description: lines.join('\n'),
|
|
819
999
|
project,
|
|
820
1000
|
});
|
|
@@ -823,7 +1003,7 @@ function buildDraftWorkItem(session, { project, feedback } = {}) {
|
|
|
823
1003
|
/**
|
|
824
1004
|
* Build the EXECUTE work item. The agent invokes the runner against the live
|
|
825
1005
|
* spawn, captures artifacts per `capture`, and writes the injected absolute
|
|
826
|
-
* result sidecar. The qaRunId hook in engine/lifecycle.js ingests it; our own
|
|
1006
|
+
* result sidecar. The qaRunId hook in engine/orchestration/lifecycle.js ingests it; our own
|
|
827
1007
|
* session hook below transitions done/failed based on the resulting qa-run
|
|
828
1008
|
* terminal status.
|
|
829
1009
|
*
|
|
@@ -837,7 +1017,7 @@ function buildExecuteWorkItem(session, { qaRunId, project } = {}) {
|
|
|
837
1017
|
throw new Error('qa-sessions: buildExecuteWorkItem requires qaRunId');
|
|
838
1018
|
}
|
|
839
1019
|
const wi = _baseWorkItem(session, SESSION_PHASE.EXECUTE, {
|
|
840
|
-
title: `QA Session EXECUTE: ${_summarizeTarget(session
|
|
1020
|
+
title: `QA Session EXECUTE: ${_summarizeTarget(_targetForProject(session, project))}`,
|
|
841
1021
|
description: [
|
|
842
1022
|
`QA Session ${session.id} — EXECUTE phase.`,
|
|
843
1023
|
'',
|
|
@@ -876,21 +1056,80 @@ function _summarizeTarget(target) {
|
|
|
876
1056
|
// endpoints call when an agent finishes or a user takes an action. Each one
|
|
877
1057
|
// validates the transition first (so an illegal call throws BEFORE side
|
|
878
1058
|
// effects like queueing the next WI), then applies the state change, then
|
|
879
|
-
//
|
|
1059
|
+
// persists the next phase's work item via _queueWorkItem.
|
|
880
1060
|
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1061
|
+
// Dispatch itself is owned by the engine's normal work-discovery pass
|
|
1062
|
+
// (engine.js#discoverFromWorkItems / #discoverCentralWorkItems) — see
|
|
1063
|
+
// _queueWorkItem. work-items is lazy-required inside shared.mutateWorkItems to
|
|
1064
|
+
// keep `require('./qa/sessions')` cycle-safe at the top of lifecycle.js.
|
|
1065
|
+
|
|
1066
|
+
const CENTRAL_SCOPE = 'central';
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* The project that owns a SQL work-item scope. `shared.resolveProjectSource`
|
|
1070
|
+
* builds a scope as `project?.name || 'central'`, so a scope is either a
|
|
1071
|
+
* configured project name or the central sentinel.
|
|
1072
|
+
*/
|
|
1073
|
+
function _scopeProject(scope) {
|
|
1074
|
+
const value = _isNonEmptyString(scope) ? String(scope).trim() : '';
|
|
1075
|
+
return value && value !== CENTRAL_SCOPE ? value : null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* W-ms9nq2pu00cd60f3 — a QA phase work item's project association is DERIVED
|
|
1080
|
+
* from the scope it is stored in, never merely carried alongside it.
|
|
1081
|
+
*
|
|
1082
|
+
* `WORK_TYPE.TEST` is in shared.WORKTREE_REQUIRING_TYPES: engine.js resolves a
|
|
1083
|
+
* worktree rootDir from the work item's project, and without one it falls back
|
|
1084
|
+
* to MINIONS_DIR's parent — which collapses to a drive root on installs where
|
|
1085
|
+
* MINIONS_DIR sits one level below it (`D:\squad-opg` → `D:\`) and fails
|
|
1086
|
+
* non-retryably with FAILURE_CLASS.WORKTREE_PREFLIGHT. Because the builders
|
|
1087
|
+
* take `project` as a separate argument with an `opts.project ||
|
|
1088
|
+
* spec.project || null` fallback chain, a phase queued into a project scope
|
|
1089
|
+
* could land with `project: null` whenever that chain came up empty (e.g. a
|
|
1090
|
+
* session created with `projects: ['x']`, which canonicalizes to `spec.projects`
|
|
1091
|
+
* and leaves `spec.project` unset). Binding to the scope makes the association
|
|
1092
|
+
* structural instead of incidental.
|
|
1093
|
+
*
|
|
1094
|
+
* A project scope that is handed a DIFFERENT project name is an unambiguous
|
|
1095
|
+
* caller bug — neither value can be trusted, so it throws rather than persist
|
|
1096
|
+
* an item the engine can never place. A central scope is left alone: it has no
|
|
1097
|
+
* owning project to bind, and a declared project there is how
|
|
1098
|
+
* discoverCentralWorkItems still resolves a rootDir, so neither inventing nor
|
|
1099
|
+
* stripping one would help.
|
|
1100
|
+
*/
|
|
1101
|
+
function _assertProjectMatchesScope(scope, project, label) {
|
|
1102
|
+
const scopeProject = _scopeProject(scope);
|
|
1103
|
+
const declared = _isNonEmptyString(project) ? String(project).trim() : null;
|
|
1104
|
+
if (scopeProject && declared && declared !== scopeProject) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
`qa-sessions: ${label} project "${declared}" does not match scope "${scope}" — ` +
|
|
1107
|
+
'a QA phase work item must be owned by the project whose work-item scope stores it',
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
return scopeProject;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Wake the engine daemon so its 1s fast poll runs a tick immediately instead of
|
|
1115
|
+
* waiting a full tick interval. Mirrors the dashboard's create/advance wakeups
|
|
1116
|
+
* (dashboard.js `mutateControl(control => ({ ...control, _wakeupAt }))`).
|
|
1117
|
+
* Best-effort: a missing/locked control.json must never fail the transition —
|
|
1118
|
+
* the next scheduled tick still discovers the item.
|
|
1119
|
+
*/
|
|
1120
|
+
function _requestDispatchWakeup() {
|
|
1121
|
+
try {
|
|
1122
|
+
shared.mutateControl((control) => ({ ...control, _wakeupAt: Date.now() }));
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
log('warn', `qa-sessions: dispatch wakeup request failed: ${e.message}`);
|
|
1125
|
+
}
|
|
891
1126
|
}
|
|
892
1127
|
|
|
893
1128
|
function _persistWorkItem(wi, scope) {
|
|
1129
|
+
const project = _assertProjectMatchesScope(scope, wi.project, `work item ${wi.id}`);
|
|
1130
|
+
// Stamp BEFORE the store write: the item must never be visible to work
|
|
1131
|
+
// discovery without the project field engine.js reads to place a worktree.
|
|
1132
|
+
if (project) wi.project = project;
|
|
894
1133
|
shared.mutateWorkItems(scope, (items) => {
|
|
895
1134
|
if (!Array.isArray(items)) items = [];
|
|
896
1135
|
if (!items.some(item => item && item.id === wi.id)) items.push(wi);
|
|
@@ -898,9 +1137,22 @@ function _persistWorkItem(wi, scope) {
|
|
|
898
1137
|
});
|
|
899
1138
|
}
|
|
900
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* Persist a phase work item as `pending` and wake the engine.
|
|
1142
|
+
*
|
|
1143
|
+
* QA phases deliberately do NOT enqueue their own dispatch record. Dispatch is
|
|
1144
|
+
* built by engine.js#discoverFromWorkItems / #discoverCentralWorkItems, which
|
|
1145
|
+
* is the only place that resolves `meta.project`, routes an agent, derives the
|
|
1146
|
+
* branch, honours the branch mutex, and renders the phase playbook prompt.
|
|
1147
|
+
* A hand-rolled `addToDispatch({ meta: { item, playbook } })` carried none of
|
|
1148
|
+
* those, so it spawned project-less and forever-failed WORKTREE_PREFLIGHT while
|
|
1149
|
+
* also blocking the correct dispatch via the meta.item.id dedup
|
|
1150
|
+
* (W-ms9nq2pu00cd60f3). Matches the sibling qa-validate flow
|
|
1151
|
+
* (dashboard.js#handleQaRunbookRun), which has always relied on discovery.
|
|
1152
|
+
*/
|
|
901
1153
|
function _queueWorkItem(wi, scope) {
|
|
902
1154
|
_persistWorkItem(wi, scope);
|
|
903
|
-
|
|
1155
|
+
_requestDispatchWakeup();
|
|
904
1156
|
return wi.id;
|
|
905
1157
|
}
|
|
906
1158
|
|
|
@@ -909,8 +1161,9 @@ function _queueWorkItem(wi, scope) {
|
|
|
909
1161
|
* Validates pending → spawning, queues the SETUP WI(s), returns the queued
|
|
910
1162
|
* primary WI id (back-compat).
|
|
911
1163
|
*
|
|
912
|
-
* W-mpq6xqzj000606d0 — multi-project fan-out.
|
|
913
|
-
* DRAFT/EXECUTE
|
|
1164
|
+
* W-mpq6xqzj000606d0 — multi-project fan-out. W-msb9kgs402813a97-a — the
|
|
1165
|
+
* session's declared `primaryProject` drives DRAFT/EXECUTE (the targets are
|
|
1166
|
+
* reordered so it leads); every other target is a co-service.
|
|
914
1167
|
*
|
|
915
1168
|
* @param {string} sessionId
|
|
916
1169
|
* @param {object} opts
|
|
@@ -931,6 +1184,9 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
931
1184
|
if (!t || !_isNonEmptyString(t.scope)) {
|
|
932
1185
|
throw new Error('qa-sessions: queueSetup target missing scope');
|
|
933
1186
|
}
|
|
1187
|
+
// Reject a project/scope disagreement BEFORE markSpawning, so a bad target
|
|
1188
|
+
// can't leave the session stranded in `spawning` with no work item.
|
|
1189
|
+
_assertProjectMatchesScope(t.scope, t.project, 'queueSetup target');
|
|
934
1190
|
}
|
|
935
1191
|
if (targets.length > LIMITS.projectsMax) {
|
|
936
1192
|
throw new Error(`qa-sessions: queueSetup target count exceeds ${LIMITS.projectsMax}`);
|
|
@@ -939,6 +1195,31 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
939
1195
|
const session = getSession(sessionId);
|
|
940
1196
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
941
1197
|
|
|
1198
|
+
// W-msb9kgs402813a97-a — the primary is the target whose project matches the
|
|
1199
|
+
// session's DECLARED primaryProject, not whichever target the caller happened
|
|
1200
|
+
// to list first. Reordering (rather than tracking an index) keeps the single
|
|
1201
|
+
// "index 0 is primary" invariant the rest of this function and the fan-in
|
|
1202
|
+
// handler rely on.
|
|
1203
|
+
//
|
|
1204
|
+
// A declared primary that no project-bound target can host is a genuine
|
|
1205
|
+
// contract violation: the resolver and the session record disagree about what
|
|
1206
|
+
// the session IS. Throw BEFORE markSpawning so the caller sees a 4xx instead
|
|
1207
|
+
// of a session stranded in `spawning` with a mis-flagged fan-out. Callers
|
|
1208
|
+
// that pass only project-less targets (the legacy `{ scope }` shape) are
|
|
1209
|
+
// unaffected — there is nothing to contradict.
|
|
1210
|
+
const declaredPrimary = _isNonEmptyString(session.primaryProject) ? session.primaryProject : null;
|
|
1211
|
+
if (declaredPrimary) {
|
|
1212
|
+
const primaryIndex = targets.findIndex(t => (t.project || null) === declaredPrimary);
|
|
1213
|
+
if (primaryIndex > 0) {
|
|
1214
|
+
targets.unshift(targets.splice(primaryIndex, 1)[0]);
|
|
1215
|
+
} else if (primaryIndex < 0 && targets.some(t => _isNonEmptyString(t.project))) {
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
`qa-sessions: queueSetup primaryProject "${declaredPrimary}" is not among the resolved `
|
|
1218
|
+
+ `targets (${targets.map(t => t.project || '(central)').join(', ')})`,
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
942
1223
|
const primaryTarget = targets[0];
|
|
943
1224
|
const coServiceProjects = targets.slice(1).map(t => t.project || null).filter(Boolean);
|
|
944
1225
|
const isMulti = targets.length > 1;
|
|
@@ -1153,7 +1434,7 @@ function handleSetupCompleteAfterManagedSpawn(sessionId, opts = {}) {
|
|
|
1153
1434
|
if (!opts.success) return handleSetupComplete(sessionId, opts);
|
|
1154
1435
|
|
|
1155
1436
|
const requirement = _setupManagedSpawnRequirement(session, opts.project || null);
|
|
1156
|
-
const record = require('
|
|
1437
|
+
const record = require('../processes/managed-spawn').getManagedSpecByName(requirement.name);
|
|
1157
1438
|
const hasExpectedOwner = _isNonEmptyString(requirement.wiId);
|
|
1158
1439
|
const ownerMatches = hasExpectedOwner && record?.owner_wi === requirement.wiId;
|
|
1159
1440
|
if (!record || record.alive !== true || record.healthy !== true || !ownerMatches) {
|
|
@@ -1181,10 +1462,21 @@ function handleSetupCompleteAfterManagedSpawn(sessionId, opts = {}) {
|
|
|
1181
1462
|
* awaiting-approval (confirm mode — user must call /approve) or auto-chain
|
|
1182
1463
|
* EXECUTE (auto mode). The testFile path is captured for the EXECUTE prompt.
|
|
1183
1464
|
*
|
|
1465
|
+
* W-msb9kgs402813a97-b — a session that asked for screenshots or video must
|
|
1466
|
+
* also produce a VISUAL-JOURNEY MANIFEST (the agent's
|
|
1467
|
+
* `agents/<id>/qa-session-draft-result.json` sidecar, consumed by the
|
|
1468
|
+
* lifecycle hook and handed in as `opts.manifest`). The gate runs BEFORE both
|
|
1469
|
+
* exits — before `markAwaitingApproval` and before the auto-mode EXECUTE queue
|
|
1470
|
+
* — so a draft that never opened a browser, skipped a project, or ignored a
|
|
1471
|
+
* co-service origin fails here instead of producing a green qa-run with no
|
|
1472
|
+
* visual evidence.
|
|
1473
|
+
*
|
|
1184
1474
|
* @param {string} sessionId
|
|
1185
1475
|
* @param {object} opts
|
|
1186
1476
|
* @param {boolean} opts.success
|
|
1187
1477
|
* @param {string} [opts.testFile] - relative path under qa-tests/<id>/, captured for EXECUTE
|
|
1478
|
+
* @param {object} [opts.manifest] - parsed visual-journey manifest from the DRAFT sidecar
|
|
1479
|
+
* @param {Array} [opts.services] - managed-spawn inventory override for the coverage check
|
|
1188
1480
|
* @param {string} [opts.scope] - required when success=true and mode=auto
|
|
1189
1481
|
* @param {string} [opts.project]
|
|
1190
1482
|
* @param {string} [opts.qaRunId] - required when success=true and mode=auto (caller creates the qa-runs record)
|
|
@@ -1205,15 +1497,46 @@ function handleDraftComplete(sessionId, opts = {}) {
|
|
|
1205
1497
|
});
|
|
1206
1498
|
return { nextState: QA_SESSION_STATE.FAILED, queuedExecuteWi: null };
|
|
1207
1499
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1500
|
+
|
|
1501
|
+
// Caller-contract checks first: the chain helpers validate arguments BEFORE
|
|
1502
|
+
// any side effect, so a missing scope/qaRunId still throws rather than
|
|
1503
|
+
// half-applying a state change.
|
|
1504
|
+
const isAuto = session.spec.mode === 'auto';
|
|
1505
|
+
if (isAuto) {
|
|
1210
1506
|
if (!_isNonEmptyString(opts.scope)) {
|
|
1211
1507
|
throw new Error('qa-sessions: handleDraftComplete (auto) requires scope');
|
|
1212
1508
|
}
|
|
1213
1509
|
if (!_isNonEmptyString(opts.qaRunId)) {
|
|
1214
1510
|
throw new Error('qa-sessions: handleDraftComplete (auto) requires qaRunId');
|
|
1215
1511
|
}
|
|
1216
|
-
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Fail-closed visual-coverage gate. Deliberately BEFORE the mode branch: a
|
|
1515
|
+
// rejection must not queue EXECUTE and must not park the session for human
|
|
1516
|
+
// approval as if the draft were usable.
|
|
1517
|
+
const gate = visualJourney.validateVisualJourneyManifest(
|
|
1518
|
+
session,
|
|
1519
|
+
opts.manifest === undefined ? null : opts.manifest,
|
|
1520
|
+
{ services: opts.services },
|
|
1521
|
+
);
|
|
1522
|
+
if (!gate.ok) {
|
|
1523
|
+
markFailed(sessionId, {
|
|
1524
|
+
failureClass: visualJourney.VISUAL_COVERAGE_FAILURE_CLASS,
|
|
1525
|
+
error: gate.error,
|
|
1526
|
+
summary: `DRAFT rejected: visual-journey coverage incomplete (${gate.errors.length} issue${gate.errors.length === 1 ? '' : 's'})`,
|
|
1527
|
+
});
|
|
1528
|
+
log('warn', `qa-session ${sessionId} DRAFT rejected by the visual-journey gate: ${gate.error}`);
|
|
1529
|
+
return { nextState: QA_SESSION_STATE.FAILED, queuedExecuteWi: null };
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const testFilePatch = opts.testFile ? { testFile: opts.testFile } : {};
|
|
1533
|
+
// Only stamp a manifest when the draft actually declared one — a logs-only
|
|
1534
|
+
// session keeps its historical record shape.
|
|
1535
|
+
const draftPatch = gate.manifest
|
|
1536
|
+
? { ...testFilePatch, visualJourneyManifest: gate.manifest }
|
|
1537
|
+
: testFilePatch;
|
|
1538
|
+
if (isAuto) {
|
|
1539
|
+
markExecuting(sessionId, { ...draftPatch, qaRunId: opts.qaRunId });
|
|
1217
1540
|
const updated = getSession(sessionId);
|
|
1218
1541
|
const wi = buildExecuteWorkItem(updated, {
|
|
1219
1542
|
qaRunId: opts.qaRunId,
|
|
@@ -1224,20 +1547,72 @@ function handleDraftComplete(sessionId, opts = {}) {
|
|
|
1224
1547
|
return { nextState: QA_SESSION_STATE.EXECUTING, queuedExecuteWi: wi.id };
|
|
1225
1548
|
}
|
|
1226
1549
|
// confirm mode (default)
|
|
1227
|
-
markAwaitingApproval(sessionId,
|
|
1550
|
+
markAwaitingApproval(sessionId, draftPatch);
|
|
1228
1551
|
return { nextState: QA_SESSION_STATE.AWAITING_APPROVAL, queuedExecuteWi: null };
|
|
1229
1552
|
}
|
|
1230
1553
|
|
|
1554
|
+
/**
|
|
1555
|
+
* Resolve the EXECUTE-side evidence verdict for a session about to be marked
|
|
1556
|
+
* done (W-msb9kgs402813a97-c).
|
|
1557
|
+
*
|
|
1558
|
+
* Skipped entirely — `{ ok: true, required: false }` — when the session asked
|
|
1559
|
+
* for neither screenshots nor video, or when no DRAFT visual-journey manifest
|
|
1560
|
+
* was persisted (a legacy session predating the manifest). Otherwise the run
|
|
1561
|
+
* record and its artifacts directory are resolved and handed to the validator.
|
|
1562
|
+
*
|
|
1563
|
+
* An internal failure FAILS CLOSED: a gate that cannot prove evidence must not
|
|
1564
|
+
* wave the session through.
|
|
1565
|
+
*/
|
|
1566
|
+
function _evaluateExecuteEvidence(session, opts = {}) {
|
|
1567
|
+
const manifest = session && session.visualJourneyManifest;
|
|
1568
|
+
const hasManifest = !!manifest && typeof manifest === 'object' && !Array.isArray(manifest);
|
|
1569
|
+
if (!visualJourney.isVisualCaptureRequested(session) || !hasManifest) {
|
|
1570
|
+
return { ok: true, required: false };
|
|
1571
|
+
}
|
|
1572
|
+
try {
|
|
1573
|
+
const qaRuns = require('./runs');
|
|
1574
|
+
let run = opts.qaRun && typeof opts.qaRun === 'object' ? opts.qaRun : null;
|
|
1575
|
+
const runId = (run && run.id) || session.qaRunId || null;
|
|
1576
|
+
if (!run && _isNonEmptyString(runId)) {
|
|
1577
|
+
try { run = qaRuns.getRun(runId); } catch { run = null; }
|
|
1578
|
+
}
|
|
1579
|
+
let artifactsDir = null;
|
|
1580
|
+
if (_isNonEmptyString(runId)) {
|
|
1581
|
+
try { artifactsDir = qaRuns.qaArtifactsDirForRun(runId); } catch { artifactsDir = null; }
|
|
1582
|
+
}
|
|
1583
|
+
return visualJourney.validateEvidenceCoverage({
|
|
1584
|
+
session, manifest, run, artifactsDir, services: opts.services,
|
|
1585
|
+
});
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
return {
|
|
1588
|
+
ok: false,
|
|
1589
|
+
required: true,
|
|
1590
|
+
missing: { files: [], journeys: [], projects: [], services: [], evidenceTypes: [] },
|
|
1591
|
+
errors: [err.message],
|
|
1592
|
+
error: `evidence coverage could not be verified for session ${session.id}: ${err.message}`,
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1231
1597
|
/**
|
|
1232
1598
|
* Lifecycle hook: EXECUTE WI completed. The qa-runs record's terminal status
|
|
1233
1599
|
* is the source of truth for done vs failed — the qaRunId hook at
|
|
1234
|
-
* engine/lifecycle.js:4340 has already written it. We just read that record
|
|
1600
|
+
* engine/orchestration/lifecycle.js:4340 has already written it. We just read that record
|
|
1235
1601
|
* (when provided) and transition the session accordingly.
|
|
1236
1602
|
*
|
|
1603
|
+
* W-msb9kgs402813a97-c — a green qa-run is necessary but NOT sufficient for a
|
|
1604
|
+
* session that asked for screenshots or video. Before choosing DONE we run the
|
|
1605
|
+
* evidence-coverage validator: a run whose registered artifacts do not exist
|
|
1606
|
+
* on disk, whose evidence is missing a requested capture type, or whose passed
|
|
1607
|
+
* journeys cover only one of the session's projects/services fails with
|
|
1608
|
+
* `qa-session-evidence-incomplete` instead.
|
|
1609
|
+
*
|
|
1237
1610
|
* @param {string} sessionId
|
|
1238
1611
|
* @param {object} opts
|
|
1239
1612
|
* @param {boolean} opts.success - dispatch-level success (whether the agent exited 0)
|
|
1240
1613
|
* @param {string} [opts.qaRunStatus] - 'passed' | 'failed' | 'errored' (from qa-runs record)
|
|
1614
|
+
* @param {object} [opts.qaRun] - the already-read qa-runs record (avoids a second lookup)
|
|
1615
|
+
* @param {Array} [opts.services] - managed-spawn inventory override for the coverage check
|
|
1241
1616
|
* @param {string} [opts.summary]
|
|
1242
1617
|
* @param {string} [opts.reason]
|
|
1243
1618
|
*/
|
|
@@ -1266,6 +1641,17 @@ function handleExecuteComplete(sessionId, opts = {}) {
|
|
|
1266
1641
|
patch.failureClass = 'qa-session-execute-failed';
|
|
1267
1642
|
patch.error = opts.reason || 'EXECUTE phase failed';
|
|
1268
1643
|
}
|
|
1644
|
+
// Fail-closed evidence gate — deliberately BEFORE the transition, and only
|
|
1645
|
+
// on the DONE path so an already-failing run keeps its own failure class.
|
|
1646
|
+
if (toState === QA_SESSION_STATE.DONE) {
|
|
1647
|
+
const evidence = _evaluateExecuteEvidence(session, opts);
|
|
1648
|
+
if (evidence.required && !evidence.ok) {
|
|
1649
|
+
toState = QA_SESSION_STATE.FAILED;
|
|
1650
|
+
patch.failureClass = visualJourney.EVIDENCE_COVERAGE_FAILURE_CLASS;
|
|
1651
|
+
patch.error = evidence.error;
|
|
1652
|
+
log('warn', `qa-session ${sessionId} EXECUTE rejected by the evidence-coverage gate: ${evidence.error}`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1269
1655
|
transitionSession(sessionId, toState, patch);
|
|
1270
1656
|
return toState;
|
|
1271
1657
|
}
|
|
@@ -1307,9 +1693,10 @@ function queueApprovedDraft(sessionId, { scope, qaRunId, project } = {}) {
|
|
|
1307
1693
|
}
|
|
1308
1694
|
session.workItems[SESSION_PHASE.EXECUTE] = wi.id;
|
|
1309
1695
|
session.updatedAt = ts();
|
|
1310
|
-
_dispatchQueuedWorkItem(wi);
|
|
1311
1696
|
return sessions;
|
|
1312
1697
|
});
|
|
1698
|
+
// Outside the session lock — never take a second lock inside a mutator.
|
|
1699
|
+
_requestDispatchWakeup();
|
|
1313
1700
|
return wi.id;
|
|
1314
1701
|
}
|
|
1315
1702
|
|
|
@@ -1383,6 +1770,163 @@ function dismissSession(sessionId, { summary } = {}) {
|
|
|
1383
1770
|
});
|
|
1384
1771
|
}
|
|
1385
1772
|
|
|
1773
|
+
// ── Deletion (operator-facing, terminal-only) ──────────────────────────────
|
|
1774
|
+
|
|
1775
|
+
// W-msb4vgj500zbb008 — DELETE /api/qa/sessions/<id>. Sessions accumulate in
|
|
1776
|
+
// the dashboard's "Recent Sessions" list and, before this, the only way to
|
|
1777
|
+
// remove a failed record was hand-editing engine/state.db. Mirrors
|
|
1778
|
+
// engine/qa/runs.js#deleteQaRun: terminal-only, envelope-returning, the record
|
|
1779
|
+
// removed inside the SQL mutator (which emits the qa_sessions state event) and
|
|
1780
|
+
// every filesystem / cross-store side effect performed OUTSIDE the lock.
|
|
1781
|
+
//
|
|
1782
|
+
// Linked-resource ownership rules are deliberately conservative — a leaked
|
|
1783
|
+
// artifact costs nothing, a wrongly-deleted one destroys QA evidence:
|
|
1784
|
+
//
|
|
1785
|
+
// engine/qa-tests/<id>/ Session-owned by construction (createSession makes
|
|
1786
|
+
// it; the directory name IS the session id) → removed
|
|
1787
|
+
// with the same resolve + prefix sandbox deleteQaRun
|
|
1788
|
+
// applies to artifact directories. Never the root.
|
|
1789
|
+
// session.qaRunId Cascaded through qaRuns.deleteQaRun ONLY when the
|
|
1790
|
+
// run still exists AND carries the synthetic
|
|
1791
|
+
// `qa-session-<id>` runbookId stamped by the approve
|
|
1792
|
+
// endpoint (dashboard.js#handleQaSessionApprove) and
|
|
1793
|
+
// the auto-mode chain (lifecycle.js). A run owned by a
|
|
1794
|
+
// real runbook, a run already deleted, and a run that
|
|
1795
|
+
// is still pending/running are each left in place and
|
|
1796
|
+
// named in `qaRun.reason`. deleteQaRun's own
|
|
1797
|
+
// terminal-only guard is the second line of defence.
|
|
1798
|
+
// managed spawns NEVER touched. A QA managed-spawn can outlive its
|
|
1799
|
+
// session, and killing a live process as a side
|
|
1800
|
+
// effect of deleting a history record would be a
|
|
1801
|
+
// surprise. Live names are reported instead so the
|
|
1802
|
+
// operator can call POST /api/managed-processes/kill
|
|
1803
|
+
// deliberately.
|
|
1804
|
+
//
|
|
1805
|
+
// Repeat deletes are safe: the second call returns `not_found`, and a linked
|
|
1806
|
+
// run that a previous call (or a manual DELETE /api/qa/runs/<id>) already
|
|
1807
|
+
// removed reports `qaRun.reason: 'not_found'` rather than failing the request.
|
|
1808
|
+
const QA_SESSION_RUNBOOK_PREFIX = 'qa-session-';
|
|
1809
|
+
|
|
1810
|
+
function sessionOwnedRunbookId(sessionId) {
|
|
1811
|
+
return QA_SESSION_RUNBOOK_PREFIX + sessionId;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function _removeSessionTestDir(sessionId) {
|
|
1815
|
+
try {
|
|
1816
|
+
const base = path.resolve(qaTestsDir());
|
|
1817
|
+
const target = path.resolve(qaTestsDirForSession(sessionId));
|
|
1818
|
+
if (!target.startsWith(base + path.sep)) return false;
|
|
1819
|
+
if (!fs.existsSync(target)) return false;
|
|
1820
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
1821
|
+
return true;
|
|
1822
|
+
} catch (e) {
|
|
1823
|
+
log('warn', `qa-sessions: rm tests dir failed for ${sessionId}: ${e.message}`);
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function _deleteLinkedQaRun(session) {
|
|
1829
|
+
const runId = session && session.qaRunId;
|
|
1830
|
+
if (!_isNonEmptyString(runId)) {
|
|
1831
|
+
return { linked: false, id: null, deleted: false, reason: 'no_linked_run' };
|
|
1832
|
+
}
|
|
1833
|
+
let qaRuns;
|
|
1834
|
+
let run;
|
|
1835
|
+
try {
|
|
1836
|
+
qaRuns = require('./runs');
|
|
1837
|
+
run = qaRuns.getRun(runId);
|
|
1838
|
+
} catch (e) {
|
|
1839
|
+
log('warn', `qa-sessions: qa-run lookup failed for ${runId}: ${e.message}`);
|
|
1840
|
+
return { linked: true, id: runId, deleted: false, reason: 'lookup_failed' };
|
|
1841
|
+
}
|
|
1842
|
+
if (!run) return { linked: true, id: runId, deleted: false, reason: 'not_found' };
|
|
1843
|
+
if (run.runbookId !== sessionOwnedRunbookId(session.id)) {
|
|
1844
|
+
return { linked: true, id: runId, deleted: false, reason: 'not_session_owned' };
|
|
1845
|
+
}
|
|
1846
|
+
let result;
|
|
1847
|
+
try { result = qaRuns.deleteQaRun(runId); }
|
|
1848
|
+
catch (e) {
|
|
1849
|
+
log('warn', `qa-sessions: linked qa-run delete failed for ${runId}: ${e.message}`);
|
|
1850
|
+
return { linked: true, id: runId, deleted: false, reason: 'delete_failed' };
|
|
1851
|
+
}
|
|
1852
|
+
if (result && result.ok) {
|
|
1853
|
+
return {
|
|
1854
|
+
linked: true,
|
|
1855
|
+
id: runId,
|
|
1856
|
+
deleted: true,
|
|
1857
|
+
reason: null,
|
|
1858
|
+
artifactsRemoved: !!result.artifactsRemoved,
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
const out = {
|
|
1862
|
+
linked: true,
|
|
1863
|
+
id: runId,
|
|
1864
|
+
deleted: false,
|
|
1865
|
+
reason: (result && result.error) || 'delete_failed',
|
|
1866
|
+
};
|
|
1867
|
+
if (result && result.currentStatus) out.currentStatus = result.currentStatus;
|
|
1868
|
+
return out;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Report — never kill. `known: false` means the managed-spawn store could not
|
|
1872
|
+
// be read, so the empty list must not be read as "no live spawns".
|
|
1873
|
+
function _liveManagedSpawnsForSession(session) {
|
|
1874
|
+
try {
|
|
1875
|
+
const managedSpawn = require('../processes/managed-spawn');
|
|
1876
|
+
const live = [];
|
|
1877
|
+
for (const name of managedSpawnNamesForSession(session)) {
|
|
1878
|
+
if (managedSpawn.getManagedSpecByName(name)) live.push(name);
|
|
1879
|
+
}
|
|
1880
|
+
return { live, known: true };
|
|
1881
|
+
} catch (e) {
|
|
1882
|
+
log('warn', `qa-sessions: managed-spawn lookup failed for ${session && session.id}: ${e.message}`);
|
|
1883
|
+
return { live: [], known: false };
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/**
|
|
1888
|
+
* Hard-delete a terminal QA session record and clean up the resources it
|
|
1889
|
+
* provably owns.
|
|
1890
|
+
*
|
|
1891
|
+
* { ok: true, id, session, testDirRemoved, qaRun, managedSpawns }
|
|
1892
|
+
* { ok: false, error: 'invalid_id' | 'not_found' | 'not_terminal', currentState? }
|
|
1893
|
+
*
|
|
1894
|
+
* @param {string} id - session id (must pass _isSafeSessionId)
|
|
1895
|
+
* @returns {object} envelope (see above)
|
|
1896
|
+
*/
|
|
1897
|
+
function deleteSession(id) {
|
|
1898
|
+
if (!_isSafeSessionId(id)) return { ok: false, error: 'invalid_id' };
|
|
1899
|
+
|
|
1900
|
+
let captured = null;
|
|
1901
|
+
let notFound = false;
|
|
1902
|
+
let notTerminal = null;
|
|
1903
|
+
mutateQaSessions( (sessions) => {
|
|
1904
|
+
if (!Array.isArray(sessions)) return sessions;
|
|
1905
|
+
const idx = sessions.findIndex(s => s && s.id === id);
|
|
1906
|
+
if (idx < 0) { notFound = true; return sessions; }
|
|
1907
|
+
const session = sessions[idx];
|
|
1908
|
+
if (!TERMINAL_STATES.has(session.state)) {
|
|
1909
|
+
notTerminal = session.state;
|
|
1910
|
+
return sessions;
|
|
1911
|
+
}
|
|
1912
|
+
captured = session;
|
|
1913
|
+
sessions.splice(idx, 1);
|
|
1914
|
+
return sessions;
|
|
1915
|
+
}, { defaultValue: [] });
|
|
1916
|
+
|
|
1917
|
+
if (notFound) return { ok: false, error: 'not_found' };
|
|
1918
|
+
if (notTerminal !== null) return { ok: false, error: 'not_terminal', currentState: notTerminal };
|
|
1919
|
+
|
|
1920
|
+
return {
|
|
1921
|
+
ok: true,
|
|
1922
|
+
id,
|
|
1923
|
+
session: captured,
|
|
1924
|
+
testDirRemoved: _removeSessionTestDir(id),
|
|
1925
|
+
qaRun: _deleteLinkedQaRun(captured),
|
|
1926
|
+
managedSpawns: _liveManagedSpawnsForSession(captured),
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1386
1930
|
// ── Status summary (cheap, for /api/status fast-state slice) ───────────────
|
|
1387
1931
|
|
|
1388
1932
|
/**
|
|
@@ -1478,6 +2022,8 @@ module.exports = {
|
|
|
1478
2022
|
cancelSession,
|
|
1479
2023
|
killSession,
|
|
1480
2024
|
dismissSession,
|
|
2025
|
+
deleteSession,
|
|
2026
|
+
sessionOwnedRunbookId,
|
|
1481
2027
|
managedSpawnNamesForSession,
|
|
1482
2028
|
// Status
|
|
1483
2029
|
summarizeSessionsForStatus,
|