@yemi33/minions 0.1.2070 → 0.1.2072

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
@@ -1797,6 +1797,16 @@ function _buildStatusFastState() {
1797
1797
  // helper returns { total, sig } without sorting; that's all the sidebar
1798
1798
  // counter needs to detect new runs and status flips.
1799
1799
  qaRuns: _safeStatusSlice('qaRuns', () => qaRunsMod.summarizeRunsForStatus(), { total: 0, sig: '' }),
1800
+ // QA sessions — same role as qaRuns above. The sidebar activity-dot
1801
+ // for QA Sessions polls this slice for the cheap { total, sig } summary
1802
+ // so a new session or a state transition lights the dot within one
1803
+ // /api/status cycle. summarizeSessionsForStatus is unsorted (mirrors
1804
+ // qaRunsMod.summarizeRunsForStatus) so this stays O(N) and doesn't
1805
+ // charge sort cost to the /api/status hot path.
1806
+ qaSessions: _safeStatusSlice('qaSessions', () => {
1807
+ const qaSessions = require('./engine/qa-sessions');
1808
+ return qaSessions.summarizeSessionsForStatus();
1809
+ }, { total: 0, sig: '' }),
1800
1810
  };
1801
1811
  }
1802
1812
 
@@ -6110,6 +6120,17 @@ const server = http.createServer(async (req, res) => {
6110
6120
 
6111
6121
  async function handleKnowledgeRead(req, res, match) {
6112
6122
  const cat = match[1];
6123
+ // P-bfa2b-kb-path-traversal — whitelist the category param BEFORE any
6124
+ // path.join. Without this, cat='..' collapses kbCatDir to MINIONS_DIR
6125
+ // (path.join normalizes '..') and the next sanitizePath('config.json',
6126
+ // MINIONS_DIR) check still passes, allowing safeRead to disclose
6127
+ // config.json / work-items.json / engine state to any browser the
6128
+ // operator has open. The whitelist also rejects encoded variants
6129
+ // (`%2e%2e`, `..%2f`, etc.) since none of those are valid category
6130
+ // names. Single source of truth lives in shared.KB_READABLE_CATEGORIES.
6131
+ if (!shared.KB_READABLE_CATEGORIES.includes(cat)) {
6132
+ return jsonReply(res, 400, { error: 'invalid category' });
6133
+ }
6113
6134
  const file = decodeURIComponent(match[2]);
6114
6135
  // Prevent path traversal
6115
6136
  const kbCatDir = path.join(MINIONS_DIR, 'knowledge', cat);
@@ -9571,6 +9592,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9571
9592
  }
9572
9593
  const keepProcessSweep = require('./engine/keep-process-sweep');
9573
9594
  const filePath = path.join(MINIONS_DIR, 'agents', agentId, keepProcessSweep.KEEP_PIDS_FILENAME);
9595
+ // Pre-lock validation: existence + parse + pid-present. Surfaces clean
9596
+ // 404/400 responses without taking the lock when there's nothing to
9597
+ // mutate. The authoritative read-modify-write happens inside
9598
+ // mutateJsonFileLocked below — that callback re-reads the on-disk state
9599
+ // so a concurrent kill that already removed our pid is handled safely.
9574
9600
  let raw;
9575
9601
  try { raw = fs.readFileSync(filePath, 'utf8'); }
9576
9602
  catch { return jsonReply(res, 404, { error: 'keep-pids.json not found for agent' }, req); }
@@ -9581,17 +9607,38 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9581
9607
  if (!pidsBefore.includes(pidNum)) {
9582
9608
  return jsonReply(res, 404, { error: `pid ${pidNum} not declared in ${filePath}` }, req);
9583
9609
  }
9610
+ // Kill OUTSIDE the lock — process kills (and any other syscalls beyond
9611
+ // the file mutation) must not live inside a mutateJsonFileLocked
9612
+ // callback per the lock-discipline rule in copilot-instructions.md.
9584
9613
  const killed = shared.killByPidImmediate(pidNum);
9585
- const remaining = pidsBefore.filter((p) => p !== pidNum);
9614
+ // P-bfa1b wrap the keep-pids.json read-modify-write in
9615
+ // mutateJsonFileLocked so two concurrent /api/keep-processes/kill
9616
+ // calls cannot each read the pre-mutation pids[] snapshot and clobber
9617
+ // each other's writes. The callback is synchronous, mutation-only:
9618
+ // no kills, no awaits, no network. The unlink (when remaining is
9619
+ // empty) runs AFTER the lock releases.
9620
+ let remaining = pidsBefore.filter((p) => p !== pidNum);
9621
+ let shouldUnlink = false;
9622
+ try {
9623
+ mutateJsonFileLocked(filePath, (data) => {
9624
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
9625
+ const currentPids = Array.isArray(data.pids) ? data.pids.map(Number) : [];
9626
+ remaining = currentPids.filter((p) => p !== pidNum);
9627
+ if (remaining.length === 0) {
9628
+ shouldUnlink = true;
9629
+ return data;
9630
+ }
9631
+ data.pids = remaining;
9632
+ return data;
9633
+ }, { skipWriteIfUnchanged: true });
9634
+ } catch (e) {
9635
+ return jsonReply(res, 500, { error: `failed to update keep-pids.json: ${e.message}` }, req);
9636
+ }
9586
9637
  let action;
9587
- if (remaining.length === 0) {
9638
+ if (shouldUnlink) {
9588
9639
  try { fs.unlinkSync(filePath); } catch {}
9589
9640
  action = 'killed-and-removed-file';
9590
9641
  } else {
9591
- parsed.pids = remaining;
9592
- try { fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2)); } catch (e) {
9593
- return jsonReply(res, 500, { error: `failed to update keep-pids.json: ${e.message}` }, req);
9594
- }
9595
9642
  action = 'killed-and-updated-file';
9596
9643
  }
9597
9644
  shared.log('info', `keep-processes manual kill: agent=${agentId} pid=${pidNum} killed=${killed} action=${action}`);
@@ -10166,6 +10213,348 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10166
10213
  }
