@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/qa.js +358 -0
- package/dashboard/js/state.js +2 -1
- package/dashboard/pages/qa.html +72 -0
- package/dashboard/styles.css +102 -0
- package/dashboard.js +410 -6
- package/docs/qa-runbook-lifecycle.md +232 -0
- package/engine/cleanup.js +4 -1
- package/engine/comment-classifier.js +8 -1
- package/engine/cooldown.js +6 -2
- package/engine/gh-comment.js +74 -3
- package/engine/gh-token.js +7 -9
- package/engine/lifecycle.js +100 -0
- package/engine/pipeline.js +9 -1
- package/engine/playbook.js +39 -0
- package/engine/qa-runners/maestro.js +152 -0
- package/engine/qa-runners/playwright.js +149 -0
- package/engine/qa-runners.js +323 -0
- package/engine/qa-sessions.js +1008 -0
- package/engine/shared.js +71 -12
- package/engine.js +140 -0
- package/package.json +1 -1
- package/playbooks/qa-session-draft.md +158 -0
- package/playbooks/qa-session-execute.md +165 -0
- package/playbooks/qa-session-setup.md +154 -0
- package/prompts/cc-system.md +43 -0
- package/routing.md +3 -0
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
|
-
|
|
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 (
|
|
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 },
|