@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.
- package/dashboard/js/refresh.js +23 -1
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +577 -103
- package/docs/qa-runbooks.md +104 -0
- package/docs/security.md +21 -13
- package/engine/ado.js +18 -2
- package/engine/consolidation.js +38 -9
- package/engine/dispatch.js +2 -0
- package/engine/github.js +14 -2
- package/engine/lifecycle.js +166 -0
- package/engine/operator-identity.js +104 -0
- package/engine/playbook.js +120 -10
- package/engine/qa-runbooks.js +328 -0
- package/engine/qa-runs.js +42 -1
- package/engine/queries.js +49 -7
- package/engine/shared.js +47 -1
- package/engine/untrusted-fence.js +184 -0
- package/engine.js +44 -5
- package/package.json +1 -1
- package/playbooks/implement.md +9 -3
- package/playbooks/plan-to-prd.md +3 -3
- package/playbooks/qa-validate.md +118 -0
- package/playbooks/shared-rules.md +31 -0
- package/playbooks/work-item.md +4 -3
- package/prompts/cc-system.md +8 -0
- package/routing.md +1 -0
package/engine/playbook.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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([
|
|
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,
|