10167
10214
  }
10168
10215
 
10216
+ // ── QA Sessions + Runners endpoints (P-d2f5a8c9) ────────────────────────────
10217
+ // Thin HTTP wrappers over engine/qa-sessions.js + engine/qa-runners.js. The
10218
+ // lifecycle (state machine, work-item builders, dispatch chain) lives in
10219
+ // those modules; these handlers translate HTTP into the right call,
10220
+ // resolve project/wiPath the same way handleQaRunbookRun does, and shape
10221
+ // qa-sessions thrown errors into 400/404/409 instead of 500.
10222
+ //
10223
+ // Endpoint summary:
10224
+ // POST /api/qa/session create + queueSetup (pending→spawning)
10225
+ // GET /api/qa/sessions list (?limit= ?state=)
10226
+ // GET /api/qa/sessions/<id> fetch single
10227
+ // POST /api/qa/sessions/<id>/approve awaiting-approval → executing
10228
+ // POST /api/qa/sessions/<id>/edit awaiting-approval → drafting (with feedback)
10229
+ // POST /api/qa/sessions/<id>/cancel any non-terminal → killed
10230
+ // POST /api/qa/sessions/<id>/kill cancel + kill managed-spawn
10231
+ // POST /api/qa/sessions/<id>/dismiss non-terminal → done
10232
+ // GET /api/qa/runners list registered runner adapters
10233
+ // POST /api/qa/runners/reload clear registry + re-register + re-scan plugins
10234
+
10235
+ // Map a qa-sessions thrown error to an HTTP status. The module throws
10236
+ // descriptive strings on illegal transitions / missing sessions; pre-PR4
10237
+ // callers swallowed these as 500 — that loses the error semantics we need
10238
+ // (404 for unknown sessions, 409 for wrong state, 400 for spec/feedback
10239
+ // violations).
10240
+ function _qaSessionsErrorToStatus(err) {
10241
+ const msg = (err && err.message) || '';
10242
+ if (/session not found/i.test(msg)) return 404;
10243
+ if (/unsafe sessionId/i.test(msg)) return 400;
10244
+ if (/illegal state transition/i.test(msg)) return 409;
10245
+ if (/requires state /i.test(msg)) return 409;
10246
+ if (/requires non-terminal/i.test(msg)) return 409;
10247
+ if (/invalid spec|requires |exceeds /i.test(msg)) return 400;
10248
+ return 500;
10249
+ }
10250
+
10251
+ // Resolve the (project, wiPath) tuple for a session create/approve/edit
10252
+ // request. session.spec.project (or the create-time project field) decides
10253
+ // which work-items.json receives the new WI. central → root-level
10254
+ // work-items.json. Returns { error } on resolution failure so the handler
10255
+ // can reply 400.
10256
+ function _qaSessionsResolveTarget(projectName) {
10257
+ const target = resolveWorkItemsCreateTarget(projectName || '');
10258
+ if (target.error) return { error: target.error };
10259
+ return { wiPath: target.wiPath, project: target.project ? target.project.name : null };
10260
+ }
10261
+
10262
+ async function handleQaSessionCreate(req, res) {
10263
+ try {
10264
+ const body = await readBody(req);
10265
+ // Validate first so a bad spec returns 400 BEFORE we touch state. The
10266
+ // body shape is identical to qaSessions.validateSpec — target, flowsRaw,
10267
+ // mode, capture, runner, project, createdBy — so we forward it
10268
+ // verbatim.
10269
+ const qaSessions = require('./engine/qa-sessions');
10270
+ const v = qaSessions.validateSpec(body);
10271
+ if (!v.ok) return jsonReply(res, 400, { error: 'invalid spec', details: v.errors }, req);
10272
+
10273
+ // Stamp the operator identity from the standard tracing header so the
10274
+ // session record carries the same _originAgent/_originWi audit trail
10275
+ // POST /api/work-items uses. Skipped when the header is missing — the
10276
+ // session record's createdBy stays null.
10277
+ const originAgent = req.headers['x-minions-agent'];
10278
+ const createSpec = { ...body };
10279
+ if (originAgent && typeof originAgent === 'string' && createSpec.createdBy === undefined) {
10280
+ createSpec.createdBy = originAgent;
10281
+ }
10282
+
10283
+ const resolved = _qaSessionsResolveTarget(createSpec.project);
10284
+ if (resolved.error) return jsonReply(res, 400, { error: resolved.error }, req);
10285
+
10286
+ let session;
10287
+ try { session = qaSessions.createSession(createSpec); }
10288
+ catch (e) { return jsonReply(res, 400, { error: e.message, details: e.validationErrors }, req); }
10289
+
10290
+ let setupWiId = null;
10291
+ try {
10292
+ setupWiId = qaSessions.queueSetup(session.id, {
10293
+ wiPath: resolved.wiPath,
10294
+ project: resolved.project,
10295
+ });
10296
+ } catch (e) {
10297
+ // queueSetup throws when pending→spawning is rejected (createSession
10298
+ // succeeded but transition failed — e.g. concurrent call). Surface
10299
+ // the session id so the caller can poll /api/qa/sessions/<id>; the
10300
+ // session record itself was persisted.
10301
+ return jsonReply(res, _qaSessionsErrorToStatus(e), {
10302
+ error: e.message,
10303
+ sessionId: session.id,
10304
+ }, req);
10305
+ }
10306
+
10307
+ invalidateStatusCache();
10308
+ return jsonReply(res, 200, {
10309
+ sessionId: session.id,
10310
+ state: 'spawning',
10311
+ setupWorkItemId: setupWiId,
10312
+ managedSpawnName: session.managedSpawnName,
10313
+ }, req);
10314
+ } catch (e) {
10315
+ return jsonReply(res, 500, { error: e.message }, req);
10316
+ }
10317
+ }
10318
+
10319
+ function handleQaSessionsList(req, res) {
10320
+ try {
10321
+ const qaSessions = require('./engine/qa-sessions');
10322
+ const u = new URL(req.url, 'http://x');
10323
+ const limitRaw = u.searchParams.get('limit');
10324
+ const stateRaw = u.searchParams.get('state');
10325
+ const opts = {};
10326
+ if (limitRaw != null && limitRaw !== '') {
10327
+ const n = Number(limitRaw);
10328
+ if (!Number.isFinite(n) || n <= 0) {
10329
+ return jsonReply(res, 400, { error: 'limit must be a positive number' }, req);
10330
+ }
10331
+ opts.limit = Math.floor(n);
10332
+ }
10333
+ if (stateRaw) {
10334
+ if (!qaSessions.isValidState(stateRaw)) {
10335
+ return jsonReply(res, 400, {
10336
+ error: `state must be one of ${Object.values(qaSessions.QA_SESSION_STATE).join(', ')}`,
10337
+ }, req);
10338
+ }
10339
+ opts.state = stateRaw;
10340
+ }
10341
+ const sessions = qaSessions.listSessions(opts);
10342
+ return jsonReply(res, 200, { sessions, generatedAt: new Date().toISOString() }, req);
10343
+ } catch (e) {
10344
+ return jsonReply(res, 500, { error: e.message }, req);
10345
+ }
10346
+ }
10347
+
10348
+ function handleQaSessionsById(req, res, match) {
10349
+ try {
10350
+ const qaSessions = require('./engine/qa-sessions');
10351
+ const id = decodeURIComponent(match[1] || '');
10352
+ // Reuse the qa-artifacts safe-segment guard — sessionId travels into
10353
+ // qa-tests/<id>/ paths inside qaSessions and is already validated by
10354
+ // _isSafeSessionId there. The handler-level guard avoids a thrown
10355
+ // error before getSession even runs.
10356
+ if (!_qaIsSafeSegment(id)) return jsonReply(res, 400, { error: 'invalid session id' }, req);
10357
+ const session = qaSessions.getSession(id);
10358
+ if (!session) return jsonReply(res, 404, { error: 'qa session not found' }, req);
10359
+ return jsonReply(res, 200, { session }, req);
10360
+ } catch (e) {
10361
+ return jsonReply(res, 500, { error: e.message }, req);
10362
+ }
10363
+ }
10364
+
10365
+ // Shared helper for approve + edit + cancel + kill + dismiss — each takes
10366
+ // a sessionId from the URL and a body, delegates to qaSessions.* and maps
10367
+ // module errors to HTTP statuses.
10368
+ async function _qaSessionAction(req, res, match, action) {
10369
+ try {
10370
+ const qaSessions = require('./engine/qa-sessions');
10371
+ const id = decodeURIComponent((match && match[1]) || '');
10372
+ if (!_qaIsSafeSegment(id)) return jsonReply(res, 400, { error: 'invalid session id' }, req);
10373
+ const session = qaSessions.getSession(id);
10374
+ if (!session) return jsonReply(res, 404, { error: 'qa session not found' }, req);
10375
+ const body = await readBody(req);
10376
+ return await action(qaSessions, session, body);
10377
+ } catch (e) {
10378
+ return jsonReply(res, _qaSessionsErrorToStatus(e), { error: e.message }, req);
10379
+ }
10380
+ }
10381
+
10382
+ // POST /api/qa/sessions/<id>/approve — awaiting-approval → executing. The
10383
+ // handler creates the qa-runs record server-side (mirrors handleQaRunbookRun)
10384
+ // so the caller doesn't have to. The synthetic runbookId is
10385
+ // `qa-session-<id>` so the qa-runs view can distinguish session-spawned
10386
+ // runs from runbook-spawned ones at a glance.
10387
+ async function handleQaSessionApprove(req, res, match) {
10388
+ return _qaSessionAction(req, res, match, async (qaSessions, session) => {
10389
+ const resolved = _qaSessionsResolveTarget(session.spec.project);
10390
+ if (resolved.error) return jsonReply(res, 400, { error: resolved.error }, req);
10391
+
10392
+ const qaRuns = require('./engine/qa-runs');
10393
+ const run = qaRuns.createRun({
10394
+ runbookId: 'qa-session-' + session.id,
10395
+ targetName: session.managedSpawnName,
10396
+ project: resolved.project || session.spec.project || null,
10397
+ workItemId: null, // back-filled below
10398
+ });
10399
+
10400
+ let executeWiId;
10401
+ try {
10402
+ executeWiId = qaSessions.approveDraft(session.id, {
10403
+ wiPath: resolved.wiPath,
10404
+ qaRunId: run.id,
10405
+ project: resolved.project,
10406
+ });
10407
+ } catch (e) {
10408
+ return jsonReply(res, _qaSessionsErrorToStatus(e), { error: e.message }, req);
10409
+ }
10410
+
10411
+ try { qaRuns.setRunWorkItemId(run.id, executeWiId); }
10412
+ catch (_e) { /* non-fatal — engine still resolves run by id */ }
10413
+
10414
+ // Also stamp the qaRunId onto the session record so /api/qa/sessions/<id>
10415
+ // shows the linked run without forcing the client to traverse WI meta.
10416
+ try { qaSessions.setSessionQaRunId(session.id, run.id); } catch (_e) { /* non-fatal */ }
10417
+
10418
+ invalidateStatusCache();
10419
+ return jsonReply(res, 200, {
10420
+ sessionId: session.id,
10421
+ state: 'executing',
10422
+ executeWorkItemId: executeWiId,
10423
+ qaRunId: run.id,
10424
+ }, req);
10425
+ });
10426
+ }
10427
+
10428
+ // POST /api/qa/sessions/<id>/edit — awaiting-approval → drafting. The body
10429
+ // must include non-empty `feedback` (≤ qaSessions.LIMITS.feedbackMax) that
10430
+ // re-fires DRAFT with the user's natural-language steering.
10431
+ async function handleQaSessionEdit(req, res, match) {
10432
+ return _qaSessionAction(req, res, match, async (qaSessions, session, body) => {
10433
+ const feedback = body && typeof body.feedback === 'string' ? body.feedback : '';
10434
+ if (!feedback.trim()) return jsonReply(res, 400, { error: 'feedback required' }, req);
10435
+
10436
+ const resolved = _qaSessionsResolveTarget(session.spec.project);
10437
+ if (resolved.error) return jsonReply(res, 400, { error: resolved.error }, req);
10438
+
10439
+ let draftWiId;
10440
+ try {
10441
+ draftWiId = qaSessions.editDraft(session.id, {
10442
+ wiPath: resolved.wiPath,
10443
+ feedback,
10444
+ project: resolved.project,
10445
+ });
10446
+ } catch (e) {
10447
+ return jsonReply(res, _qaSessionsErrorToStatus(e), { error: e.message }, req);
10448
+ }
10449
+
10450
+ invalidateStatusCache();
10451
+ return jsonReply(res, 200, {
10452
+ sessionId: session.id,
10453
+ state: 'drafting',
10454
+ draftWorkItemId: draftWiId,
10455
+ }, req);
10456
+ });
10457
+ }
10458
+
10459
+ // POST /api/qa/sessions/<id>/cancel — any non-terminal → killed. Does NOT
10460
+ // touch the managed-spawn (use /kill if the spawn must die too). Idempotent
10461
+ // on already-terminal sessions: returns 409 with the current state so the
10462
+ // caller knows the operation was a no-op.
10463
+ async function handleQaSessionCancel(req, res, match) {
10464
+ return _qaSessionAction(req, res, match, async (qaSessions, session, body) => {
10465
+ const reason = body && typeof body.reason === 'string' ? body.reason : null;
10466
+ let updated;
10467
+ try { updated = qaSessions.cancelSession(session.id, { reason }); }
10468
+ catch (e) { return jsonReply(res, _qaSessionsErrorToStatus(e), { error: e.message }, req); }
10469
+ invalidateStatusCache();
10470
+ return jsonReply(res, 200, {
10471
+ sessionId: session.id,
10472
+ state: updated && updated.state,
10473
+ }, req);
10474
+ });
10475
+ }
10476
+
10477
+ // POST /api/qa/sessions/<id>/kill — same as cancel but also kills the
10478
+ // managed-spawn via removeManagedSpec(). Best-effort on the spawn kill —
10479
+ // we still mark the session killed even if no managed-spawn exists yet
10480
+ // (e.g., session was killed while still in `pending` before SETUP ran).
10481
+ async function handleQaSessionKill(req, res, match) {
10482
+ return _qaSessionAction(req, res, match, async (qaSessions, session, body) => {
10483
+ const reason = body && typeof body.reason === 'string' ? body.reason : null;
10484
+
10485
+ // Kill the managed-spawn FIRST so the session record gets a true
10486
+ // post-kill state. Wrapped in try/catch because the spawn may not
10487
+ // exist (pending state) or the module may be missing in tests.
10488
+ try {
10489
+ const managedSpawn = require('./engine/managed-spawn');
10490
+ const specs = managedSpawn.listManagedSpecs();
10491
+ const spec = specs.find(s => s && s.name === session.managedSpawnName);
10492
+ if (spec) managedSpawn.removeManagedSpec(session.managedSpawnName);
10493
+ } catch (e) {
10494
+ shared.log('warn', `qa-sessions: managed-spawn kill failed for ${session.id}: ${e.message}`);
10495
+ }
10496
+
10497
+ let updated;
10498
+ try { updated = qaSessions.killSession(session.id, { reason }); }
10499
+ catch (e) { return jsonReply(res, _qaSessionsErrorToStatus(e), { error: e.message }, req); }
10500
+ invalidateStatusCache();
10501
+ return jsonReply(res, 200, {
10502
+ sessionId: session.id,
10503
+ state: updated && updated.state,
10504
+ killedSpawn: session.managedSpawnName,
10505
+ }, req);
10506
+ });
10507
+ }
10508
+
10509
+ // POST /api/qa/sessions/<id>/dismiss — non-terminal → done without running
10510
+ // EXECUTE. Useful when the user accepts the draft as final but doesn't
10511
+ // want to execute (e.g., reviewing a test before merging it).
10512
+ async function handleQaSessionDismiss(req, res, match) {
10513
+ return _qaSessionAction(req, res, match, async (qaSessions, session, body) => {
10514
+ const summary = body && typeof body.summary === 'string' ? body.summary : null;
10515
+ let updated;
10516
+ try { updated = qaSessions.dismissSession(session.id, { summary }); }
10517
+ catch (e) { return jsonReply(res, _qaSessionsErrorToStatus(e), { error: e.message }, req); }
10518
+ invalidateStatusCache();
10519
+ return jsonReply(res, 200, {
10520
+ sessionId: session.id,
10521
+ state: updated && updated.state,
10522
+ }, req);
10523
+ });
10524
+ }
10525
+
10526
+ // GET /api/qa/runners — list registered runner adapters (built-ins +
10527
+ // qa-runners.d/ plugins). Returns metadata only (name, priority, label,
10528
+ // description, installHint) — hooks are intentionally stripped server-side
10529
+ // because they're functions and not JSON-serializable.
10530
+ function handleQaRunnersList(req, res) {
10531
+ try {
10532
+ const qaRunners = require('./engine/qa-runners');
10533
+ const runners = qaRunners.listRunners();
10534
+ return jsonReply(res, 200, { runners }, req);
10535
+ } catch (e) {
10536
+ return jsonReply(res, 500, { error: e.message }, req);
10537
+ }
10538
+ }
10539
+
10540
+ // POST /api/qa/runners/reload — clear the in-process registry, re-register
10541
+ // built-ins, then re-scan qa-runners.d/ for plugin edits. Hot-reload entry
10542
+ // point referenced from engine/qa-runners.js docs. Returns the new runner
10543
+ // list so the caller can confirm the reload picked up edited/added plugins.
10544
+ async function handleQaRunnersReload(req, res) {
10545
+ try {
10546
+ const qaRunners = require('./engine/qa-runners');
10547
+ qaRunners._clearRunners();
10548
+ qaRunners._registerBuiltins();
10549
+ qaRunners._loadRunnerPlugins();
10550
+ const runners = qaRunners.listRunners();
10551
+ shared.log('info', `qa-runners reload: ${runners.length} runner(s) registered`);
10552
+ return jsonReply(res, 200, { reloaded: true, runners }, req);
10553
+ } catch (e) {
10554
+ return jsonReply(res, 500, { error: e.message }, req);
10555
+ }
10556
+ }
10557
+
10169
10558
  // ── Route Registry ──────────────────────────────────────────────────────────
