@yemi33/minions 0.1.1993 → 0.1.1995

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 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');
package/engine/ado.js CHANGED
@@ -378,6 +378,15 @@ function applyAdoPrMetadata(pr, prData) {
378
378
  updated = true;
379
379
  }
380
380
 
381
+ // W-mpej044m00076d63: backfill `pr.created` from ADO's real creation
382
+ // timestamp. lifecycle.js attach paths omit `created` so this poll is the
383
+ // single source of truth for fresh records, and historical records missing
384
+ // `created` get backfilled the next time they're polled. Idempotent.
385
+ if (!pr.created && prData.creationDate) {
386
+ pr.created = prData.creationDate;
387
+ updated = true;
388
+ }
389
+
381
390
  return updated;
382
391
  }
383
392
 
@@ -720,6 +729,18 @@ async function pollPrStatus(config) {
720
729
  pr.status = newStatus;
721
730
  updated = true;
722
731
 
732
+ // W-mpej044m00076d63: persist platform close/merge timestamps on the
733
+ // normal-poll status flip. ADO does not split mergedAt vs closedAt at
734
+ // the API level — `closedDate` is set whenever a PR moves out of
735
+ // `active` (whether via merge or abandon). Mirror that into our own
736
+ // fields with the standard idempotency guard.
737
+ if (newStatus === PR_STATUS.MERGED && !pr.mergedAt) {
738
+ pr.mergedAt = prData.closedDate || ts();
739
+ }
740
+ if ((newStatus === PR_STATUS.ABANDONED || newStatus === PR_STATUS.CLOSED) && !pr.closedAt) {
741
+ pr.closedAt = prData.closedDate || ts();
742
+ }
743
+
723
744
  if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
724
745
  if (pr.reviewStatus === 'waiting') {
725
746
  pr.reviewStatus = newStatus === PR_STATUS.MERGED ? 'approved' : 'pending';
@@ -1070,7 +1091,7 @@ async function pollPrHumanComments(config) {
1070
1091
  const threadsData = await adoFetch(threadsUrl, token);
1071
1092
  const threads = threadsData.value || [];
1072
1093
 
1073
- const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || '1970-01-01';
1094
+ const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
1074
1095
  const cutoffMs = new Date(cutoffStr).getTime() || 0;
1075
1096
 
1076
1097
  // Collect ALL human comments on the PR for full context. `allCommentDates`
@@ -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);
package/engine/github.js CHANGED
@@ -661,6 +661,16 @@ async function pollPrStatus(config) {
661
661
  else if (prData.state === 'closed') newStatus = PR_STATUS.ABANDONED;
662
662
  else if (prData.state === 'open') newStatus = PR_STATUS.ACTIVE;
663
663
 
664
+ // W-mpej044m00076d63: backfill `pr.created` from the platform's real
665
+ // creation timestamp. lifecycle.js attach paths deliberately omit
666
+ // `created` so this poll is the single source of truth — and historical
667
+ // records that pre-date the fix get backfilled the next time they're
668
+ // polled. Idempotent: only writes when missing.
669
+ if (!pr.created && prData.created_at) {
670
+ pr.created = prData.created_at;
671
+ updated = true;
672
+ }
673
+
664
674
  // Track head SHA changes to detect new pushes (used for review re-dispatch gating)
665
675
  if (prData.head?.sha && pr.headSha !== prData.head.sha) {
666
676
  pr.headSha = prData.head.sha;
@@ -745,6 +755,17 @@ async function pollPrStatus(config) {
745
755
  pr.status = newStatus;
746
756
  updated = true;
747
757
 
758
+ // W-mpej044m00076d63: persist platform close/merge timestamps on the
759
+ // normal-poll status flip (previously only the abandoned-reconciliation
760
+ // path at ~line 1489 set mergedAt). Idempotency guard prevents a later
761
+ // reconciliation pass from overwriting an earlier-known value.
762
+ if (newStatus === PR_STATUS.MERGED && !pr.mergedAt) {
763
+ pr.mergedAt = prData.merged_at || prData.closed_at || ts();
764
+ }
765
+ if ((newStatus === PR_STATUS.ABANDONED || newStatus === PR_STATUS.CLOSED) && !pr.closedAt) {
766
+ pr.closedAt = prData.closed_at || ts();
767
+ }
768
+
748
769
  if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
749
770
  // Resolve stale 'waiting' review status — won't be polled again after this
750
771
  if (pr.reviewStatus === 'waiting') {
@@ -961,7 +982,7 @@ async function pollPrHumanComments(config) {
961
982
  // fixture already populated the field.
962
983
  _backfillViewerDidAuthor(allComments, viewerLogin);
963
984
 
964
- const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || '1970-01-01';
985
+ const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
965
986
  const cutoffMs = new Date(cutoffStr).getTime() || 0;
966
987
 
967
988
  // Collect comments that should advance the cutoff separately from comments
@@ -907,7 +907,11 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
907
907
  branch: meta?.branch || '',
908
908
  reviewStatus: 'pending',
909
909
  status: PR_STATUS.ACTIVE,
910
- created: ts(),
910
+ // W-mpej044m00076d63: do NOT seed `created` with ts() (engine discovery time).
911
+ // The next GitHub/ADO poll backfills `pr.created` from the platform's real
912
+ // creation timestamp; `_attachedAt` is preserved as a fallback for downstream
913
+ // cutoffs (e.g. comment-poll cutoffStr) that need any timestamp at all.
914
+ _attachedAt: ts(),
911
915
  url: prUrl,
912
916
  prdItems: meta?.item?.id ? [meta.item.id] : [],
913
917
  sourcePlan: meta?.item?.sourcePlan || '',
@@ -1221,7 +1225,9 @@ function _attachFoundPrToWi(found, meta, agentId, resultSummary, config) {
1221
1225
  branch: meta.branch || '',
1222
1226
  reviewStatus: 'pending',
1223
1227
  status: PR_STATUS.ACTIVE,
1224
- created: ts(),
1228
+ // W-mpej044m00076d63: omit `created` — the next poll backfills from the
1229
+ // platform's real createdAt. `_attachedAt` is the discovery-time fallback.
1230
+ _attachedAt: ts(),
1225
1231
  url: found.url,
1226
1232
  prdItems: [meta.item.id],
1227
1233
  sourcePlan: meta.item?.sourcePlan || '',
@@ -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/engine/queries.js CHANGED
@@ -673,12 +673,22 @@ function getPullRequests(config) {
673
673
  }
674
674
  }
675
675
  allPrs.sort((a, b) => {
676
- // Normalize to YYYY-MM-DD for date comparison (some have full ISO, some date-only)
677
- const aDate = (a.created || '').slice(0, 10);
678
- const bDate = (b.created || '').slice(0, 10);
679
- const dateComp = bDate.localeCompare(aDate);
680
- if (dateComp !== 0) return dateComp;
681
- // Same date sort by PR number descending (newest first)
676
+ // W-mpej044m00076d63: sort by the full ISO `created` timestamp DESC so
677
+ // same-day PRs preserve creation order (previously the slice-to-date
678
+ // collapsed every PR opened on the same day into one bucket, then tied
679
+ // on a noisy PR-number-derived integer that ignored owner/repo digits).
680
+ // The PR-number tiebreaker survives only as a guard for records that
681
+ // legitimately lack `created` (e.g. mid-poll backfill window).
682
+ const aCreated = a.created || '';
683
+ const bCreated = b.created || '';
684
+ if (aCreated && bCreated) {
685
+ const cmp = bCreated.localeCompare(aCreated);
686
+ if (cmp !== 0) return cmp;
687
+ } else if (aCreated || bCreated) {
688
+ // One missing — surface the one with a timestamp first (newer signal).
689
+ return bCreated ? 1 : -1;
690
+ }
691
+ // Final guard for missing-data records: keep PR-number desc (newer first).
682
692
  const aNum = parseInt((a.id || '').replace(/\D/g, '')) || 0;
683
693
  const bNum = parseInt((b.id || '').replace(/\D/g, '')) || 0;
684
694
  return bNum - aNum;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1993",
3
+ "version": "0.1.1995",
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"