@yemi33/minions 0.1.1995 → 0.1.1997

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.
@@ -9,6 +9,7 @@ const os = require('os');
9
9
  const path = require('path');
10
10
  const shared = require('./shared');
11
11
  const queries = require('./queries');
12
+ const { wrapUntrusted, buildSource } = require('./untrusted-fence');
12
13
 
13
14
  const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
14
15
  const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
@@ -184,7 +185,9 @@ function resolveTaskContext(item, config) {
184
185
  const planPath = path.join(MINIONS_DIR, 'plans', planFile);
185
186
  try {
186
187
  const content = safeRead(planPath);
187
- resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan')}`;
188
+ const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan');
189
+ const fenced = wrapUntrusted(truncated, buildSource('wi-reference', { path: `plans/${planFile}` }));
190
+ resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${fenced || truncated}`;
188
191
  resolved.referencedFiles.push(planPath);
189
192
  log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
190
193
  } catch (e) { log('warn', 'resolve plan context: ' + e.message); }
@@ -195,7 +198,9 @@ function resolveTaskContext(item, config) {
195
198
  const planPath = path.join(MINIONS_DIR, 'plans', match);
196
199
  try {
197
200
  const content = safeRead(planPath);
198
- resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan')}`;
201
+ const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan');
202
+ const fenced = wrapUntrusted(truncated, buildSource('wi-reference', { path: `plans/${match}` }));
203
+ resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${fenced || truncated}`;
199
204
  resolved.referencedFiles.push(planPath);
200
205
  log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
201
206
  } catch (e) { log('warn', 'resolve plan fallback context: ' + e.message); }
@@ -218,7 +223,9 @@ function resolveTaskContext(item, config) {
218
223
  .sort().reverse();
219
224
  if (files.length > 0) {
220
225
  const content = safeRead(path.join(inboxDir, files[0]));
221
- resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedNotesBytes, 'referenced notes')}`;
226
+ const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedNotesBytes, 'referenced notes');
227
+ const fenced = wrapUntrusted(truncated, buildSource('inbox', { filename: files[0] }));
228
+ resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${fenced || truncated}`;
222
229
  resolved.referencedFiles.push(path.join(inboxDir, files[0]));
223
230
  log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
224
231
  }
@@ -237,7 +244,9 @@ function resolveTaskContext(item, config) {
237
244
  if (plans.length > 0) {
238
245
  const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
239
246
  const content = safeRead(planPath);
240
- resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan')}`;
247
+ const truncated = truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan');
248
+ const fenced = wrapUntrusted(truncated, buildSource('wi-reference', { path: `plans/${plans[0]}` }));
249
+ resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${fenced || truncated}`;
241
250
  resolved.referencedFiles.push(planPath);
242
251
  log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
243
252
  }
@@ -309,6 +318,7 @@ const PLAYBOOK_REQUIRED_VARS = {
309
318
  'test': ['item_name'],
310
319
  'docs': ['item_id', 'item_name'],
311
320
  'setup': ['item_id', 'item_name', 'project_path'],
321
+ 'qa-validate': ['item_id', 'item_name', 'qa_run_id'],
312
322
  'work-item': ['item_id', 'item_name'],
313
323
  'meeting-investigate': ['meeting_title', 'agenda'],
314
324
  'meeting-debate': ['meeting_title', 'agenda'],
@@ -391,6 +401,69 @@ function resolvePlaybookPath(projectName, playbookType) {
391
401
  return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
392
402
  }
393
403
 
404
+ // W-mpeiwz6k0005bf34-c — Build the QA Run Context block that renderPlaybook
405
+ // injects when vars.qa_run_id is set. Pure formatter: takes the runbook +
406
+ // target snapshot the dispatcher captured (and stored on the work item meta)
407
+ // and renders a compact, prompt-friendly summary. Heavy guards against
408
+ // missing fields because dispatch callers may supply partial snapshots when
409
+ // the managed-process state has rotated between schedule and dispatch.
410
+ function buildQaValidateContextBlock({ runId, runbook, target, artifactsDir }) {
411
+ if (!runId) return '';
412
+ const lines = [];
413
+ lines.push('## QA Run Context');
414
+ lines.push('');
415
+ lines.push(`- **runId:** \`${runId}\``);
416
+ if (artifactsDir) lines.push(`- **artifactsDir:** \`${artifactsDir}\``);
417
+ lines.push('');
418
+
419
+ const rb = runbook && typeof runbook === 'object' ? runbook : null;
420
+ if (rb) {
421
+ lines.push('### Runbook');
422
+ lines.push(`- **id:** \`${rb.id || ''}\``);
423
+ if (rb.name) lines.push(`- **name:** ${rb.name}`);
424
+ if (Array.isArray(rb.steps) && rb.steps.length > 0) {
425
+ lines.push('- **steps:**');
426
+ rb.steps.forEach((s, i) => {
427
+ if (!s || typeof s !== 'object') return;
428
+ const desc = String(s.description || '').trim();
429
+ const cmd = s.command ? ` \`${String(s.command).trim()}\`` : '';
430
+ lines.push(` ${i + 1}. ${desc}${cmd}`);
431
+ });
432
+ }
433
+ if (Array.isArray(rb.expectedArtifacts) && rb.expectedArtifacts.length > 0) {
434
+ lines.push('- **expectedArtifacts:**');
435
+ for (const a of rb.expectedArtifacts) {
436
+ if (!a || typeof a !== 'object') continue;
437
+ const type = String(a.type || 'other');
438
+ const label = String(a.label || '').trim();
439
+ const hint = a.path ? ` (\`${a.path}\`)` : '';
440
+ lines.push(` - \`${type}\` — ${label}${hint}`);
441
+ }
442
+ }
443
+ lines.push('');
444
+ }
445
+
446
+ const t = target && typeof target === 'object' ? target : null;
447
+ if (t) {
448
+ lines.push('### Target (managed-process snapshot)');
449
+ if (t.name) lines.push(`- **name:** \`${t.name}\``);
450
+ if (t.owner_project) lines.push(`- **project:** \`${t.owner_project}\``);
451
+ if (typeof t.healthy === 'boolean') lines.push(`- **healthy:** ${t.healthy}`);
452
+ if (Array.isArray(t.ports) && t.ports.length > 0) lines.push(`- **ports:** ${t.ports.join(', ')}`);
453
+ if (t.attrs && typeof t.attrs === 'object') {
454
+ const base = t.attrs.base_url || t.attrs.baseUrl;
455
+ const framework = t.attrs.framework;
456
+ if (base) lines.push(`- **base_url:** ${base}`);
457
+ if (framework) lines.push(`- **framework:** ${framework}`);
458
+ }
459
+ lines.push('');
460
+ }
461
+
462
+ lines.push('Use this context to execute the runbook against the live target. Write the result sidecar to `agents/<your-id>/qa-run-result.json` before exit — the engine consumes it in `engine/lifecycle.js` and calls `qaRuns.completeRun(runId, ...)`.');
463
+
464
+ return lines.join('\n');
465
+ }
466
+
394
467
 