10170
10559
  // Order matters: specific routes before general ones (e.g., /api/plans/approve before /api/plans/:file)
10171
10560
 
@@ -10256,6 +10645,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10256
10645
  { method: 'GET', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Get a single QA runbook by id.', handler: handleQaRunbooksGet },
10257
10646
  { method: 'POST', path: '/api/qa/runbooks', desc: 'Create or update a QA runbook. Body: full runbook spec (id, name, project, targetName, steps[], expectedArtifacts[]).', handler: handleQaRunbooksSave },
10258
10647
  { method: 'DELETE', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Delete a QA runbook by id.', handler: handleQaRunbooksDelete },
10648
+ // QA Sessions endpoints (P-d2f5a8c9). HTTP wrappers over engine/qa-sessions.js.
10649
+ // The state-action routes (/approve, /edit, /cancel, /kill, /dismiss) MUST
10650
+ // sit BEFORE the catch-all /api/qa/sessions/<id> GET regex so the action
10651
+ // suffix doesn't get swallowed as part of the id segment.
10652
+ { method: 'POST', path: '/api/qa/session', desc: 'Create a QA session and queue its SETUP work item. Body: { target, flowsRaw, mode?, capture?, runner?, project?, createdBy? }.', params: 'target, flowsRaw, mode?, capture?, runner?, project?, X-Minions-Agent?', handler: handleQaSessionCreate },
10653
+ { method: 'GET', path: '/api/qa/sessions', desc: 'List QA sessions (newest first). Optional ?limit=N and ?state=pending|spawning|drafting|awaiting-approval|executing|done|failed|killed filters.', handler: handleQaSessionsList },
10654
+ { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/approve$/, template: '/api/qa/sessions/<id>/approve', desc: 'Approve a draft (awaiting-approval → executing). Creates the linked qa-runs record server-side and queues the EXECUTE work item.', handler: handleQaSessionApprove },
10655
+ { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/edit$/, template: '/api/qa/sessions/<id>/edit', desc: 'Edit a draft (awaiting-approval → drafting). Body: { feedback }. Re-fires DRAFT with the feedback threaded into the prompt.', params: 'feedback', handler: handleQaSessionEdit },
10656
+ { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/cancel$/, template: '/api/qa/sessions/<id>/cancel', desc: 'Cancel a session (non-terminal → killed). Does NOT touch the managed-spawn — use /kill for that.', params: 'reason?', handler: handleQaSessionCancel },
10657
+ { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/kill$/, template: '/api/qa/sessions/<id>/kill', desc: 'Kill a session and its managed-spawn (non-terminal → killed). Best-effort on the spawn kill.', params: 'reason?', handler: handleQaSessionKill },
10658
+ { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/dismiss$/, template: '/api/qa/sessions/<id>/dismiss', desc: 'Mark a session done without running EXECUTE (non-terminal → done).', params: 'summary?', handler: handleQaSessionDismiss },
10659
+ { method: 'GET', path: /^\/api\/qa\/sessions\/([^/?]+)$/, template: '/api/qa/sessions/<id>', desc: 'Fetch a single QA session record by id.', handler: handleQaSessionsById },
10660
+ // QA Runners endpoints (P-d2f5a8c9). Pluggable runner-adapter registry.
10661
+ { method: 'GET', path: '/api/qa/runners', desc: 'List registered QA runner adapters (built-ins + qa-runners.d/ plugins). Returns metadata only (no hooks).', handler: handleQaRunnersList },
10662
+ { method: 'POST', path: '/api/qa/runners/reload', desc: 'Clear the in-process runner registry, re-register built-ins, and re-scan qa-runners.d/ for plugin edits.', handler: handleQaRunnersReload },
10259
10663
 
10260
10664
  // Work items
10261
10665
  { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },