@yemi33/minions 0.1.2423 → 0.1.2425

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -61,13 +61,15 @@ function generateBrief(opts) {
61
61
  const baseUrl = _spawnUrl(opts.spawnInfo);
62
62
  const flows = opts.flowsRaw || '(no flows provided)';
63
63
  const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
64
+ const testDir = opts.testDir || `engine/qa-tests/${sessionId}`;
65
+ const testFile = opts.testFile || path.join(testDir, 'flow.yaml');
64
66
  const capture = opts.capture || {};
65
67
  const captureSummary = Object.entries(capture).filter(([, v]) => !!v).map(([k]) => k).join(', ') || '(none requested)';
66
68
 
67
69
  return [
68
70
  '## Maestro test draft',
69
71
  '',
70
- `Write a single Maestro flow file at \`engine/qa-tests/${sessionId}/flow.yaml\` that exercises:`,
72
+ `Write a single Maestro flow file at \`${testFile}\` that exercises:`,
71
73
  '',
72
74
  flows.trim(),
73
75
  '',
@@ -92,12 +94,14 @@ function generateBrief(opts) {
92
94
  function executeBrief(opts) {
93
95
  opts = opts || {};
94
96
  const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
95
- const testFile = opts.testFile || `engine/qa-tests/${sessionId}/flow.yaml`;
97
+ const testDir = opts.testDir || `engine/qa-tests/${sessionId}`;
98
+ const testFile = opts.testFile || path.join(testDir, 'flow.yaml');
96
99
  const capture = opts.capture || {};
97
- const outputDir = `engine/qa-artifacts/${sessionId}`;
100
+ const outputDir = opts.artifactsDir || `engine/qa-artifacts/${sessionId}`;
101
+ const resultSidecar = opts.resultSidecar || 'agents/<your-id>/qa-run-result.json';
98
102
 
99
- const cmdFlags = [`--output ${outputDir}`];
100
- if (capture.logs) cmdFlags.push(`--debug-output ${outputDir}/maestro-debug`);
103
+ const cmdFlags = [`--output "${outputDir}"`];
104
+ if (capture.logs) cmdFlags.push(`--debug-output "${path.join(outputDir, 'maestro-debug')}"`);
101
105
  // `maestro test` does not support video; surface the limitation in the
102
106
  // brief body rather than silently dropping the capture request.
103
107
 
@@ -107,10 +111,10 @@ function executeBrief(opts) {
107
111
  'Run the drafted flow against the live managed-spawn:',
108
112
  '',
109
113
  '```bash',
110
- `maestro test ${testFile} ${cmdFlags.join(' ')}`,
114
+ `maestro test "${testFile}" ${cmdFlags.join(' ')}`,
111
115
  '```',
112
116
  '',
113
- `Write the result sidecar to \`agents/<your-id>/qa-run-result.json\` referencing artifacts under \`${outputDir}/\`.`,
117
+ `Write the result sidecar to \`${resultSidecar}\` after storing artifacts under \`${outputDir}\`.`,
114
118
  capture.video
115
119
  ? '\nVideo capture was requested — note in the result summary that `maestro test` does not record video and the human will need a separate `maestro record` pass if they want it.'
116
120
  : '',
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  const fs = require('fs');
19
+ const path = require('path');
19
20
 
20
21
  const NAME = 'playwright';
21
22
  const PRIORITY = 50;
@@ -48,20 +49,25 @@ function generateBrief(opts) {
48
49
  const ports = _portsLabel(opts.spawnInfo);
49
50
  const flows = opts.flowsRaw || '(no flows provided)';
50
51
  const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
52
+ const testDir = opts.testDir || `engine/qa-tests/${sessionId}`;
53
+ const testFile = opts.testFile || path.join(testDir, 'test.spec.js');
51
54
  const capture = opts.capture || {};
52
55
  const captureSummary = Object.entries(capture).filter(([, v]) => !!v).map(([k]) => k).join(', ') || '(none requested)';
53
56
 
54
57
  return [
55
58
  '## Playwright test draft',
56
59
  '',
57
- `Write a single Playwright test at \`engine/qa-tests/${sessionId}/test.spec.js\` that exercises:`,
60
+ `Write a single Playwright test at \`${testFile}\` that exercises:`,
58
61
  '',
59
62
  flows.trim(),
60
63
  '',
61
64
  '### Required structure',
62
65
  '',
63
66
  '```js',
64
- "const { test, expect } = require('@playwright/test');",
67
+ "const { createRequire } = require('module');",
68
+ "const path = require('path');",
69
+ "const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));",
70
+ "const { test, expect } = projectRequire('@playwright/test');",
65
71
  '',
66
72
  "test('qa-session flow', async ({ page }) => {",
67
73
  baseUrl
@@ -79,22 +85,30 @@ function generateBrief(opts) {
79
85
  '',
80
86
  '### Capture configuration',
81
87
  '',
82
- `Requested artifacts: ${captureSummary}. The EXECUTE phase wires \`--video\`, \`--screenshot\`, and trace flags ` +
83
- 'into the `npx playwright test` invocation. The test file itself should NOT hard-code recording (let the CLI flags drive it).',
88
+ `Requested artifacts: ${captureSummary}. The EXECUTE phase writes Playwright \`use.video\`, ` +
89
+ '`use.screenshot`, and `use.trace` options into its runner config. The test file itself should NOT hard-code recording.',
84
90
  ].join('\n');
85
91
  }
86
92
 
87
93
  function executeBrief(opts) {
88
94
  opts = opts || {};
89
95
  const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
90
- const testFile = opts.testFile || `engine/qa-tests/${sessionId}/test.spec.js`;
96
+ const testDir = opts.testDir || `engine/qa-tests/${sessionId}`;
97
+ const testFile = opts.testFile || path.join(testDir, 'test.spec.js');
91
98
  const capture = opts.capture || {};
92
- const outputDir = `engine/qa-artifacts/${sessionId}`;
93
-
94
- const flags = [`--output ${outputDir}`, '--reporter=json'];
95
- if (capture.video) flags.push('--video=on');
96
- if (capture.screenshots) flags.push('--screenshot=on');
97
- if (capture.logs) flags.push('--trace=on');
99
+ const outputDir = opts.artifactsDir || `engine/qa-artifacts/${sessionId}`;
100
+ const resultSidecar = opts.resultSidecar || 'agents/<your-id>/qa-run-result.json';
101
+ const configPath = path.join(testDir, 'playwright.config.cjs');
102
+ const use = {};
103
+ if (capture.video) use.video = 'on';
104
+ if (capture.screenshots) use.screenshot = 'on';
105
+ if (capture.logs) use.trace = 'on';
106
+ const configSource = `module.exports = ${JSON.stringify({
107
+ testDir,
108
+ outputDir,
109
+ reporter: 'json',
110
+ use,
111
+ }, null, 2)};`;
98
112
 
99
113
  const artifactTypes = [];
100
114
  if (capture.video) artifactTypes.push('`video` (webm)');
@@ -105,13 +119,22 @@ function executeBrief(opts) {
105
119
  return [
106
120
  '## Playwright execute',
107
121
  '',
108
- 'Run the drafted test against the live managed-spawn:',
122
+ `Write this Playwright config to \`${configPath}\`:`,
123
+ '',
124
+ '```js',
125
+ configSource,
126
+ '```',
127
+ '',
128
+ 'Then run the drafted test from the target project worktree against the live managed-spawn:',
109
129
  '',
110
130
  '```bash',
111
- `npx playwright test ${testFile} ${flags.join(' ')}`,
131
+ `npx playwright test --config "${configPath}"`,
112
132
  '```',
113
133
  '',
114
- `Write the result sidecar to \`agents/<your-id>/qa-run-result.json\` referencing artifacts under \`${outputDir}/\`.`,
134
+ `Drafted file: \`${testFile}\`. The config scopes discovery to this QA session.`,
135
+ '',
136
+ `Write the result sidecar to \`${resultSidecar}\` after storing artifacts under \`${outputDir}\`.`,
137
+ `Record artifact paths relative to \`${outputDir}\`, preserving any nested Playwright output directories.`,
115
138
  '',
116
139
  `Artifact types: ${artifactTypes.join(', ')}`,
117
140
  ].join('\n');
@@ -11,22 +11,21 @@
11
11
  *
12
12
  * detect(target, project) → boolean. Inspect the target/project
13
13
  * and return true when this runner is
14
- * the right choice (e.g. a Playwright
15
- * adapter returns true when the project
16
- * has playwright.config.* in its repo).
14
+ * the right choice.
17
15
  * generateBrief(opts) → string|object. Produces the
18
16
  * instructions handed to the DRAFT
19
17
  * agent so it emits a runner-native
20
18
  * test file under engine/qa-tests/<id>/.
21
19
  * executeBrief(opts) → result. Produces the instructions /
22
- * command the EXECUTE agent runs to
23
- * invoke the drafted test against the
24
- * live managed-spawn target.
20
+ * command the EXECUTE agent runs from
21
+ * opts.testDir (opts.testFile identifies
22
+ * the draft), writing to opts.artifactsDir
23
+ * and opts.resultSidecar.
25
24
  * validateOutputDir(dir) → { ok: boolean, errors: string[] }.
26
- * Called after DRAFT lands a test file
27
- * to confirm the runner-native artifact
28
- * is present before allowing EXECUTE
29
- * to schedule.
25
+ * Available to callers that want to
26
+ * validate a drafted runner artifact;
27
+ * the core DRAFT transition does not
28
+ * invoke it automatically today.
30
29
  * installHint → string. Human-readable instructions
31
30
  * shown to the user when detect()
32
31
  * returns true but the runner CLI is
package/engine/qa-runs.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Persistence: SQLite (engine/state.db, `qa_runs` table). Artifacts live on disk under
9
9
  * engine/qa-artifacts/<runId>/ — the run record stores relative paths
10
10
  * (`type`, `path`, `label`, `capturedAt`), and the dashboard exposes them
11
- * through GET /api/qa/artifacts/<runId>/<file>.
11
+ * through GET /api/qa/artifacts/<runId>/<path>.
12
12
  *
13
13
  * Concurrency: every mutation goes through shared.mutateQaRuns, which routes
14
14
  * to small-state-store.applyQaRunsMutation inside a SQLite transaction
@@ -37,6 +37,10 @@ const { mutateQaRuns, uid, ts, log } = shared;
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;
40
+ const QA_RUN_ID_MAX = 128;
41
+ const QA_ARTIFACT_PATH_MAX = 2048;
42
+ const QA_ARTIFACT_SEGMENT_MAX = 255;
43
+ const QA_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
40
44
 
41
45
  const QA_RUN_STATUS = Object.freeze({
42
46
  PENDING: 'pending',
@@ -85,9 +89,87 @@ function qaArtifactsDir() {
85
89
  }
86
90
 
87
91
  function qaArtifactsDirForRun(runId) {
92
+ if (!_isSafeRunId(runId)) throw new Error('qa-runs: invalid artifact run id: ' + runId);
88
93
  return path.join(qaArtifactsDir(), runId);
89
94
  }
90
95
 
96
+ function resultSidecarPath(agentId) {
97
+ const segment = typeof agentId === 'string' ? agentId : '';
98
+ if (!segment || segment.length > 128 || !/^[a-zA-Z0-9._-]+$/.test(segment)
99
+ || segment === '.' || segment === '..') {
100
+ throw new Error(`qa-runs: invalid agent id for result sidecar: ${agentId}`);
101
+ }
102
+ return path.join(shared.MINIONS_DIR, 'agents', segment, 'qa-run-result.json');
103
+ }
104
+
105
+ function prepareResultSidecar(agentId, runId) {
106
+ if (!_isSafeRunId(runId)) {
107
+ throw new Error(`qa-runs: invalid run id for result sidecar: ${runId}`);
108
+ }
109
+ const filePath = resultSidecarPath(agentId);
110
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
111
+ try {
112
+ fs.unlinkSync(filePath);
113
+ } catch (err) {
114
+ if (err.code !== 'ENOENT') {
115
+ throw new Error(`qa-runs: could not clear result sidecar for ${runId}: ${err.message}`);
116
+ }
117
+ }
118
+ return filePath;
119
+ }
120
+
121
+ function consumeResultSidecar(agentId, expectedRunId) {
122
+ if (!_isSafeRunId(expectedRunId)) {
123
+ throw new Error(`qa-runs: invalid expected run id for result sidecar: ${expectedRunId}`);
124
+ }
125
+ const filePath = resultSidecarPath(agentId);
126
+ let raw;
127
+ try {
128
+ raw = fs.readFileSync(filePath, 'utf8');
129
+ } catch (err) {
130
+ return {
131
+ ok: false,
132
+ reason: err.code === 'ENOENT' ? 'missing' : 'read-error',
133
+ detail: err.code === 'ENOENT' ? '' : err.message,
134
+ filePath,
135
+ };
136
+ }
137
+
138
+ let outcome;
139
+ try {
140
+ const parsed = JSON.parse(raw);
141
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
142
+ outcome = { ok: false, reason: 'malformed', detail: 'expected a JSON object', filePath };
143
+ } else if (parsed.runId !== expectedRunId) {
144
+ outcome = {
145
+ ok: false,
146
+ reason: 'run-id-mismatch',
147
+ actualRunId: parsed.runId,
148
+ expectedRunId,
149
+ filePath,
150
+ };
151
+ } else if (!TERMINAL_STATUSES.has(parsed.status)) {
152
+ outcome = {
153
+ ok: false,
154
+ reason: 'invalid-status',
155
+ status: parsed.status,
156
+ filePath,
157
+ };
158
+ } else {
159
+ outcome = { ok: true, parsed, filePath };
160
+ }
161
+ } catch (err) {
162
+ outcome = { ok: false, reason: 'malformed', detail: err.message, filePath };
163
+ }
164
+
165
+ try {
166
+ fs.unlinkSync(filePath);
167
+ } catch (err) {
168
+ if (err.code !== 'ENOENT') outcome.cleanupError = err.message;
169
+ }
170
+ return outcome;
171
+ }
172
+
91
173
  function isValidStatus(status) {
92
174
  return Object.values(QA_RUN_STATUS).includes(status);
93
175
  }
@@ -114,6 +196,16 @@ function normalizeArtifact(a) {
114
196
  };
115
197
  }
116
198
 
199
+ function createRunId() {
200
+ return 'qarun-' + uid();
201
+ }
202
+
203
+ function _trimRunsToLimit(runs) {
204
+ if (runs.length <= QA_RUNS_MAX_RECORDS) return runs;
205
+ runs.sort((a, b) => ((a && a.createdAt) || '').localeCompare((b && b.createdAt) || ''));
206
+ return runs.slice(runs.length - QA_RUNS_MAX_RECORDS);
207
+ }
208
+
117
209
  /**
118
210
  * Create a new run record in 'pending' status. Also pre-creates the per-run
119
211
  * artifact directory so the agent can drop files into it.
@@ -125,12 +217,14 @@ function normalizeArtifact(a) {
125
217
  * @param {string} [opts.workItemId] - originating work-item id (optional)
126
218
  * @returns {object} the created run record
127
219
  */
128
- function createRun({ runbookId, targetName, project, workItemId } = {}) {
220
+ function createRun({ id, runbookId, targetName, project, workItemId, deferRotation = false } = {}) {
129
221
  if (!runbookId || typeof runbookId !== 'string') throw new Error('qa-runs: runbookId is required');
130
222
  if (!targetName || typeof targetName !== 'string') throw new Error('qa-runs: targetName is required');
223
+ const runId = id || createRunId();
224
+ if (!_isSafeRunId(runId)) throw new Error('qa-runs: invalid run id: ' + runId);
131
225
 
132
226
  const run = {
133
- id: 'qarun-' + uid(),
227
+ id: runId,
134
228
  runbookId,
135
229
  targetName,
136
230
  project: project || null,
@@ -143,17 +237,19 @@ function createRun({ runbookId, targetName, project, workItemId } = {}) {
143
237
  createdAt: ts(),
144
238
  };
145
239
 
240
+ let duplicate = false;
146
241
  mutateQaRuns((runs) => {
147
242
  if (!Array.isArray(runs)) runs = [];
243
+ if (runs.some(existing => existing && existing.id === run.id)) {
244
+ duplicate = true;
245
+ return runs;
246
+ }
148
247
  runs.push(run);
149
248
  // Rotation: drop oldest-by-createdAt when over the cap. Cheap because
150
249
  // this runs only on createRun, not on every read.
151
- if (runs.length > QA_RUNS_MAX_RECORDS) {
152
- runs.sort((a, b) => ((a && a.createdAt) || '').localeCompare((b && b.createdAt) || ''));
153
- runs = runs.slice(runs.length - QA_RUNS_MAX_RECORDS);
154
- }
155
- return runs;
250
+ return deferRotation ? runs : _trimRunsToLimit(runs);
156
251
  });
252
+ if (duplicate) throw new Error('qa-runs: run id already exists: ' + run.id);
157
253
 
158
254
  // Pre-create the artifact directory outside the lock — directory creation
159
255
  // is idempotent and slow file I/O must never run while holding the lock.
@@ -164,6 +260,17 @@ function createRun({ runbookId, targetName, project, workItemId } = {}) {
164
260
  return run;
165
261
  }
166
262
 
263
+ function pruneRunsToLimit() {
264
+ let removed = 0;
265
+ mutateQaRuns((runs) => {
266
+ if (!Array.isArray(runs)) return [];
267
+ const trimmed = _trimRunsToLimit(runs);
268
+ removed = runs.length - trimmed.length;
269
+ return trimmed;
270
+ });
271
+ return removed;
272
+ }
273
+
167
274
  /**
168
275
  * Transition pending -> running. Stamps startedAt. Throws on illegal source state.
169
276
  * @param {string} id
@@ -321,12 +428,12 @@ function setRunWorkItemId(id, workItemId) {
321
428
  // DELETE endpoints take ids from URL paths, so every callsite re-validates.
322
429
  function _isSafeRunId(id) {
323
430
  if (!id || typeof id !== 'string') return false;
324
- if (id.length > 128) return false;
431
+ if (id.length > QA_RUN_ID_MAX) return false;
325
432
  if (id.indexOf('\0') >= 0) return false;
326
433
  if (id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) return false;
327
434
  if (id.indexOf('..') >= 0) return false;
328
435
  if (/^[a-zA-Z]:/.test(id)) return false;
329
- return true;
436
+ return QA_RUN_ID_RE.test(id);
330
437
  }
331
438
 
332
439
  /**
@@ -372,7 +479,7 @@ function deleteQaRun(id) {
372
479
  const dir = qaArtifactsDirForRun(id);
373
480
  const base = path.resolve(qaArtifactsDir());
374
481
  const resolved = path.resolve(dir);
375
- if (resolved.startsWith(base + path.sep) || resolved === base) {
482
+ if (resolved.startsWith(base + path.sep)) {
376
483
  if (fs.existsSync(resolved)) {
377
484
  fs.rmSync(resolved, { recursive: true, force: true });
378
485
  artifactsRemoved = true;
@@ -412,7 +519,10 @@ module.exports = {
412
519
  ALLOWED_TRANSITIONS,
413
520
  qaArtifactsDir,
414
521
  qaArtifactsDirForRun,
522
+ resultSidecarPath, prepareResultSidecar, consumeResultSidecar, // exported for testing
415
523
  createRun,
524
+ createRunId,
525
+ pruneRunsToLimit,
416
526
  markRunning,
417
527
  completeRun,
418
528
  setRunWorkItemId,
@@ -426,4 +536,7 @@ module.exports = {
426
536
  validateTransition,
427
537
  isValidStatus,
428
538
  QA_RUNS_MAX_RECORDS,
539
+ QA_RUN_ID_MAX,
540
+ QA_ARTIFACT_PATH_MAX,
541
+ QA_ARTIFACT_SEGMENT_MAX,
429
542
  };