395
468
  // ─── Playbook Renderer ──────────────────────────────────────────────────────
396
469
 
@@ -411,15 +484,20 @@ function renderPlaybook(type, vars) {
411
484
 
412
485
  const inertAppendices = [];
413
486
 
414
- // Inject pinned context (always visible to agents) — capped at 4KB
487
+ // Inject pinned context (always visible to agents) — capped at 4KB.
488
+ // F5 (W-mpeklod3000we69c): wrap in <UNTRUSTED-INPUT> fence — human-edited
489
+ // file that ends up in every agent prompt.
415
490
  let pinnedContent = '';
416
491
  try { pinnedContent = fs.readFileSync(path.join(MINIONS_DIR, 'pinned.md'), 'utf8'); } catch { /* optional */ }
417
492
  if (pinnedContent) {
418
493
  if (pinnedContent.length > 4096) pinnedContent = pinnedContent.slice(0, 4096) + '\n\n_...pinned.md truncated (read full file if needed)_';
419
- inertAppendices.push('\n\n---\n\n## Pinned Context (CRITICAL READ FIRST)\n\n' + pinnedContent);
494
+ const fenced = wrapUntrusted(pinnedContent, buildSource('pinned-note', { path: 'pinned.md' }));
495
+ inertAppendices.push('\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + (fenced || pinnedContent));
420
496
  }
421
497
 
422
- // Inject team notes (single injection point — not in buildAgentContext) — capped via ENGINE_DEFAULTS
498
+ // Inject team notes (single injection point — not in buildAgentContext) — capped via ENGINE_DEFAULTS.
499
+ // F5: wrap in <UNTRUSTED-INPUT> fence — notes.md is an LLM-consolidated mix
500
+ // of agent inbox notes (semi-trusted) and human edits.
423
501
  let notes = getNotes();
424
502
  if (notes) {
425
503
  if (Buffer.byteLength(notes, 'utf8') > ENGINE_DEFAULTS.maxNotesPromptBytes) {
@@ -430,15 +508,19 @@ function renderPlaybook(type, vars) {
430
508
  const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes - Buffer.byteLength(footer, 'utf8'));
431
509
  notes = truncateTextBytes(recent, budget, '\n\n_...notes truncated_') + footer;
432
510
  }
433
- inertAppendices.push('\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes);
511
+ const fenced = wrapUntrusted(notes, buildSource('team-notes', { path: 'notes.md' }));
512
+ inertAppendices.push('\n\n---\n\n## Team Notes (MUST READ)\n\n' + (fenced || notes));
434
513
  }
435
514
 
436
515
  // Inject per-agent memory file (knowledge/agents/<agentId>.md) — personal
437
516
  // notebook curated by the consolidation pipeline. Capped at the same
438
517
  // notes budget; missing file degrades gracefully (silent skip).
518
+ // F5: fence — agent-authored inbox notes routed into this file; any agent
519
+ // could include attacker-controlled quoted material.
439
520
  const agentIdForMemory = vars.agent_id;
440
521
  if (agentIdForMemory && /^[a-z][a-z0-9-]{0,40}$/i.test(agentIdForMemory) && !String(agentIdForMemory).toLowerCase().startsWith('temp-')) {
441
- const agentMemPath = path.join(MINIONS_DIR, 'knowledge', 'agents', `${String(agentIdForMemory).toLowerCase()}.md`);
522
+ const agentMemRel = `knowledge/agents/${String(agentIdForMemory).toLowerCase()}.md`;
523
+ const agentMemPath = path.join(MINIONS_DIR, agentMemRel);
442
524
  let agentMem = '';
443
525
  try { agentMem = fs.readFileSync(agentMemPath, 'utf8'); } catch { /* optional — file may not exist */ }
444
526
  if (agentMem && agentMem.trim()) {
@@ -448,7 +530,8 @@ function renderPlaybook(type, vars) {
448
530
  const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes);
449
531
  agentMem = truncateTextBytes(recent, budget, '\n\n_...agent memory truncated_');
450
532
  }
451
- inertAppendices.push('\n\n---\n\n## Personal Memory (your past learnings MUST READ)\n\n' + agentMem);
533
+ const fenced = wrapUntrusted(agentMem, buildSource('agent-memory', { path: agentMemRel }));
534
+ inertAppendices.push('\n\n---\n\n## Personal Memory (your past learnings — MUST READ)\n\n' + (fenced || agentMem));
452
535
  }
453
536
  }
454
537
 
@@ -503,6 +586,23 @@ function renderPlaybook(type, vars) {
503
586
  } catch (e) { log('warn', `managed-spawn live-processes inject failed: ${e.message}`); }
504
587
  }
505
588
 
589
+ // W-mpeiwz6k0005bf34-c — opt-in qa-validate context block. Injected only
590
+ // when the dispatcher set vars.qa_run_id (truthy) from the work item's
591
+ // `meta.qaRunId`. Mirrors the managed_spawn hint pattern: the playbook is
592
+ // pure markdown; this block surfaces the live runbook + target snapshot so
593
+ // the agent doesn't need to re-resolve them from disk.
594
+ if (vars.qa_run_id) {
595
+ try {
596
+ const block = buildQaValidateContextBlock({
597
+ runId: vars.qa_run_id,
598
+ runbook: vars.qa_runbook,
599
+ target: vars.qa_target,
600
+ artifactsDir: vars.qa_artifacts_dir,
601
+ });
602
+ if (block) inertAppendices.push(block);
603
+ } catch (e) { log('warn', `qa-validate context render failed: ${e.message}`); }
604
+ }
605
+
506
606
  // Inject KB guardrail
507
607
  content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
508
608
  content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
@@ -846,6 +946,15 @@ function buildBaseVars(agentId, config, project) {
846
946
  }
847
947
 
848
948
  function selectPlaybook(workType, item) {
949
+ // W-mpeiwz6k0005bf34-c — explicit playbook override via item.meta.playbook.
950
+ // Used by /api/qa/runbooks/run to route a `test`-type work item to the
951
+ // qa-validate playbook without minting a new work-type. Validated against
952
+ // PLAYBOOK_REQUIRED_VARS so a typo'd override falls through to work-item
953
+ // rather than mis-rendering.
954
+ const playbookOverride = (item?.meta?.playbook || item?.playbook || '').toString().trim();
955
+ if (playbookOverride && PLAYBOOK_REQUIRED_VARS[playbookOverride]) {
956
+ return playbookOverride;
957
+ }
849
958
  if (item?.branchStrategy === 'shared-branch' && (workType === WORK_TYPE.IMPLEMENT || workType === WORK_TYPE.IMPLEMENT_LARGE)) {
850
959
  return 'implement-shared';
851
960
  }
@@ -893,6 +1002,7 @@ module.exports = {
893
1002
  selectPlaybook,
894
1003
  buildBaseVars,
895
1004
  buildPrDispatch,
1005
+ buildQaValidateContextBlock,
896
1006
  resolveTaskContext,
897
1007
  // Repo host helpers (used by engine.js for buildProjectContext)
898
1008
  getRepoHost,
@@ -0,0 +1,328 @@
1
+ /**
2
+ * engine/qa-runbooks.js — W-mpeiwz6k0005bf34-a
3
+ *
4
+ * Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
5
+ * that travel with a project entry, mirroring the
6
+ * projects/<name>/pull-requests.json precedent. Each runbook is one JSON file
7
+ * at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
8
+ *
9
+ * Pure persistence + validation only — this module does NOT spawn agents,
10
+ * dispatch runs, or touch UI. The dispatch endpoint, run records, and QA UI
11
+ * are intentionally deferred to follow-up plan items.
12
+ *
13
+ * All writes go through mutateJsonFileLocked per the repo convention. The id
14
+ * field is globally unique across projects (kebab-case, ≤64 chars) so reads
15
+ * by id can locate the file without the caller knowing the project.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const shared = require('./shared');
21
+
22
+ const RUNBOOKS_DIR = 'runbooks';
23
+
24
+ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
+
26
+ // Mirrors shared.PROJECT_NAME_RE — kept local to avoid a require cycle and to
27
+ // keep this module self-contained for path-traversal hardening (review feedback
28
+ // on PR #2694: id/project params previously flowed into path.join without
29
+ // validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
30
+ // and DELETE could wipe dispatch.json).
31
+ const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
32
+
33
+ const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
34
+
35
+ const LIMITS = {
36
+ idMax: 64,
37
+ nameMax: 200,
38
+ targetNameMax: 200,
39
+ stepDescriptionMax: 500,
40
+ stepCommandMax: 2000,
41
+ artifactLabelMax: 200,
42
+ artifactPathMax: 500,
43
+ stepsMax: 20,
44
+ artifactsMax: 20,
45
+ };
46
+
47
+ function _projectsDir() {
48
+ return path.join(shared.MINIONS_DIR, 'projects');
49
+ }
50
+
51
+ function _runbooksDir(projectName) {
52
+ return path.join(_projectsDir(), projectName, RUNBOOKS_DIR);
53
+ }
54
+
55
+ function _runbookPath(projectName, id) {
56
+ return path.join(_runbooksDir(projectName), id + '.json');
57
+ }
58
+
59
+ function _isNonEmptyString(v) {
60
+ return typeof v === 'string' && v.length > 0;
61
+ }
62
+
63
+ // Guards against path traversal at the module boundary. Mirrors the validation
64
+ // saveRunbook already applies via validateRunbook(). Reject anything that isn't
65
+ // a safe kebab-case id ≤ idMax chars so it can never reach path.join().
66
+ function _isSafeId(id) {
67
+ return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
68
+ }
69
+
70
+ // Guards against path traversal via the project segment. Project directory
71
+ // names on disk follow shared.PROJECT_NAME_RE — anything outside that set
72
+ // (path separators, `..`, null bytes, whitespace) cannot be a real project.
73
+ function _isSafeProjectName(name) {
74
+ return _isNonEmptyString(name) && _PROJECT_NAME_RE.test(name);
75
+ }
76
+
77
+ /**
78
+ * Validate a runbook spec. Returns { ok: boolean, errors: string[] }.
79
+ * Never throws.
80
+ */
81
+ function validateRunbook(spec) {
82
+ const errors = [];
83
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
84
+ return { ok: false, errors: ['spec must be a plain object'] };
85
+ }
86
+
87
+ if (!_isNonEmptyString(spec.id)) {
88
+ errors.push('id is required (non-empty string)');
89
+ } else {
90
+ if (spec.id.length > LIMITS.idMax) errors.push('id exceeds ' + LIMITS.idMax + ' chars');
91
+ if (!_KEBAB_RE.test(spec.id)) errors.push('id must be kebab-case (a-z, 0-9, hyphens; no leading/trailing hyphen)');
92
+ }
93
+
94
+ if (!_isNonEmptyString(spec.name)) {
95
+ errors.push('name is required (non-empty string)');
96
+ } else if (spec.name.length > LIMITS.nameMax) {
97
+ errors.push('name exceeds ' + LIMITS.nameMax + ' chars');
98
+ }
99
+
100
+ if (!_isNonEmptyString(spec.project)) {
101
+ errors.push('project is required (non-empty string)');
102
+ } else if (!_PROJECT_NAME_RE.test(spec.project)) {
103
+ // Reject path-traversal / illegal project names at the schema layer so
104
+ // they never reach path.join in saveRunbook (review feedback on PR #2694:
105
+ // POST /api/qa/runbooks with project="../engine" previously wrote arbitrary
106
+ // JSON outside MINIONS_DIR).
107
+ errors.push('project must match ' + _PROJECT_NAME_RE.source + ' (alphanumerics, underscore, hyphen; 1-64 chars)');
108
+ }
109
+
110
+ if (!_isNonEmptyString(spec.targetName)) {
111
+ errors.push('targetName is required (non-empty string)');
112
+ } else if (spec.targetName.length > LIMITS.targetNameMax) {
113
+ errors.push('targetName exceeds ' + LIMITS.targetNameMax + ' chars');
114
+ }
115
+
116
+ if (!Array.isArray(spec.steps)) {
117
+ errors.push('steps must be an array');
118
+ } else {
119
+ if (spec.steps.length > LIMITS.stepsMax) {
120
+ errors.push('steps exceeds max of ' + LIMITS.stepsMax);
121
+ }
122
+ for (let i = 0; i < spec.steps.length; i++) {
123
+ const s = spec.steps[i];
124
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
125
+ errors.push('steps[' + i + '] must be an object');
126
+ continue;
127
+ }
128
+ if (!_isNonEmptyString(s.description)) {
129
+ errors.push('steps[' + i + '].description is required (non-empty string)');
130
+ } else if (s.description.length > LIMITS.stepDescriptionMax) {
131
+ errors.push('steps[' + i + '].description exceeds ' + LIMITS.stepDescriptionMax + ' chars');
132
+ }
133
+ if (s.command !== undefined && s.command !== null) {
134
+ if (typeof s.command !== 'string') {
135
+ errors.push('steps[' + i + '].command must be a string when present');
136
+ } else if (s.command.length > LIMITS.stepCommandMax) {
137
+ errors.push('steps[' + i + '].command exceeds ' + LIMITS.stepCommandMax + ' chars');
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ if (!Array.isArray(spec.expectedArtifacts)) {
144
+ errors.push('expectedArtifacts must be an array');
145
+ } else {
146
+ if (spec.expectedArtifacts.length > LIMITS.artifactsMax) {
147
+ errors.push('expectedArtifacts exceeds max of ' + LIMITS.artifactsMax);
148
+ }
149
+ for (let i = 0; i < spec.expectedArtifacts.length; i++) {
150
+ const a = spec.expectedArtifacts[i];
151
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
152
+ errors.push('expectedArtifacts[' + i + '] must be an object');
153
+ continue;
154
+ }
155
+ if (!_isNonEmptyString(a.type) || !ARTIFACT_TYPES.includes(a.type)) {
156
+ errors.push('expectedArtifacts[' + i + '].type must be one of: ' + ARTIFACT_TYPES.join(', '));
157
+ }
158
+ if (!_isNonEmptyString(a.label)) {
159
+ errors.push('expectedArtifacts[' + i + '].label is required (non-empty string)');
160
+ } else if (a.label.length > LIMITS.artifactLabelMax) {
161
+ errors.push('expectedArtifacts[' + i + '].label exceeds ' + LIMITS.artifactLabelMax + ' chars');
162
+ }
163
+ if (a.path !== undefined && a.path !== null) {
164
+ if (typeof a.path !== 'string') {
165
+ errors.push('expectedArtifacts[' + i + '].path must be a string when present');
166
+ } else if (a.path.length > LIMITS.artifactPathMax) {
167
+ errors.push('expectedArtifacts[' + i + '].path exceeds ' + LIMITS.artifactPathMax + ' chars');
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return { ok: errors.length === 0, errors };
174
+ }
175
+
176
+ function _readRunbookFile(filePath) {
177
+ let raw;
178
+ try { raw = fs.readFileSync(filePath, 'utf8'); }
179
+ catch (_e) { return null; }
180
+ try { return JSON.parse(raw); }
181
+ catch (_e) { return null; }
182
+ }
183
+
184
+ function _listProjectNames() {
185
+ const dir = _projectsDir();
186
+ let entries;
187
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
188
+ catch (_e) { return []; }
189
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
190
+ }
191
+
192
+ /**
193
+ * List runbooks across all projects, or filtered to a single project. Each
194
+ * returned record is the parsed file contents (already includes id + project
195
+ * + timestamps).
196
+ */
197
+ function listRunbooks(project) {
198
+ let projects;
199
+ if (project === undefined || project === null || project === '') {
200
+ projects = _listProjectNames();
201
+ } else {
202
+ // Hardened: reject traversal/illegal project names instead of letting them
203
+ // flow into path.join (review feedback on PR #2694).
204
+ if (!_isSafeProjectName(project)) return [];
205
+ projects = [project];
206
+ }
207
+ const out = [];
208
+ for (const name of projects) {
209
+ const dir = _runbooksDir(name);
210
+ let files;
211
+ try { files = fs.readdirSync(dir); }
212
+ catch (_e) { continue; }
213
+ for (const f of files) {
214
+ if (!f.endsWith('.json')) continue;
215
+ const parsed = _readRunbookFile(path.join(dir, f));
216
+ if (parsed && typeof parsed === 'object') out.push(parsed);
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Find a runbook by globally-unique id. Returns the parsed record or null.
224
+ */
225
+ function getRunbook(id) {
226
+ // Hardened: reject traversal ids before they can reach path.join + existsSync
227
+ // (review feedback on PR #2694).
228
+ if (!_isSafeId(id)) return null;
229
+ for (const name of _listProjectNames()) {
230
+ const filePath = _runbookPath(name, id);
231
+ if (fs.existsSync(filePath)) {
232
+ return _readRunbookFile(filePath);
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Locate the project that currently owns id, or null if not present.
240
+ */
241
+ function _findOwningProject(id) {
242
+ for (const name of _listProjectNames()) {
243
+ if (fs.existsSync(_runbookPath(name, id))) return name;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * Create or update a runbook. Sets createdAt on first save and updatedAt on
250
+ * every save. Throws on validation failure. Rejects cross-project renames —
251
+ * if id already exists under a different project, the caller must
252
+ * deleteRunbook(id) first.
253
+ */
254
+ function saveRunbook(spec) {
255
+ const v = validateRunbook(spec);
256
+ if (!v.ok) {
257
+ const err = new Error('invalid runbook: ' + v.errors.join('; '));
258
+ err.validationErrors = v.errors;
259
+ throw err;
260
+ }
261
+ const existingProject = _findOwningProject(spec.id);
262
+ if (existingProject && existingProject !== spec.project) {
263
+ throw new Error('runbook id "' + spec.id + '" already exists under project "' + existingProject + '" — delete it before saving under "' + spec.project + '"');
264
+ }
265
+
266
+ const filePath = _runbookPath(spec.project, spec.id);
267
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
268
+
269
+ const nowIso = new Date().toISOString();
270
+ const result = shared.mutateJsonFileLocked(filePath, (data) => {
271
+ const prior = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
272
+ return {
273
+ id: spec.id,
274
+ name: spec.name,
275
+ project: spec.project,
276
+ targetName: spec.targetName,
277
+ steps: spec.steps.map(s => {
278
+ const out = { description: s.description };
279
+ if (typeof s.command === 'string' && s.command.length > 0) out.command = s.command;
280
+ return out;
281
+ }),
282
+ expectedArtifacts: spec.expectedArtifacts.map(a => {
283
+ const out = { type: a.type, label: a.label };
284
+ if (typeof a.path === 'string' && a.path.length > 0) out.path = a.path;
285
+ return out;
286
+ }),
287
+ createdAt: _isNonEmptyString(prior.createdAt) ? prior.createdAt : nowIso,
288
+ updatedAt: nowIso,
289
+ };
290
+ }, { defaultValue: {} });
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * Remove a runbook by id. No-op when the id is not found. Returns true when
296
+ * a file was removed.
297
+ *
298
+ * Coordination: acquires the runbook's lock via withFileLock so a concurrent
299
+ * saveRunbook can't be mid-rename when we unlink. The unlink happens inside
300
+ * the lock callback (single fs call — keeps the callback synchronous and
301
+ * fast per the repo convention).
302
+ */
303
+ function deleteRunbook(id) {
304
+ // Hardened: reject traversal ids before they can reach _findOwningProject /
305
+ // path.join / unlink (review feedback on PR #2694).
306
+ if (!_isSafeId(id)) return false;
307
+ const owning = _findOwningProject(id);
308
+ if (!owning) return false;
309
+ const filePath = _runbookPath(owning, id);
310
+ shared.withFileLock(filePath + '.lock', () => {
311
+ try { fs.unlinkSync(filePath); } catch (_e) { /* already gone */ }
312
+ try { fs.unlinkSync(filePath + '.backup'); } catch (_e) { /* optional */ }
313
+ });
314
+ return true;
315
+ }
316
+
317
+ module.exports = {
318
+ ARTIFACT_TYPES,
319
+ LIMITS,
320
+ validateRunbook,
321
+ listRunbooks,
322
+ getRunbook,
323
+ saveRunbook,
324
+ deleteRunbook,
325
+ // internals exposed for testing
326
+ _runbookPath,
327
+ _runbooksDir,
328
+ };
package/engine/qa-runs.js CHANGED
@@ -43,8 +43,23 @@ const TERMINAL_STATUSES = new Set([
43
43
  ]);
44
44
 
45
45
  // Allowed forward transitions. Anything not enumerated here is rejected.
46
+ //
47
+ // PR #2697 review fix (W-mpeiwz6k0005bf34-c — Ripley): the lifecycle hook in
48
+ // engine/lifecycle.js parses the agent's qa-run-result.json sidecar and calls
49
+ // completeRun({status: 'passed'|'failed'|'errored'}) directly. It never calls
50
+ // markRunning, because the agent may crash before writing the sidecar (in
51
+ // which case the hook still needs to mark the run errored from `pending`).
52
+ // Allowing pending → {passed,failed,errored} keeps the production path from
53
+ // throwing "illegal transition" inside the hook's try/catch and leaving the
54
+ // run perma-pending. The state machine still rejects double-completion
55
+ // (terminal → terminal) so race-y double-writes can't silently overwrite.
46
56
  const ALLOWED_TRANSITIONS = {
47
- [QA_RUN_STATUS.PENDING]: new Set([QA_RUN_STATUS.RUNNING]),
57
+ [QA_RUN_STATUS.PENDING]: new Set([
58
+ QA_RUN_STATUS.RUNNING,
59
+ QA_RUN_STATUS.PASSED,
60
+ QA_RUN_STATUS.FAILED,
61
+ QA_RUN_STATUS.ERRORED,
62
+ ]),
48
63
  [QA_RUN_STATUS.RUNNING]: new Set([
49
64
  QA_RUN_STATUS.PASSED,
50
65
  QA_RUN_STATUS.FAILED,
@@ -259,6 +274,31 @@ function getRunsForWorkItem(wi) {
259
274
  });
260
275
  }
261
276
 
277
+ /**
278
+ * Back-fill workItemId on an existing run record. Used by the qa-validate
279
+ * dispatch endpoint (dashboard.js handleQaRunbookRun) when the WI is created
280
+ * after the run record so the dashboard can join the two. No-op (returns
281
+ * null) when the run id is unknown.
282
+ *
283
+ * @param {string} id - run id
284
+ * @param {string|null} workItemId - work-item id (or null to clear)
285
+ * @returns {object|null} updated run, or null if not found
286
+ */
287
+ function setRunWorkItemId(id, workItemId) {
288
+ if (!id) return null;
289
+ let captured = null;
290
+ mutateJsonFileLocked(qaRunsPath(), (runs) => {
291
+ if (!Array.isArray(runs)) runs = [];
292
+ const run = runs.find(r => r && r.id === id);
293
+ if (run) {
294
+ run.workItemId = workItemId || null;
295
+ captured = run;
296
+ }
297
+ return runs;
298
+ }, { defaultValue: [] });
299
+ return captured;
300
+ }
301
+
262
302
  module.exports = {
263
303
  QA_RUN_STATUS,
264
304
  TERMINAL_STATUSES,
@@ -269,6 +309,7 @@ module.exports = {
269
309
  createRun,
270
310
  markRunning,
271
311
  completeRun,
312
+ setRunWorkItemId,
272
313
  getRun,
273
314
  listRuns,
274
315
  getRunsForWorkItem,