@yemi33/minions 0.1.2379 → 0.1.2380
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/minions.js +19 -9
- package/dashboard/js/refresh.js +2 -3
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard.js +393 -721
- package/docs/architecture-review-2026-07-09.md +2 -4
- package/docs/auto-discovery.md +36 -49
- package/docs/blog-first-successful-dispatch.md +1 -1
- package/docs/branch-derivation.md +2 -2
- package/docs/command-center.md +1 -1
- package/docs/completion-reports.md +14 -4
- package/docs/constellation-bridge.md +59 -10
- package/docs/constellation-style-telemetry.md +6 -6
- package/docs/cooldown-merge-semantics.md +4 -0
- package/docs/copilot-cli-schema.md +3 -3
- package/docs/cross-repo-plans.md +17 -17
- package/docs/deprecated.json +2 -2
- package/docs/design-state-storage.md +1 -1
- package/docs/documentation-audit-2026-07-09.md +2 -2
- package/docs/engine-restart.md +20 -8
- package/docs/harness-mode.md +1 -1
- package/docs/managed-spawn.md +4 -4
- package/docs/onboarding.md +1 -2
- package/docs/pr-comment-followup.md +3 -3
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
- package/docs/qa-runbook-lifecycle.md +4 -4
- package/docs/qa-runbooks.md +2 -2
- package/docs/rfc-completion-json.md +4 -1
- package/docs/runtime-adapters.md +1 -1
- package/docs/self-improvement.md +4 -5
- package/docs/shared-lifecycle-module-map.md +3 -1
- package/docs/slim-ux/architecture-suggestions.md +5 -6
- package/docs/slim-ux/concepts.md +23 -25
- package/docs/watches.md +7 -7
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +1 -1
- package/engine/abandoned-pr-reconciliation.js +4 -5
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +58 -1
- package/engine/bridge.js +260 -5
- package/engine/cleanup.js +48 -131
- package/engine/cli.js +125 -83
- package/engine/cooldown.js +9 -16
- package/engine/db/index.js +22 -9
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/020-qa-session-scopes.js +23 -0
- package/engine/db/migrations/021-archived-work-items.js +72 -0
- package/engine/db/migrations/022-global-cc-session.js +31 -0
- package/engine/db/migrations/023-engine-state.js +30 -0
- package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
- package/engine/dispatch-store.js +2 -7
- package/engine/dispatch.js +34 -41
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +221 -283
- package/engine/llm.js +1 -1
- package/engine/logs-store.js +2 -2
- package/engine/managed-spawn.js +2 -23
- package/engine/meeting.js +6 -6
- package/engine/metrics-store.js +2 -2
- package/engine/note-link-backfill.js +6 -11
- package/engine/pipeline.js +18 -36
- package/engine/playbook.js +1 -1
- package/engine/prd-store.js +73 -54
- package/engine/preflight.js +2 -5
- package/engine/projects.js +15 -62
- package/engine/pull-requests-store.js +0 -17
- package/engine/qa-runbooks.js +2 -2
- package/engine/qa-runs.js +1 -8
- package/engine/qa-sessions.js +41 -64
- package/engine/queries.js +120 -219
- package/engine/routing.js +3 -5
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +132 -637
- package/engine/small-state-store.js +89 -10
- package/engine/state-operations.js +16 -4
- package/engine/stdio-timestamps.js +1 -1
- package/engine/timeout.js +5 -12
- package/engine/watch-actions.js +20 -22
- package/engine/watches-store.js +1 -1
- package/engine/watches.js +6 -10
- package/engine/work-item-validation.js +52 -0
- package/engine/work-items-store.js +127 -29
- package/engine/worktree-gc.js +2 -2
- package/engine/worktree-pool.js +8 -18
- package/engine.js +147 -343
- package/minions.js +2 -2
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -2
- package/playbooks/shared-rules.md +3 -3
- package/playbooks/templates/followup-dispatch.md +1 -1
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +2 -2
package/engine/projects.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const shared = require('./shared');
|
|
11
11
|
const dispatch = require('./dispatch');
|
|
12
|
+
const queries = require('./queries');
|
|
12
13
|
const projectDiscovery = require('./project-discovery');
|
|
13
14
|
const { MINIONS_DIR } = shared;
|
|
14
15
|
|
|
@@ -86,9 +87,8 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
|
|
89
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
90
90
|
const ids = new Set();
|
|
91
|
-
const state =
|
|
91
|
+
const state = queries.getDispatch();
|
|
92
92
|
for (const queue of ['pending', 'active']) {
|
|
93
93
|
for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
|
|
94
94
|
if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
|
|
@@ -99,9 +99,8 @@ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
|
|
|
99
99
|
|
|
100
100
|
function _requeueProjectlessCentralWorkItems(itemIds) {
|
|
101
101
|
if (!itemIds || itemIds.size === 0) return 0;
|
|
102
|
-
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
103
102
|
let requeued = 0;
|
|
104
|
-
shared.mutateWorkItems(
|
|
103
|
+
shared.mutateWorkItems('central', items => {
|
|
105
104
|
if (!Array.isArray(items)) return items;
|
|
106
105
|
for (const w of items) {
|
|
107
106
|
if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
|
|
@@ -124,10 +123,7 @@ function _requeueProjectlessCentralWorkItems(itemIds) {
|
|
|
124
123
|
* @param {object} [options]
|
|
125
124
|
* @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
|
|
126
125
|
* archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
|
|
127
|
-
* keep: leave projects/<name>/ in place
|
|
128
|
-
* pull-requests.json into projects/<name>/.dropped/ so dropScope()
|
|
129
|
-
* (step 9) isn't immediately undone by the *-store.js resync-on-
|
|
130
|
-
* read path re-hydrating the still-present JSON (W-mr96oira000g979a)
|
|
126
|
+
* keep: leave projects/<name>/ in place
|
|
131
127
|
* purge: rm -rf projects/<name>/
|
|
132
128
|
* @returns {object} summary { ok, project, cancelledItems, killedAgents,
|
|
133
129
|
* drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
|
|
@@ -147,7 +143,6 @@ function removeProject(target, options = {}) {
|
|
|
147
143
|
pipelineRefs: [],
|
|
148
144
|
archivedTo: null,
|
|
149
145
|
purgedDataDir: false,
|
|
150
|
-
tombstonedDataFiles: false,
|
|
151
146
|
// P-bfa2d-remove-project-cleanup — soft-delete tag counts for entries in
|
|
152
147
|
// the three central state files that survived the hard-cancel passes
|
|
153
148
|
// (steps 1, 2). See step 7.5 for the orphan-tag policy rationale.
|
|
@@ -168,15 +163,14 @@ function removeProject(target, options = {}) {
|
|
|
168
163
|
}
|
|
169
164
|
summary.project = { name: project.name, localPath: project.localPath };
|
|
170
165
|
|
|
171
|
-
// 1. Cancel pending/queued work items linked to this project
|
|
172
|
-
// file + central). Done items are preserved as history.
|
|
166
|
+
// 1. Cancel pending/queued work items linked to this project.
|
|
173
167
|
summary.cancelledItems += dispatch.cancelPendingWorkItems(
|
|
174
|
-
|
|
168
|
+
project,
|
|
175
169
|
() => true,
|
|
176
170
|
'project-removed',
|
|
177
171
|
);
|
|
178
172
|
summary.cancelledItems += dispatch.cancelPendingWorkItems(
|
|
179
|
-
|
|
173
|
+
'central',
|
|
180
174
|
w => _workItemMatchesProject(w, project, projects),
|
|
181
175
|
'project-removed',
|
|
182
176
|
);
|
|
@@ -190,7 +184,7 @@ function removeProject(target, options = {}) {
|
|
|
190
184
|
_requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
|
|
191
185
|
|
|
192
186
|
// 2.5. Managed-spawn cleanup (P-8a4d6f29). Centralised in managed-spawn.js
|
|
193
|
-
// so no other module needs to know
|
|
187
|
+
// so no other module needs to know the managed-process storage schema.
|
|
194
188
|
// Kills + unlinks every spec owned by this project, including the .log
|
|
195
189
|
// and .log.1 rotated sibling. Best-effort: failures only warn.
|
|
196
190
|
try {
|
|
@@ -316,11 +310,8 @@ function removeProject(target, options = {}) {
|
|
|
316
310
|
} catch (e) { writeError = e; }
|
|
317
311
|
if (writeError) return { ...summary, error: 'Failed to write config: ' + writeError.message };
|
|
318
312
|
|
|
319
|
-
// 7.5.
|
|
320
|
-
//
|
|
321
|
-
// `engine/dispatch.json`) so downstream consumers can filter or sweep
|
|
322
|
-
// them. Project-local files in `projects/<name>/` are handled by
|
|
323
|
-
// step 8 (archive or purge), so this step only touches central state.
|
|
313
|
+
// 7.5. Tag dangling refs in central state so downstream consumers can filter
|
|
314
|
+
// or sweep them. Project-scoped rows are removed in step 9.
|
|
324
315
|
//
|
|
325
316
|
// Policy is soft-delete (`_orphaned_at` + `_orphaned_project` tags)
|
|
326
317
|
// rather than hard-delete: preserves history for debugging, lets users
|
|
@@ -330,17 +321,11 @@ function removeProject(target, options = {}) {
|
|
|
330
321
|
// survivors (non-pending WIs, central PRs, defense-in-depth dispatch
|
|
331
322
|
// survivors).
|
|
332
323
|
//
|
|
333
|
-
// Lock acquisition is sequential and uses the mutate-* helpers from
|
|
334
|
-
// engine/dispatch.js and engine/shared.js so we never hold two file
|
|
335
|
-
// locks simultaneously. Order: dispatch.json → pull-requests.json →
|
|
336
|
-
// work-items.json (alphabetical by basename). Each helper acquires
|
|
337
|
-
// its own lock, runs the synchronous mutator, then releases before
|
|
338
|
-
// the next helper runs.
|
|
339
324
|
try {
|
|
340
325
|
const orphanedAt = new Date().toISOString();
|
|
341
326
|
const orphanedProject = project.name;
|
|
342
327
|
|
|
343
|
-
// (a)
|
|
328
|
+
// (a) Dispatch state — defense-in-depth. cleanDispatchEntries in
|
|
344
329
|
// step 2 should have already removed every matching entry; tag any
|
|
345
330
|
// survivor so the operator can see the residue.
|
|
346
331
|
try {
|
|
@@ -361,11 +346,10 @@ function removeProject(target, options = {}) {
|
|
|
361
346
|
|
|
362
347
|
// (b) Central PR registry. PRs in this scope lack an explicit project field; match by canonical scope
|
|
363
348
|
// (e.g. `github:owner/repo`) against the removed project's PR scope.
|
|
364
|
-
const centralPrPath = shared.centralPullRequestsPath();
|
|
365
349
|
try {
|
|
366
350
|
const projectScope = shared.getProjectPrScope(project);
|
|
367
351
|
if (projectScope) {
|
|
368
|
-
shared.mutatePullRequests(
|
|
352
|
+
shared.mutatePullRequests('central', (prs) => {
|
|
369
353
|
for (const pr of prs) {
|
|
370
354
|
if (!pr || pr._orphaned_at) continue;
|
|
371
355
|
const canonical = shared.parseCanonicalPrId(pr.id);
|
|
@@ -383,9 +367,8 @@ function removeProject(target, options = {}) {
|
|
|
383
367
|
// were already CANCELLED by cancelPendingWorkItems in step 1; tag
|
|
384
368
|
// the remaining matchers (dispatched / done / failed / etc.) so
|
|
385
369
|
// downstream consumers can filter them.
|
|
386
|
-
const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
387
370
|
try {
|
|
388
|
-
shared.mutateWorkItems(
|
|
371
|
+
shared.mutateWorkItems('central', (items) => {
|
|
389
372
|
for (const w of items) {
|
|
390
373
|
if (!w || w._orphaned_at) continue;
|
|
391
374
|
if (w.status === shared.WI_STATUS.CANCELLED) continue;
|
|
@@ -424,40 +407,10 @@ function removeProject(target, options = {}) {
|
|
|
424
407
|
summary.archiveError = e.code ? `${e.code}: ${e.message}` : e.message;
|
|
425
408
|
summary.warnings.push('archive data dir: ' + e.message);
|
|
426
409
|
}
|
|
427
|
-
} else if (dataMode === 'keep') {
|
|
428
|
-
// W-mr96oira000g979a — 'keep' leaves projects/<name>/ (and therefore
|
|
429
|
-
// work-items.json / pull-requests.json) at its normal, live-resolving
|
|
430
|
-
// path. dropScope() below DELETEs the SQL rows for this scope, but the
|
|
431
|
-
// *-store.js resync-on-read path (_resyncScopeIfJsonDiverged) treats a
|
|
432
|
-
// scope with an empty SQL table + a still-present JSON file as a
|
|
433
|
-
// fresh-install first-touch and re-hydrates straight from that JSON —
|
|
434
|
-
// silently undoing the drop the very next time anything reads this
|
|
435
|
-
// scope. Tombstone the two live-state files into a `.dropped/`
|
|
436
|
-
// subdirectory so `_filePathForScope(scope)` (and the read-side
|
|
437
|
-
// resync/fallback logic keyed off it) no longer resolves to them,
|
|
438
|
-
// while keeping the historical data on disk for manual inspection —
|
|
439
|
-
// exactly what 'keep' promises, minus the auto-resurrection.
|
|
440
|
-
try {
|
|
441
|
-
const droppedDir = path.join(dataDir, '.dropped');
|
|
442
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
443
|
-
for (const fname of ['work-items.json', 'pull-requests.json']) {
|
|
444
|
-
const src = path.join(dataDir, fname);
|
|
445
|
-
if (!fs.existsSync(src)) continue;
|
|
446
|
-
fs.mkdirSync(droppedDir, { recursive: true });
|
|
447
|
-
let dest = path.join(droppedDir, `${fname}.${stamp}`);
|
|
448
|
-
let n = 1;
|
|
449
|
-
while (fs.existsSync(dest)) dest = path.join(droppedDir, `${fname}.${stamp}-${++n}`);
|
|
450
|
-
_renameWithRetry(src, dest);
|
|
451
|
-
summary.tombstonedDataFiles = true;
|
|
452
|
-
}
|
|
453
|
-
} catch (e) {
|
|
454
|
-
summary.warnings.push('tombstone kept data files: ' + e.message);
|
|
455
|
-
}
|
|
456
410
|
}
|
|
457
411
|
}
|
|
458
412
|
|
|
459
|
-
// 9. Drop SQL rows for the project
|
|
460
|
-
// surface the removed project after the JSON file is archived.
|
|
413
|
+
// 9. Drop SQL rows for the removed project scope.
|
|
461
414
|
try { require('./work-items-store').dropScope(project.name); } catch { /* best-effort */ }
|
|
462
415
|
try { require('./pull-requests-store').dropScope(project.name); } catch { /* best-effort */ }
|
|
463
416
|
|
|
@@ -571,7 +524,7 @@ async function addProject(target, options = {}) {
|
|
|
571
524
|
assertProjectLinkOk(project);
|
|
572
525
|
|
|
573
526
|
// 7. Ensure project state files (projects/<name>/...).
|
|
574
|
-
shared.
|
|
527
|
+
shared.initializeProjectState(project);
|
|
575
528
|
|
|
576
529
|
// 8. Persist under config lock. Re-check inside the lock so a concurrent
|
|
577
530
|
// add can't insert a duplicate between our pre-check and the write.
|
|
@@ -13,21 +13,6 @@ function _toMs(v) {
|
|
|
13
13
|
return Number.isFinite(parsed) ? parsed : null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function scopeForFilePath(filePath) {
|
|
17
|
-
const norm = String(filePath).replace(/\\/g, '/');
|
|
18
|
-
const m = norm.match(/projects\/([^/]+)\/pull-requests\.json$/);
|
|
19
|
-
if (m) return m[1];
|
|
20
|
-
return 'central';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Compatibility adapter for callers that still pass a path-shaped scope token
|
|
24
|
-
// into shared.mutatePullRequests. No file is read or written.
|
|
25
|
-
function _filePathForScope(scope) {
|
|
26
|
-
const shared = require('./shared');
|
|
27
|
-
if (!scope || scope === 'central') return shared.centralPullRequestsPath(shared.MINIONS_DIR);
|
|
28
|
-
return require('path').join(shared.MINIONS_DIR, 'projects', scope, 'pull-requests.json');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
16
|
function _parseRow(row) {
|
|
32
17
|
if (!row || !row.data) return null;
|
|
33
18
|
try { return JSON.parse(row.data); }
|
|
@@ -169,8 +154,6 @@ function dropScope(scope) {
|
|
|
169
154
|
}
|
|
170
155
|
|
|
171
156
|
module.exports = {
|
|
172
|
-
_filePathForScope,
|
|
173
|
-
scopeForFilePath,
|
|
174
157
|
readPullRequestsForScope,
|
|
175
158
|
readAllPullRequests,
|
|
176
159
|
applyPullRequestsMutation,
|
package/engine/qa-runbooks.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
|
|
5
5
|
* that travel with a project entry, mirroring the
|
|
6
|
-
*
|
|
6
|
+
* project-local definition precedent. Each runbook is one JSON file
|
|
7
7
|
* at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
|
|
8
8
|
*
|
|
9
9
|
* Pure persistence + validation only — this module does NOT spawn agents,
|
|
@@ -27,7 +27,7 @@ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
27
27
|
// keep this module self-contained for path-traversal hardening (review feedback
|
|
28
28
|
// on PR #2694: id/project params previously flowed into path.join without
|
|
29
29
|
// validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
|
|
30
|
-
// and DELETE could wipe
|
|
30
|
+
// and DELETE could wipe unrelated runtime state).
|
|
31
31
|
const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
|
|
32
32
|
|
|
33
33
|
const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
|
package/engine/qa-runs.js
CHANGED
|
@@ -33,7 +33,7 @@ const { mutateQaRuns, uid, ts, log } = shared;
|
|
|
33
33
|
|
|
34
34
|
// Cap qa_runs so the table doesn't grow unboundedly
|
|
35
35
|
// over months of nightly QA dispatch. Mirrors the 2500-entry cap on
|
|
36
|
-
// engine
|
|
36
|
+
// the engine log. createRun trims oldest-by-createdAt when crossing the
|
|
37
37
|
// threshold; terminal-status runs that have already shipped completion
|
|
38
38
|
// notifications are safe to drop.
|
|
39
39
|
const QA_RUNS_MAX_RECORDS = 2000;
|
|
@@ -80,12 +80,6 @@ const ALLOWED_TRANSITIONS = {
|
|
|
80
80
|
[QA_RUN_STATUS.ERRORED]: new Set(),
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
// Dynamic paths — respect MINIONS_TEST_DIR for test isolation. Kept exported
|
|
84
|
-
// for back-compat with tests that probe the JSON sidecar on disk.
|
|
85
|
-
function qaRunsPath() {
|
|
86
|
-
return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
83
|
function qaArtifactsDir() {
|
|
90
84
|
return path.join(shared.MINIONS_DIR, 'engine', 'qa-artifacts');
|
|
91
85
|
}
|
|
@@ -416,7 +410,6 @@ module.exports = {
|
|
|
416
410
|
QA_RUN_STATUS,
|
|
417
411
|
TERMINAL_STATUSES,
|
|
418
412
|
ALLOWED_TRANSITIONS,
|
|
419
|
-
qaRunsPath,
|
|
420
413
|
qaArtifactsDir,
|
|
421
414
|
qaArtifactsDirForRun,
|
|
422
415
|
createRun,
|
package/engine/qa-sessions.js
CHANGED
|
@@ -164,13 +164,6 @@ function _isSafeSessionId(id) {
|
|
|
164
164
|
return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Dynamic paths — respect MINIONS_TEST_DIR for test isolation. shared.MINIONS_DIR
|
|
168
|
-
// resolves at every call so MINIONS_TEST_DIR=foo flips the resolution
|
|
169
|
-
// without re-requiring this module (mirrors qa-runs.js pattern).
|
|
170
|
-
function qaSessionsPath() {
|
|
171
|
-
return path.join(shared.MINIONS_DIR, 'engine', 'qa-sessions.json');
|
|
172
|
-
}
|
|
173
|
-
|
|
174
167
|
function qaTestsDir() {
|
|
175
168
|
return path.join(shared.MINIONS_DIR, 'engine', 'qa-tests');
|
|
176
169
|
}
|
|
@@ -332,11 +325,11 @@ function validateSpec(spec) {
|
|
|
332
325
|
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
333
326
|
|
|
334
327
|
/**
|
|
335
|
-
* Create a session in `pending` state and persist it to
|
|
328
|
+
* Create a session in `pending` state and persist it to SQL.
|
|
336
329
|
*
|
|
337
330
|
* The caller (POST /api/qa/session handler) is responsible for queuing the
|
|
338
331
|
* SETUP work item via buildSetupWorkItem() + the standard work-items/dispatch
|
|
339
|
-
* flow. createSession() intentionally does
|
|
332
|
+
* flow. createSession() intentionally does not touch dispatch state so the
|
|
340
333
|
* pure persistence layer stays unit-testable without standing up the whole
|
|
341
334
|
* engine.
|
|
342
335
|
*
|
|
@@ -407,10 +400,8 @@ function createSession(spec) {
|
|
|
407
400
|
// 1 (multi-project fan-out). Single-project sessions skip this map and
|
|
408
401
|
// use session.state directly.
|
|
409
402
|
setupStatus: null,
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
// project through dashboard config.
|
|
413
|
-
primaryWiPath: null,
|
|
403
|
+
// Stamped at queueSetup time so DRAFT stays in the primary scope.
|
|
404
|
+
primaryScope: null,
|
|
414
405
|
// Per-phase WI links — back-filled by setSessionWorkItem when the
|
|
415
406
|
// dashboard endpoint or lifecycle hook queues the next phase.
|
|
416
407
|
workItems: { setup: null, draft: null, execute: null },
|
|
@@ -641,10 +632,10 @@ function transitionSession(id, toState, patch = {}) {
|
|
|
641
632
|
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
642
633
|
// Whitelist mutable fields to keep transitionSession from rewriting
|
|
643
634
|
// immutable spec/createdAt fields by mistake.
|
|
644
|
-
// W-mpq6xqzj000606d0 —
|
|
635
|
+
// W-mpq6xqzj000606d0 — `setupStatus` and `primaryScope` let the
|
|
645
636
|
// multi-project fan-in handler can stamp per-project completion state
|
|
646
637
|
// through the same locked path used by every other mutation.
|
|
647
|
-
for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', '
|
|
638
|
+
for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryScope']) {
|
|
648
639
|
if (Object.prototype.hasOwnProperty.call(patch, field)) {
|
|
649
640
|
session[field] = patch[field];
|
|
650
641
|
}
|
|
@@ -761,7 +752,7 @@ function _baseWorkItem(session, phase, { title, description, project, primary, c
|
|
|
761
752
|
* W-mpq6xqzj000606d0 — When the session has multiple projects (fan-out),
|
|
762
753
|
* `primary` distinguishes the orchestrator WI (true; carries the full
|
|
763
754
|
* coServices list) from each co-service WI (false; coServices === []). The
|
|
764
|
-
* session's DRAFT phase keys off the primary
|
|
755
|
+
* session's DRAFT phase keys off the primary scope.
|
|
765
756
|
*/
|
|
766
757
|
function buildSetupWorkItem(session, { project, primary, coServices, primaryProject } = {}) {
|
|
767
758
|
const isPrimary = primary === undefined ? true : !!primary;
|
|
@@ -881,12 +872,8 @@ function _summarizeTarget(target) {
|
|
|
881
872
|
// dispatch + work-items are lazy-required inside _queueWorkItem to keep
|
|
882
873
|
// `require('./qa-sessions')` cycle-safe at the top of lifecycle.js.
|
|
883
874
|
|
|
884
|
-
function _queueWorkItem(wi,
|
|
885
|
-
|
|
886
|
-
// dispatch entry that wraps it. Mirrors the qa-validate flow at
|
|
887
|
-
// dashboard.js handleQaRunbookRun (line 9985+). Both writes go through their
|
|
888
|
-
// module-internal locks so concurrent dashboard calls don't lose entries.
|
|
889
|
-
shared.mutateWorkItems(wiPath, (items) => {
|
|
875
|
+
function _queueWorkItem(wi, scope) {
|
|
876
|
+
shared.mutateWorkItems(scope, (items) => {
|
|
890
877
|
if (!Array.isArray(items)) items = [];
|
|
891
878
|
if (!items.some(i => i && i.id === wi.id)) items.push(wi);
|
|
892
879
|
return items;
|
|
@@ -905,35 +892,27 @@ function _queueWorkItem(wi, wiPath) {
|
|
|
905
892
|
* Validates pending → spawning, queues the SETUP WI(s), returns the queued
|
|
906
893
|
* primary WI id (back-compat).
|
|
907
894
|
*
|
|
908
|
-
* W-mpq6xqzj000606d0 — multi-project fan-out.
|
|
909
|
-
*
|
|
910
|
-
* 1. Legacy single-target: `{ wiPath, project }` (project may be null for
|
|
911
|
-
* central). Builds and queues one SETUP WI exactly like before.
|
|
912
|
-
* 2. Multi-target: `{ resolvedTargets: [{ project, wiPath }, ...] }`. The
|
|
913
|
-
* FIRST entry is treated as the primary (drives DRAFT/EXECUTE later);
|
|
914
|
-
* the rest are co-services. One SETUP WI is queued per entry, and
|
|
915
|
-
* `session.setupStatus` is initialized with a `pending` entry per
|
|
916
|
-
* project so handleSetupComplete can fan-in.
|
|
895
|
+
* W-mpq6xqzj000606d0 — multi-project fan-out. The first target drives
|
|
896
|
+
* DRAFT/EXECUTE; the rest are co-services.
|
|
917
897
|
*
|
|
918
898
|
* @param {string} sessionId
|
|
919
899
|
* @param {object} opts
|
|
920
|
-
* @param {string} [opts.
|
|
921
|
-
* @param {string} [opts.project]
|
|
922
|
-
* @param {Array<{project:?string,
|
|
900
|
+
* @param {string} [opts.scope]
|
|
901
|
+
* @param {string} [opts.project]
|
|
902
|
+
* @param {Array<{project:?string, scope:string}>} [opts.resolvedTargets]
|
|
923
903
|
* @returns {string} the queued PRIMARY SETUP WI id
|
|
924
904
|
*/
|
|
925
905
|
function queueSetup(sessionId, opts = {}) {
|
|
926
906
|
let targets = Array.isArray(opts.resolvedTargets) ? opts.resolvedTargets.slice() : null;
|
|
927
907
|
if (!targets || targets.length === 0) {
|
|
928
|
-
if (!_isNonEmptyString(opts.
|
|
929
|
-
throw new Error('qa-sessions: queueSetup requires
|
|
908
|
+
if (!_isNonEmptyString(opts.scope)) {
|
|
909
|
+
throw new Error('qa-sessions: queueSetup requires scope or resolvedTargets');
|
|
930
910
|
}
|
|
931
|
-
targets = [{ project: opts.project || null,
|
|
911
|
+
targets = [{ project: opts.project || null, scope: opts.scope }];
|
|
932
912
|
}
|
|
933
|
-
// Defensive: each target needs a wiPath. project may be null for central.
|
|
934
913
|
for (const t of targets) {
|
|
935
|
-
if (!t || !_isNonEmptyString(t.
|
|
936
|
-
throw new Error('qa-sessions: queueSetup target missing
|
|
914
|
+
if (!t || !_isNonEmptyString(t.scope)) {
|
|
915
|
+
throw new Error('qa-sessions: queueSetup target missing scope');
|
|
937
916
|
}
|
|
938
917
|
}
|
|
939
918
|
if (targets.length > LIMITS.projectsMax) {
|
|
@@ -947,7 +926,7 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
947
926
|
const coServiceProjects = targets.slice(1).map(t => t.project || null).filter(Boolean);
|
|
948
927
|
const isMulti = targets.length > 1;
|
|
949
928
|
|
|
950
|
-
// Initialize setupStatus +
|
|
929
|
+
// Initialize setupStatus + primaryScope BEFORE transitioning so the
|
|
951
930
|
// fan-in handler sees the full project list even on a racing completion.
|
|
952
931
|
// Single-project sessions skip the map to keep blast radius minimal.
|
|
953
932
|
const setupStatusPatch = isMulti
|
|
@@ -964,7 +943,7 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
964
943
|
// double-queueing.
|
|
965
944
|
markSpawning(sessionId, {
|
|
966
945
|
setupStatus: setupStatusPatch,
|
|
967
|
-
|
|
946
|
+
primaryScope: primaryTarget.scope,
|
|
968
947
|
});
|
|
969
948
|
|
|
970
949
|
// Build + queue one WI per target. Primary is index 0; coServices is the
|
|
@@ -981,7 +960,7 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
981
960
|
coServices: isPrimary ? coServiceProjects : [],
|
|
982
961
|
primaryProject: primaryTarget.project || null,
|
|
983
962
|
});
|
|
984
|
-
_queueWorkItem(wi, t.
|
|
963
|
+
_queueWorkItem(wi, t.scope);
|
|
985
964
|
builtWiIds[t.project || '__central__'] = wi.id;
|
|
986
965
|
if (isPrimary) primaryWiId = wi.id;
|
|
987
966
|
}
|
|
@@ -1026,7 +1005,7 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
1026
1005
|
* @param {string} sessionId
|
|
1027
1006
|
* @param {object} opts
|
|
1028
1007
|
* @param {boolean} opts.success
|
|
1029
|
-
* @param {string} [opts.
|
|
1008
|
+
* @param {string} [opts.scope] - required when success=true
|
|
1030
1009
|
* @param {string} [opts.project] - which project the completing WI was for (multi-project routing key)
|
|
1031
1010
|
* @param {string} [opts.failureClass]
|
|
1032
1011
|
* @param {string} [opts.reason]
|
|
@@ -1043,13 +1022,13 @@ function handleSetupComplete(sessionId, opts = {}) {
|
|
|
1043
1022
|
// ── Single-project fast path: original behavior, untouched semantics ──
|
|
1044
1023
|
if (!isMulti) {
|
|
1045
1024
|
if (opts.success) {
|
|
1046
|
-
if (!_isNonEmptyString(opts.
|
|
1047
|
-
throw new Error('qa-sessions: handleSetupComplete success requires
|
|
1025
|
+
if (!_isNonEmptyString(opts.scope)) {
|
|
1026
|
+
throw new Error('qa-sessions: handleSetupComplete success requires scope');
|
|
1048
1027
|
}
|
|
1049
1028
|
markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
|
|
1050
1029
|
const updated = getSession(sessionId);
|
|
1051
1030
|
const wi = buildDraftWorkItem(updated, { project: opts.project || updated.spec.project || null });
|
|
1052
|
-
_queueWorkItem(wi, opts.
|
|
1031
|
+
_queueWorkItem(wi, opts.scope);
|
|
1053
1032
|
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
1054
1033
|
return wi.id;
|
|
1055
1034
|
}
|
|
@@ -1112,17 +1091,16 @@ function handleSetupComplete(sessionId, opts = {}) {
|
|
|
1112
1091
|
return null;
|
|
1113
1092
|
}
|
|
1114
1093
|
|
|
1115
|
-
// All success →
|
|
1116
|
-
// wiPath (captured at queueSetup time).
|
|
1094
|
+
// All success → queue DRAFT on the primary scope captured by queueSetup.
|
|
1117
1095
|
const fresh = getSession(sessionId);
|
|
1118
|
-
const
|
|
1119
|
-
if (!_isNonEmptyString(
|
|
1120
|
-
throw new Error('qa-sessions: handleSetupComplete multi-project missing session.
|
|
1096
|
+
const draftScope = fresh && fresh.primaryScope;
|
|
1097
|
+
if (!_isNonEmptyString(draftScope)) {
|
|
1098
|
+
throw new Error('qa-sessions: handleSetupComplete multi-project missing session.primaryScope');
|
|
1121
1099
|
}
|
|
1122
1100
|
markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
|
|
1123
1101
|
const updated = getSession(sessionId);
|
|
1124
1102
|
const wi = buildDraftWorkItem(updated, { project: updated.primaryProject || updated.spec.project || null });
|
|
1125
|
-
_queueWorkItem(wi,
|
|
1103
|
+
_queueWorkItem(wi, draftScope);
|
|
1126
1104
|
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
1127
1105
|
return wi.id;
|
|
1128
1106
|
}
|
|
@@ -1136,7 +1114,7 @@ function handleSetupComplete(sessionId, opts = {}) {
|
|
|
1136
1114
|
* @param {object} opts
|
|
1137
1115
|
* @param {boolean} opts.success
|
|
1138
1116
|
* @param {string} [opts.testFile] - relative path under qa-tests/<id>/, captured for EXECUTE
|
|
1139
|
-
* @param {string} [opts.
|
|
1117
|
+
* @param {string} [opts.scope] - required when success=true and mode=auto
|
|
1140
1118
|
* @param {string} [opts.project]
|
|
1141
1119
|
* @param {string} [opts.qaRunId] - required when success=true and mode=auto (caller creates the qa-runs record)
|
|
1142
1120
|
* @param {string} [opts.reason]
|
|
@@ -1158,8 +1136,8 @@ function handleDraftComplete(sessionId, opts = {}) {
|
|
|
1158
1136
|
}
|
|
1159
1137
|
const testFilePatch = opts.testFile ? { testFile: opts.testFile } : {};
|
|
1160
1138
|
if (session.spec.mode === 'auto') {
|
|
1161
|
-
if (!_isNonEmptyString(opts.
|
|
1162
|
-
throw new Error('qa-sessions: handleDraftComplete (auto) requires
|
|
1139
|
+
if (!_isNonEmptyString(opts.scope)) {
|
|
1140
|
+
throw new Error('qa-sessions: handleDraftComplete (auto) requires scope');
|
|
1163
1141
|
}
|
|
1164
1142
|
if (!_isNonEmptyString(opts.qaRunId)) {
|
|
1165
1143
|
throw new Error('qa-sessions: handleDraftComplete (auto) requires qaRunId');
|
|
@@ -1170,7 +1148,7 @@ function handleDraftComplete(sessionId, opts = {}) {
|
|
|
1170
1148
|
qaRunId: opts.qaRunId,
|
|
1171
1149
|
project: opts.project || updated.spec.project || null,
|
|
1172
1150
|
});
|
|
1173
|
-
_queueWorkItem(wi, opts.
|
|
1151
|
+
_queueWorkItem(wi, opts.scope);
|
|
1174
1152
|
setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
|
|
1175
1153
|
return { nextState: QA_SESSION_STATE.EXECUTING, queuedExecuteWi: wi.id };
|
|
1176
1154
|
}
|
|
@@ -1227,8 +1205,8 @@ function handleExecuteComplete(sessionId, opts = {}) {
|
|
|
1227
1205
|
* POST /api/qa/sessions/<id>/approve — awaiting-approval → executing, queues
|
|
1228
1206
|
* the EXECUTE WI. Caller creates the qa-runs record and passes its id.
|
|
1229
1207
|
*/
|
|
1230
|
-
function approveDraft(sessionId, {
|
|
1231
|
-
if (!_isNonEmptyString(
|
|
1208
|
+
function approveDraft(sessionId, { scope, qaRunId, project } = {}) {
|
|
1209
|
+
if (!_isNonEmptyString(scope)) throw new Error('qa-sessions: approveDraft requires scope');
|
|
1232
1210
|
if (!_isNonEmptyString(qaRunId)) throw new Error('qa-sessions: approveDraft requires qaRunId');
|
|
1233
1211
|
const session = getSession(sessionId);
|
|
1234
1212
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
@@ -1241,7 +1219,7 @@ function approveDraft(sessionId, { wiPath, qaRunId, project } = {}) {
|
|
|
1241
1219
|
qaRunId,
|
|
1242
1220
|
project: project || updated.spec.project || null,
|
|
1243
1221
|
});
|
|
1244
|
-
_queueWorkItem(wi,
|
|
1222
|
+
_queueWorkItem(wi, scope);
|
|
1245
1223
|
setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
|
|
1246
1224
|
return wi.id;
|
|
1247
1225
|
}
|
|
@@ -1250,8 +1228,8 @@ function approveDraft(sessionId, { wiPath, qaRunId, project } = {}) {
|
|
|
1250
1228
|
* POST /api/qa/sessions/<id>/edit — awaiting-approval → drafting, re-queue the
|
|
1251
1229
|
* DRAFT WI with the user's natural-language feedback threaded into the prompt.
|
|
1252
1230
|
*/
|
|
1253
|
-
function editDraft(sessionId, {
|
|
1254
|
-
if (!_isNonEmptyString(
|
|
1231
|
+
function editDraft(sessionId, { scope, feedback, project } = {}) {
|
|
1232
|
+
if (!_isNonEmptyString(scope)) throw new Error('qa-sessions: editDraft requires scope');
|
|
1255
1233
|
if (!_isNonEmptyString(feedback)) throw new Error('qa-sessions: editDraft requires feedback');
|
|
1256
1234
|
if (feedback.length > LIMITS.feedbackMax) {
|
|
1257
1235
|
throw new Error(`qa-sessions: editDraft feedback exceeds ${LIMITS.feedbackMax} chars`);
|
|
@@ -1267,7 +1245,7 @@ function editDraft(sessionId, { wiPath, feedback, project } = {}) {
|
|
|
1267
1245
|
project: project || updated.spec.project || null,
|
|
1268
1246
|
feedback,
|
|
1269
1247
|
});
|
|
1270
|
-
_queueWorkItem(wi,
|
|
1248
|
+
_queueWorkItem(wi, scope);
|
|
1271
1249
|
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
1272
1250
|
return wi.id;
|
|
1273
1251
|
}
|
|
@@ -1365,7 +1343,6 @@ module.exports = {
|
|
|
1365
1343
|
LIMITS,
|
|
1366
1344
|
QA_SESSIONS_MAX_RECORDS,
|
|
1367
1345
|
// Paths
|
|
1368
|
-
qaSessionsPath,
|
|
1369
1346
|
qaTestsDir,
|
|
1370
1347
|
qaTestsDirForSession,
|
|
1371
1348
|
// Validation
|