@yemi33/minions 0.1.1992 → 0.1.1994
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/command-center.js +107 -25
- package/dashboard.js +153 -0
- package/engine/cooldown.js +2 -0
- package/engine/qa-runs.js +278 -0
- package/package.json +1 -1
|
@@ -179,48 +179,130 @@ async function _ccDashboardHealth() {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// Pure predicate — extracted so test/unit/dashboard-resilience.test.js can
|
|
183
|
+
// behaviour-test the new-vs-old-instance decision without standing up a DOM.
|
|
184
|
+
// Returns true only when newDashId is provably a NEW dashboard process:
|
|
185
|
+
// - When preRestartDashId is known, any difference proves a different process.
|
|
186
|
+
// - When preRestartDashId is unknown (page just loaded, _lastStatus empty),
|
|
187
|
+
// accept only if newDashId parses to a timestamp strictly AFTER the click.
|
|
188
|
+
// Guards against the still-alive OLD dashboard answering /api/status
|
|
189
|
+
// before its kill lands (~1s into the restart child's lifetime).
|
|
190
|
+
function _ccIsNewDashboardInstance(preRestartDashId, newDashId, clickTimeMs) {
|
|
191
|
+
if (!newDashId) return false;
|
|
192
|
+
if (preRestartDashId) return newDashId !== preRestartDashId;
|
|
193
|
+
var parsed = Date.parse(newDashId);
|
|
194
|
+
return Number.isFinite(parsed) && parsed > clickTimeMs;
|
|
195
|
+
}
|
|
196
|
+
|
|
182
197
|
// Triggered by the CC "Restart Minions" recovery button when a stale dashboard
|
|
183
198
|
// connection is killing CC streams with "Failed to fetch". Spawns the same
|
|
184
199
|
// `minions restart` flow as the CLI command (kills + respawns engine AND
|
|
185
|
-
// dashboard)
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
200
|
+
// dashboard) and reloads once the NEW dashboard is up.
|
|
201
|
+
//
|
|
202
|
+
// W-mpehvbed0018e27d: previous version polled /api/health which returns 200
|
|
203
|
+
// from EITHER the still-alive OLD dashboard (before the spawned restart child
|
|
204
|
+
// finished its kill sequence) OR the NEW one. Reloading on the OLD instance
|
|
205
|
+
// landed the page on a dying port; reloading on the NEW instance with a cold
|
|
206
|
+
// status cache hung the freshly-reloaded page on its first /api/status fetch.
|
|
207
|
+
// Both produced the "click does nothing / page still broken" symptoms.
|
|
208
|
+
//
|
|
209
|
+
// Fix:
|
|
210
|
+
// 1. Capture pre-restart dashboardStartedAt before POSTing the restart
|
|
211
|
+
// (so we can detect the NEW instance vs the still-alive OLD one).
|
|
212
|
+
// 2. Poll /api/status (not /api/health) — a 200 response proves the status
|
|
213
|
+
// cache has been built once, so the post-reload first request is warm.
|
|
214
|
+
// 3. Only reload when _ccIsNewDashboardInstance() confirms a different
|
|
215
|
+
// dashboardStartedAt, with per-poll AbortController so a hanging cold
|
|
216
|
+
// rebuild can't starve the global deadline.
|
|
217
|
+
// 4. Cache-bust the reload (?cb=) so an updated dashboard JS bundle is not
|
|
218
|
+
// served from the browser disk cache. location.replace() so the broken
|
|
219
|
+
// pre-restart URL doesn't pollute history.
|
|
189
220
|
async function ccRestartMinions(btn) {
|
|
190
221
|
if (btn) { try { btn.disabled = true; btn.textContent = 'Restarting...'; } catch {} }
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
222
|
+
var clickTimeMs = Date.now();
|
|
223
|
+
|
|
224
|
+
// Capture pre-restart dashboard instance ID BEFORE the POST so the polling
|
|
225
|
+
// step has a baseline to compare against. _lastStatus is populated by
|
|
226
|
+
// refresh.js's 4s poll; on a freshly-opened page it may still be null, so
|
|
227
|
+
// we fall back to a bounded /api/status fetch, then to clickTime-based
|
|
228
|
+
// detection inside _ccIsNewDashboardInstance.
|
|
229
|
+
var preRestartDashId = (window._lastStatus && window._lastStatus.version)
|
|
230
|
+
? window._lastStatus.version.dashboardStartedAt : null;
|
|
231
|
+
if (!preRestartDashId) {
|
|
232
|
+
try {
|
|
233
|
+
var preCtl = new AbortController();
|
|
234
|
+
var preTimer = setTimeout(function() { preCtl.abort(); }, 2000);
|
|
235
|
+
try {
|
|
236
|
+
var preRes = await fetch('/api/status', { cache: 'no-store', signal: preCtl.signal });
|
|
237
|
+
if (preRes && preRes.ok) {
|
|
238
|
+
var preData = await preRes.json().catch(function() { return null; });
|
|
239
|
+
if (preData && preData.version) preRestartDashId = preData.version.dashboardStartedAt || null;
|
|
240
|
+
}
|
|
241
|
+
} finally { clearTimeout(preTimer); }
|
|
242
|
+
} catch { /* best-effort — clickTime fallback inside helper still covers us */ }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Fire-and-forget the restart POST. We do NOT await it — the dashboard often
|
|
246
|
+
// kills its own process before the response is flushed, so the fetch throws
|
|
247
|
+
// even though the restart child (a detached `minions restart`) is happily
|
|
248
|
+
// running. The polling loop is the source of truth for completion.
|
|
195
249
|
try {
|
|
196
250
|
fetch('/api/dashboard/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
|
|
197
251
|
.catch(function() { /* dashboard process likely killed mid-response — expected */ });
|
|
198
252
|
} catch { /* network layer threw before fetch even queued — also expected */ }
|
|
199
253
|
if (btn) { try { btn.textContent = 'Restarting Minions — waiting for new dashboard...'; } catch {} }
|
|
254
|
+
|
|
200
255
|
var startedAt = Date.now();
|
|
201
|
-
var DEADLINE_MS =
|
|
256
|
+
var DEADLINE_MS = 90000; // generous: cold Windows AV + status rebuild can eat 30s
|
|
202
257
|
var INTERVAL_MS = 500;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
258
|
+
var POLL_TIMEOUT_MS = 5000;
|
|
259
|
+
|
|
260
|
+
function reloadWithCacheBust(label) {
|
|
261
|
+
if (btn) { try { btn.textContent = label; } catch {} }
|
|
262
|
+
try {
|
|
263
|
+
var url = new URL(location.href);
|
|
264
|
+
url.searchParams.set('cb', String(Date.now()));
|
|
265
|
+
location.replace(url.toString());
|
|
266
|
+
} catch {
|
|
267
|
+
try { location.reload(); } catch {}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function pollStatus() {
|
|
272
|
+
var ctl = new AbortController();
|
|
273
|
+
var pollTimer = setTimeout(function() { ctl.abort(); }, POLL_TIMEOUT_MS);
|
|
274
|
+
var shouldRetry = false;
|
|
275
|
+
try {
|
|
276
|
+
var res = await fetch('/api/status', { cache: 'no-store', signal: ctl.signal });
|
|
277
|
+
if (!res || !res.ok) { shouldRetry = true; }
|
|
278
|
+
else {
|
|
279
|
+
var data = await res.json().catch(function() { return null; });
|
|
280
|
+
var newDashId = (data && data.version) ? data.version.dashboardStartedAt : null;
|
|
281
|
+
if (_ccIsNewDashboardInstance(preRestartDashId, newDashId, clickTimeMs)) {
|
|
282
|
+
reloadWithCacheBust('Dashboard online — reloading...');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Same instance (or unparseable) — old dashboard still answering or
|
|
286
|
+
// new dashboard ID hasn't advanced past clickTime yet. Keep polling.
|
|
287
|
+
shouldRetry = true;
|
|
209
288
|
}
|
|
210
|
-
|
|
211
|
-
|
|
289
|
+
} catch { shouldRetry = true; }
|
|
290
|
+
finally { clearTimeout(pollTimer); }
|
|
291
|
+
if (shouldRetry) {
|
|
212
292
|
if (Date.now() - startedAt > DEADLINE_MS) {
|
|
213
|
-
|
|
214
|
-
|
|
293
|
+
// Last-ditch reload. refresh.js's auto-detection (4s poll on
|
|
294
|
+
// dashboardStartedAt change) will pick up the new instance once it
|
|
295
|
+
// does come up.
|
|
296
|
+
reloadWithCacheBust('Restart timed out — reloading anyway...');
|
|
215
297
|
return;
|
|
216
298
|
}
|
|
217
|
-
setTimeout(
|
|
218
|
-
}
|
|
299
|
+
setTimeout(pollStatus, INTERVAL_MS);
|
|
300
|
+
}
|
|
219
301
|
}
|
|
220
|
-
|
|
221
|
-
// the
|
|
222
|
-
//
|
|
223
|
-
setTimeout(
|
|
302
|
+
|
|
303
|
+
// Give the restart child a moment to actually kill the old dashboard before
|
|
304
|
+
// we start polling. Polling at t=0 risks confirming the dying old instance.
|
|
305
|
+
setTimeout(pollStatus, 2000);
|
|
224
306
|
}
|
|
225
307
|
|
|
226
308
|
function _ccIsReconnectableStreamError(err) {
|
package/dashboard.js
CHANGED
|
@@ -8427,6 +8427,141 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8427
8427
|
return;
|
|
8428
8428
|
}
|
|
8429
8429
|
|
|
8430
|
+
// ── QA runs + artifact serving (W-mpeiwz6k0005bf34-b) ──────────────────────
|
|
8431
|
+
// Lifecycle for QA validation runs lives in engine/qa-runs.js. These three
|
|
8432
|
+
// handlers expose: list (with optional ?limit= + ?status= filters), single-
|
|
8433
|
+
// record fetch, and a tightly-sandboxed artifact serving endpoint rooted at
|
|
8434
|
+
// engine/qa-artifacts/.
|
|
8435
|
+
|
|
8436
|
+
// Extension → Content-Type for served artifacts. Anything not listed gets
|
|
8437
|
+
// application/octet-stream (the SPA still renders unknown attachments via
|
|
8438
|
+
// the artifact download link).
|
|
8439
|
+
const _QA_ARTIFACT_MIME = {
|
|
8440
|
+
'.png': 'image/png',
|
|
8441
|
+
'.jpg': 'image/jpeg',
|
|
8442
|
+
'.jpeg': 'image/jpeg',
|
|
8443
|
+
'.webp': 'image/webp',
|
|
8444
|
+
'.gif': 'image/gif',
|
|
8445
|
+
'.mp4': 'video/mp4',
|
|
8446
|
+
'.webm': 'video/webm',
|
|
8447
|
+
'.log': 'text/plain; charset=utf-8',
|
|
8448
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
8449
|
+
'.json': 'application/json; charset=utf-8',
|
|
8450
|
+
};
|
|
8451
|
+
|
|
8452
|
+
function _qaArtifactContentType(filename) {
|
|
8453
|
+
const ext = path.extname(filename || '').toLowerCase();
|
|
8454
|
+
return _QA_ARTIFACT_MIME[ext] || 'application/octet-stream';
|
|
8455
|
+
}
|
|
8456
|
+
|
|
8457
|
+
// Safe-segment validator for runId / file. URL segments must not contain
|
|
8458
|
+
// path separators, traversal markers, null bytes, or look like an absolute
|
|
8459
|
+
// path. Rejecting at the segment level (before any path.join) closes the
|
|
8460
|
+
// traversal hole before resolution can normalize ".." away.
|
|
8461
|
+
function _qaIsSafeSegment(s) {
|
|
8462
|
+
if (typeof s !== 'string') return false;
|
|
8463
|
+
if (!s || s.length > 255) return false;
|
|
8464
|
+
if (s.includes('\0')) return false;
|
|
8465
|
+
if (s.includes('/') || s.includes('\\')) return false;
|
|
8466
|
+
if (s === '.' || s === '..') return false;
|
|
8467
|
+
if (s.includes('..')) return false;
|
|
8468
|
+
if (path.isAbsolute(s)) return false;
|
|
8469
|
+
if (/^[a-zA-Z]:/.test(s)) return false;
|
|
8470
|
+
return true;
|
|
8471
|
+
}
|
|
8472
|
+
|
|
8473
|
+
function handleQaRunsList(req, res) {
|
|
8474
|
+
try {
|
|
8475
|
+
const qa = require('./engine/qa-runs');
|
|
8476
|
+
const u = new URL(req.url, 'http://x');
|
|
8477
|
+
const limitRaw = u.searchParams.get('limit');
|
|
8478
|
+
const statusRaw = u.searchParams.get('status');
|
|
8479
|
+
const opts = {};
|
|
8480
|
+
if (limitRaw != null && limitRaw !== '') {
|
|
8481
|
+
const n = Number(limitRaw);
|
|
8482
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
8483
|
+
return jsonReply(res, 400, { error: 'limit must be a positive number' }, req);
|
|
8484
|
+
}
|
|
8485
|
+
opts.limit = Math.floor(n);
|
|
8486
|
+
}
|
|
8487
|
+
if (statusRaw) {
|
|
8488
|
+
if (!qa.isValidStatus(statusRaw)) {
|
|
8489
|
+
return jsonReply(res, 400, {
|
|
8490
|
+
error: `status must be one of ${Object.values(qa.QA_RUN_STATUS).join(', ')}`,
|
|
8491
|
+
}, req);
|
|
8492
|
+
}
|
|
8493
|
+
opts.status = statusRaw;
|
|
8494
|
+
}
|
|
8495
|
+
const runs = qa.listRuns(opts);
|
|
8496
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
8497
|
+
return jsonReply(res, 200, { runs, generatedAt: new Date().toISOString() }, req);
|
|
8498
|
+
} catch (e) {
|
|
8499
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8500
|
+
}
|
|
8501
|
+
}
|
|
8502
|
+
|
|
8503
|
+
function handleQaRunsById(req, res, match) {
|
|
8504
|
+
try {
|
|
8505
|
+
const qa = require('./engine/qa-runs');
|
|
8506
|
+
const id = decodeURIComponent(match[1] || '');
|
|
8507
|
+
if (!_qaIsSafeSegment(id)) {
|
|
8508
|
+
return jsonReply(res, 400, { error: 'invalid run id' }, req);
|
|
8509
|
+
}
|
|
8510
|
+
const run = qa.getRun(id);
|
|
8511
|
+
if (!run) return jsonReply(res, 404, { error: 'qa run not found' }, req);
|
|
8512
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
8513
|
+
return jsonReply(res, 200, { run }, req);
|
|
8514
|
+
} catch (e) {
|
|
8515
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8516
|
+
}
|
|
8517
|
+
}
|
|
8518
|
+
|
|
8519
|
+
function handleQaArtifact(req, res, match) {
|
|
8520
|
+
try {
|
|
8521
|
+
const qa = require('./engine/qa-runs');
|
|
8522
|
+
const runId = decodeURIComponent(match[1] || '');
|
|
8523
|
+
const file = decodeURIComponent(match[2] || '');
|
|
8524
|
+
// Per-segment validation rejects ../, absolute, and any embedded
|
|
8525
|
+
// separator before path.join — 403 (traversal attempt) vs 404 (miss).
|
|
8526
|
+
if (!_qaIsSafeSegment(runId) || !_qaIsSafeSegment(file)) {
|
|
8527
|
+
return jsonReply(res, 403, { error: 'invalid artifact path (traversal attempt)' }, req);
|
|
8528
|
+
}
|
|
8529
|
+
const base = path.resolve(qa.qaArtifactsDir());
|
|
8530
|
+
const target = path.resolve(base, runId, file);
|
|
8531
|
+
// Belt-and-braces: ensure the resolved absolute path is still under
|
|
8532
|
+
// base. Adding the trailing separator avoids the "/qa-artifacts2/"
|
|
8533
|
+
// prefix-equality bug if a sibling directory ever shares the same
|
|
8534
|
+
// leading characters.
|
|
8535
|
+
const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
|
8536
|
+
if (target !== base && !target.startsWith(baseWithSep)) {
|
|
8537
|
+
return jsonReply(res, 403, { error: 'invalid artifact path (escapes qa-artifacts root)' }, req);
|
|
8538
|
+
}
|
|
8539
|
+
let real;
|
|
8540
|
+
try { real = fs.realpathSync(target); }
|
|
8541
|
+
catch (e) {
|
|
8542
|
+
if (e && e.code === 'ENOENT') return jsonReply(res, 404, { error: 'artifact not found' }, req);
|
|
8543
|
+
throw e;
|
|
8544
|
+
}
|
|
8545
|
+
// Symlinks: realpath must still land under qa-artifacts (after resolving
|
|
8546
|
+
// base symlinks too, so a legitimate symlinked base dir works).
|
|
8547
|
+
const realBase = (() => { try { return fs.realpathSync(base); } catch { return base; } })();
|
|
8548
|
+
const realBaseWithSep = realBase.endsWith(path.sep) ? realBase : realBase + path.sep;
|
|
8549
|
+
if (real !== realBase && !real.startsWith(realBaseWithSep)) {
|
|
8550
|
+
return jsonReply(res, 403, { error: 'artifact symlink points outside qa-artifacts root' }, req);
|
|
8551
|
+
}
|
|
8552
|
+
const stat = fs.statSync(real);
|
|
8553
|
+
if (!stat.isFile()) return jsonReply(res, 404, { error: 'artifact not found' }, req);
|
|
8554
|
+
res.setHeader('Content-Type', _qaArtifactContentType(file));
|
|
8555
|
+
res.setHeader('Content-Length', String(stat.size));
|
|
8556
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
8557
|
+
res.statusCode = 200;
|
|
8558
|
+
fs.createReadStream(real).pipe(res);
|
|
8559
|
+
return;
|
|
8560
|
+
} catch (e) {
|
|
8561
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8562
|
+
}
|
|
8563
|
+
}
|
|
8564
|
+
|
|
8430
8565
|
// ── Route Registry ──────────────────────────────────────────────────────────
|
|
8431
8566
|
// Order matters: specific routes before general ones (e.g., /api/plans/approve before /api/plans/:file)
|
|
8432
8567
|
|
|
@@ -8497,6 +8632,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8497
8632
|
// 64KB initial tail, fs.watchFile poll for appends, auto-close when
|
|
8498
8633
|
// the spec is removed from state.
|
|
8499
8634
|
{ method: 'GET', path: /^\/api\/managed-processes\/log-stream\/([^?]+)$/, template: '/api/managed-processes/log-stream/<name>', desc: 'SSE tail-and-stream of <log_path> for a managed spec (managed-spawn). Optional ?tail=N initial-tail-bytes (default 65536).', handler: handleManagedProcessesLogStream },
|
|
8635
|
+
// QA runs + artifact serving (W-mpeiwz6k0005bf34-b). Run-record lifecycle
|
|
8636
|
+
// lives in engine/qa-runs.js. Artifact serving is sandboxed to
|
|
8637
|
+
// engine/qa-artifacts/ via per-segment validation + realpath check;
|
|
8638
|
+
// traversal attempts return 403, missing files return 404.
|
|
8639
|
+
{ method: 'GET', path: '/api/qa/runs', desc: 'List QA validation runs (newest first). Optional ?limit=N and ?status=pending|running|passed|failed|errored filters.', handler: handleQaRunsList },
|
|
8640
|
+
{ method: 'GET', path: /^\/api\/qa\/runs\/([^/?]+)$/, template: '/api/qa/runs/<id>', desc: 'Fetch a single QA run record by id.', handler: handleQaRunsById },
|
|
8641
|
+
{ method: 'GET', path: /^\/api\/qa\/artifacts\/([^/?]+)\/([^?]+)$/, template: '/api/qa/artifacts/<runId>/<file>', desc: 'Serve a QA artifact file (image/video/log). Sandboxed to engine/qa-artifacts/; rejects path traversal with 403.', handler: handleQaArtifact },
|
|
8500
8642
|
{ method: 'GET', path: '/api/hot-reload', desc: 'SSE stream for dashboard hot-reload notifications', handler: (req, res) => {
|
|
8501
8643
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
8502
8644
|
res.write('data: connected\n\n');
|
|
@@ -9379,6 +9521,17 @@ if (require.main === module) {
|
|
|
9379
9521
|
} catch { /* warm-up is best-effort */ }
|
|
9380
9522
|
});
|
|
9381
9523
|
|
|
9524
|
+
// Pre-warm /api/status cache (W-mpehvbed0018e27d). The CC "Restart Minions"
|
|
9525
|
+
// recovery button polls /api/status and only reloads once the new dashboard
|
|
9526
|
+
// reports a NEW dashboardStartedAt. Without warming, the first /api/status
|
|
9527
|
+
// request (from the button OR from refresh.js OR from the post-reload page
|
|
9528
|
+
// load) triggers a cold getStatus() rebuild that takes 2-5s on Windows+AV
|
|
9529
|
+
// and stalls the freshly-reloaded page in a spinner. Warming on listen
|
|
9530
|
+
// means the first request hits the cached buffer.
|
|
9531
|
+
setImmediate(() => {
|
|
9532
|
+
try { getStatusJson(); } catch { /* warm-up is best-effort */ }
|
|
9533
|
+
});
|
|
9534
|
+
|
|
9382
9535
|
// ─── Engine Watchdog ─────────────────────────────────────────────────────
|
|
9383
9536
|
// Every 30s, check if engine PID is alive. If dead but control.json says
|
|
9384
9537
|
// running, auto-restart it. Prevents silent engine death.
|
package/engine/cooldown.js
CHANGED
|
@@ -44,6 +44,8 @@ function loadCooldowns() {
|
|
|
44
44
|
if (!saved) return;
|
|
45
45
|
const now = Date.now();
|
|
46
46
|
for (const [k, v] of Object.entries(saved)) {
|
|
47
|
+
// Skip malformed entries (null, primitives, missing timestamp)
|
|
48
|
+
if (!v || typeof v !== 'object' || typeof v.timestamp !== 'number') continue;
|
|
47
49
|
// Prune entries older than 24 hours
|
|
48
50
|
if (now - v.timestamp < 24 * 60 * 60 * 1000) {
|
|
49
51
|
dispatchCooldowns.set(k, v);
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/qa-runs.js — Lifecycle + persistence for QA validation runs.
|
|
3
|
+
*
|
|
4
|
+
* One run record represents a single dispatch of a validation runbook against
|
|
5
|
+
* a managed target. Records carry status, timing, work-item linkage, and an
|
|
6
|
+
* artifacts array (screenshots, logs, videos captured during the run).
|
|
7
|
+
*
|
|
8
|
+
* State file: engine/qa-runs.json (single file, all runs across all projects).
|
|
9
|
+
* Artifacts live on disk under engine/qa-artifacts/<runId>/ — the run record
|
|
10
|
+
* stores relative paths (`type`, `path`, `label`, `capturedAt`), and the
|
|
11
|
+
* dashboard exposes them through GET /api/qa/artifacts/<runId>/<file>.
|
|
12
|
+
*
|
|
13
|
+
* Concurrency: every mutation goes through mutateJsonFileLocked per
|
|
14
|
+
* CLAUDE.md best-practice; callbacks are synchronous and never await.
|
|
15
|
+
*
|
|
16
|
+
* Status state machine:
|
|
17
|
+
*
|
|
18
|
+
* pending ──▶ running ──▶ passed
|
|
19
|
+
* ╲──▶ failed
|
|
20
|
+
* ╲──▶ errored
|
|
21
|
+
*
|
|
22
|
+
* Illegal transitions throw with a descriptive error. Terminal statuses
|
|
23
|
+
* (passed / failed / errored) cannot transition further.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const shared = require('./shared');
|
|
29
|
+
const { mutateJsonFileLocked, uid, ts, log } = shared;
|
|
30
|
+
|
|
31
|
+
const QA_RUN_STATUS = Object.freeze({
|
|
32
|
+
PENDING: 'pending',
|
|
33
|
+
RUNNING: 'running',
|
|
34
|
+
PASSED: 'passed',
|
|
35
|
+
FAILED: 'failed',
|
|
36
|
+
ERRORED: 'errored',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const TERMINAL_STATUSES = new Set([
|
|
40
|
+
QA_RUN_STATUS.PASSED,
|
|
41
|
+
QA_RUN_STATUS.FAILED,
|
|
42
|
+
QA_RUN_STATUS.ERRORED,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Allowed forward transitions. Anything not enumerated here is rejected.
|
|
46
|
+
const ALLOWED_TRANSITIONS = {
|
|
47
|
+
[QA_RUN_STATUS.PENDING]: new Set([QA_RUN_STATUS.RUNNING]),
|
|
48
|
+
[QA_RUN_STATUS.RUNNING]: new Set([
|
|
49
|
+
QA_RUN_STATUS.PASSED,
|
|
50
|
+
QA_RUN_STATUS.FAILED,
|
|
51
|
+
QA_RUN_STATUS.ERRORED,
|
|
52
|
+
]),
|
|
53
|
+
[QA_RUN_STATUS.PASSED]: new Set(),
|
|
54
|
+
[QA_RUN_STATUS.FAILED]: new Set(),
|
|
55
|
+
[QA_RUN_STATUS.ERRORED]: new Set(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Dynamic paths — respect MINIONS_TEST_DIR for test isolation.
|
|
59
|
+
function qaRunsPath() {
|
|
60
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function qaArtifactsDir() {
|
|
64
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'qa-artifacts');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function qaArtifactsDirForRun(runId) {
|
|
68
|
+
return path.join(qaArtifactsDir(), runId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isValidStatus(status) {
|
|
72
|
+
return Object.values(QA_RUN_STATUS).includes(status);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateTransition(from, to) {
|
|
76
|
+
if (!isValidStatus(from)) throw new Error(`qa-runs: invalid source status "${from}"`);
|
|
77
|
+
if (!isValidStatus(to)) throw new Error(`qa-runs: invalid target status "${to}"`);
|
|
78
|
+
const allowed = ALLOWED_TRANSITIONS[from];
|
|
79
|
+
if (!allowed.has(to)) {
|
|
80
|
+
throw new Error(`qa-runs: illegal status transition ${from} -> ${to}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeArtifact(a) {
|
|
85
|
+
if (!a || typeof a !== 'object') return null;
|
|
86
|
+
const type = typeof a.type === 'string' ? a.type : '';
|
|
87
|
+
const p = typeof a.path === 'string' ? a.path : '';
|
|
88
|
+
if (!type || !p) return null;
|
|
89
|
+
return {
|
|
90
|
+
type,
|
|
91
|
+
path: p,
|
|
92
|
+
label: typeof a.label === 'string' ? a.label : '',
|
|
93
|
+
capturedAt: typeof a.capturedAt === 'string' && a.capturedAt ? a.capturedAt : ts(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a new run record in 'pending' status. Also pre-creates the per-run
|
|
99
|
+
* artifact directory so the agent can drop files into it.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} opts
|
|
102
|
+
* @param {string} opts.runbookId - id of the runbook being executed
|
|
103
|
+
* @param {string} opts.targetName - managed-spawn target name being validated
|
|
104
|
+
* @param {string} [opts.project] - project name (optional)
|
|
105
|
+
* @param {string} [opts.workItemId] - originating work-item id (optional)
|
|
106
|
+
* @returns {object} the created run record
|
|
107
|
+
*/
|
|
108
|
+
function createRun({ runbookId, targetName, project, workItemId } = {}) {
|
|
109
|
+
if (!runbookId || typeof runbookId !== 'string') throw new Error('qa-runs: runbookId is required');
|
|
110
|
+
if (!targetName || typeof targetName !== 'string') throw new Error('qa-runs: targetName is required');
|
|
111
|
+
|
|
112
|
+
const run = {
|
|
113
|
+
id: 'qarun-' + uid(),
|
|
114
|
+
runbookId,
|
|
115
|
+
targetName,
|
|
116
|
+
project: project || null,
|
|
117
|
+
workItemId: workItemId || null,
|
|
118
|
+
status: QA_RUN_STATUS.PENDING,
|
|
119
|
+
startedAt: null,
|
|
120
|
+
completedAt: null,
|
|
121
|
+
artifacts: [],
|
|
122
|
+
summary: null,
|
|
123
|
+
createdAt: ts(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
mutateJsonFileLocked(qaRunsPath(), (runs) => {
|
|
127
|
+
if (!Array.isArray(runs)) runs = [];
|
|
128
|
+
runs.push(run);
|
|
129
|
+
return runs;
|
|
130
|
+
}, { defaultValue: [] });
|
|
131
|
+
|
|
132
|
+
// Pre-create the artifact directory outside the lock — directory creation
|
|
133
|
+
// is idempotent and slow file I/O must never run while holding the lock.
|
|
134
|
+
try { fs.mkdirSync(qaArtifactsDirForRun(run.id), { recursive: true }); } catch (e) {
|
|
135
|
+
log('warn', `qa-runs: mkdir artifacts dir failed for ${run.id}: ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return run;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Transition pending -> running. Stamps startedAt. Throws on illegal source state.
|
|
143
|
+
* @param {string} id
|
|
144
|
+
* @returns {object} updated run
|
|
145
|
+
*/
|
|
146
|
+
function markRunning(id) {
|
|
147
|
+
if (!id) throw new Error('qa-runs: id is required');
|
|
148
|
+
let captured = null;
|
|
149
|
+
let transitionError = null;
|
|
150
|
+
mutateJsonFileLocked(qaRunsPath(), (runs) => {
|
|
151
|
+
if (!Array.isArray(runs)) runs = [];
|
|
152
|
+
const run = runs.find(r => r && r.id === id);
|
|
153
|
+
if (!run) { transitionError = new Error(`qa-runs: run not found: ${id}`); return runs; }
|
|
154
|
+
try { validateTransition(run.status, QA_RUN_STATUS.RUNNING); }
|
|
155
|
+
catch (e) { transitionError = e; return runs; }
|
|
156
|
+
run.status = QA_RUN_STATUS.RUNNING;
|
|
157
|
+
run.startedAt = ts();
|
|
158
|
+
captured = run;
|
|
159
|
+
return runs;
|
|
160
|
+
}, { defaultValue: [] });
|
|
161
|
+
if (transitionError) throw transitionError;
|
|
162
|
+
return captured;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Transition running -> passed|failed|errored. Stamps completedAt, records
|
|
167
|
+
* summary, appends artifacts. Throws on illegal source state.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} id
|
|
170
|
+
* @param {object} opts
|
|
171
|
+
* @param {string} opts.status - 'passed' | 'failed' | 'errored'
|
|
172
|
+
* @param {string} [opts.summary] - human-readable summary
|
|
173
|
+
* @param {Array<object>} [opts.artifacts] - artifact records {type, path, label?, capturedAt?}
|
|
174
|
+
* @returns {object} updated run
|
|
175
|
+
*/
|
|
176
|
+
function completeRun(id, { status, summary, artifacts } = {}) {
|
|
177
|
+
if (!id) throw new Error('qa-runs: id is required');
|
|
178
|
+
if (!TERMINAL_STATUSES.has(status)) {
|
|
179
|
+
throw new Error(`qa-runs: completeRun status must be one of ${[...TERMINAL_STATUSES].join(', ')}`);
|
|
180
|
+
}
|
|
181
|
+
const normalized = Array.isArray(artifacts)
|
|
182
|
+
? artifacts.map(normalizeArtifact).filter(Boolean)
|
|
183
|
+
: [];
|
|
184
|
+
|
|
185
|
+
let captured = null;
|
|
186
|
+
let transitionError = null;
|
|
187
|
+
mutateJsonFileLocked(qaRunsPath(), (runs) => {
|
|
188
|
+
if (!Array.isArray(runs)) runs = [];
|
|
189
|
+
const run = runs.find(r => r && r.id === id);
|
|
190
|
+
if (!run) { transitionError = new Error(`qa-runs: run not found: ${id}`); return runs; }
|
|
191
|
+
try { validateTransition(run.status, status); }
|
|
192
|
+
catch (e) { transitionError = e; return runs; }
|
|
193
|
+
run.status = status;
|
|
194
|
+
run.completedAt = ts();
|
|
195
|
+
if (typeof summary === 'string') run.summary = summary;
|
|
196
|
+
if (normalized.length) {
|
|
197
|
+
if (!Array.isArray(run.artifacts)) run.artifacts = [];
|
|
198
|
+
run.artifacts.push(...normalized);
|
|
199
|
+
}
|
|
200
|
+
captured = run;
|
|
201
|
+
return runs;
|
|
202
|
+
}, { defaultValue: [] });
|
|
203
|
+
if (transitionError) throw transitionError;
|
|
204
|
+
return captured;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Lookup a single run by id.
|
|
209
|
+
* @param {string} id
|
|
210
|
+
* @returns {object|null}
|
|
211
|
+
*/
|
|
212
|
+
function getRun(id) {
|
|
213
|
+
if (!id) return null;
|
|
214
|
+
const runs = shared.safeJsonArr(qaRunsPath());
|
|
215
|
+
const run = runs.find(r => r && r.id === id);
|
|
216
|
+
return run || null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* List runs, newest first, optionally filtered by status, capped by limit.
|
|
221
|
+
* @param {object} [opts]
|
|
222
|
+
* @param {number} [opts.limit] - max records to return (no cap when omitted)
|
|
223
|
+
* @param {string} [opts.status] - filter by status
|
|
224
|
+
* @returns {Array<object>}
|
|
225
|
+
*/
|
|
226
|
+
function listRuns({ limit, status } = {}) {
|
|
227
|
+
let runs = shared.safeJsonArr(qaRunsPath());
|
|
228
|
+
if (!Array.isArray(runs)) return [];
|
|
229
|
+
if (status) {
|
|
230
|
+
if (!isValidStatus(status)) return [];
|
|
231
|
+
runs = runs.filter(r => r && r.status === status);
|
|
232
|
+
}
|
|
233
|
+
// Newest first by createdAt (fallback to id for ties — uid() is monotonic).
|
|
234
|
+
runs = runs.slice().sort((a, b) => {
|
|
235
|
+
const ac = (a && a.createdAt) || '';
|
|
236
|
+
const bc = (b && b.createdAt) || '';
|
|
237
|
+
if (ac === bc) return ((b && b.id) || '').localeCompare((a && a.id) || '');
|
|
238
|
+
return ac < bc ? 1 : -1;
|
|
239
|
+
});
|
|
240
|
+
const n = Number(limit);
|
|
241
|
+
if (Number.isFinite(n) && n > 0) runs = runs.slice(0, Math.floor(n));
|
|
242
|
+
return runs;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* All runs linked to a work item, newest first.
|
|
247
|
+
* @param {string} wi - work-item id
|
|
248
|
+
* @returns {Array<object>}
|
|
249
|
+
*/
|
|
250
|
+
function getRunsForWorkItem(wi) {
|
|
251
|
+
if (!wi) return [];
|
|
252
|
+
const runs = shared.safeJsonArr(qaRunsPath());
|
|
253
|
+
return runs
|
|
254
|
+
.filter(r => r && r.workItemId === wi)
|
|
255
|
+
.sort((a, b) => {
|
|
256
|
+
const ac = a.createdAt || '';
|
|
257
|
+
const bc = b.createdAt || '';
|
|
258
|
+
return ac < bc ? 1 : -1;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
QA_RUN_STATUS,
|
|
264
|
+
TERMINAL_STATUSES,
|
|
265
|
+
ALLOWED_TRANSITIONS,
|
|
266
|
+
qaRunsPath,
|
|
267
|
+
qaArtifactsDir,
|
|
268
|
+
qaArtifactsDirForRun,
|
|
269
|
+
createRun,
|
|
270
|
+
markRunning,
|
|
271
|
+
completeRun,
|
|
272
|
+
getRun,
|
|
273
|
+
listRuns,
|
|
274
|
+
getRunsForWorkItem,
|
|
275
|
+
// Exposed for tests:
|
|
276
|
+
validateTransition,
|
|
277
|
+
isValidStatus,
|
|
278
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1994",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|