@yemi33/minions 0.1.2424 → 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.
- package/bin/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +6 -4
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/internal-install.md +190 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +1 -1
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- 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
|
|
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
|
|
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
|
|
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 \`
|
|
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
|
|
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 {
|
|
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
|
|
83
|
-
'
|
|
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
|
|
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
|
|
95
|
-
|
|
96
|
-
if (capture.
|
|
97
|
-
if (capture.
|
|
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
|
-
|
|
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
|
|
131
|
+
`npx playwright test --config "${configPath}"`,
|
|
112
132
|
'```',
|
|
113
133
|
'',
|
|
114
|
-
`
|
|
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');
|
package/engine/qa-runners.js
CHANGED
|
@@ -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
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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>/<
|
|
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:
|
|
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
|
-
|
|
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 >
|
|
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
|
|
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)
|
|
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
|
};
|