@yemi33/minions 0.1.1994 → 0.1.1996
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/settings.js +2 -0
- package/dashboard.js +104 -0
- package/docs/qa-runbooks.md +104 -0
- package/engine/ado.js +22 -1
- package/engine/github.js +22 -1
- package/engine/lifecycle.js +8 -2
- package/engine/operator-identity.js +104 -0
- package/engine/qa-runbooks.js +328 -0
- package/engine/queries.js +16 -6
- package/engine/shared.js +44 -0
- package/engine.js +33 -5
- package/package.json +1 -1
- package/playbooks/implement.md +9 -3
- package/playbooks/plan-to-prd.md +3 -3
- package/playbooks/shared-rules.md +23 -0
- package/playbooks/work-item.md +4 -3
package/dashboard/js/settings.js
CHANGED
|
@@ -87,6 +87,7 @@ async function openSettings() {
|
|
|
87
87
|
settingsField('Shutdown Timeout', 'set-shutdownTimeout', e.shutdownTimeout || 300000, 'ms', 'Max wait for agents during graceful shutdown') +
|
|
88
88
|
settingsField('Restart Grace Period', 'set-restartGracePeriod', e.restartGracePeriod || 1200000, 'ms', 'Grace period before orphan detection on restart') +
|
|
89
89
|
settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
|
|
90
|
+
settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
|
|
90
91
|
'</div>' +
|
|
91
92
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
|
|
92
93
|
'<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
|
|
@@ -564,6 +565,7 @@ async function saveSettings() {
|
|
|
564
565
|
shutdownTimeout: document.getElementById('set-shutdownTimeout').value,
|
|
565
566
|
restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
|
|
566
567
|
meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
|
|
568
|
+
operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
|
|
567
569
|
autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
|
|
568
570
|
evalLoop: document.getElementById('set-evalLoop').checked,
|
|
569
571
|
autoDecompose: document.getElementById('set-autoDecompose').checked,
|
package/dashboard.js
CHANGED
|
@@ -4441,6 +4441,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
4441
4441
|
item.meta = { ...body.meta };
|
|
4442
4442
|
}
|
|
4443
4443
|
copyWorkItemPrFields(item, body);
|
|
4444
|
+
// W-mpejf0fq000e84d6: pre-compute the canonical branch name at create
|
|
4445
|
+
// time so the persisted WI carries `branch` from the moment it hits
|
|
4446
|
+
// disk. Skip when the caller already supplied a branch, when this is
|
|
4447
|
+
// a shared-branch plan (feature_branch wins), or when the type is a
|
|
4448
|
+
// PR-targeted op (engine reuses the PR's branch).
|
|
4449
|
+
if (!item.branch
|
|
4450
|
+
&& item.branchStrategy !== 'shared-branch'
|
|
4451
|
+
&& !item.pr_id && !item.prNumber && !item._pr && !item.targetPr && !item.sourcePr) {
|
|
4452
|
+
try {
|
|
4453
|
+
const derived = shared.deriveWorkItemBranchName(item, CONFIG);
|
|
4454
|
+
if (derived) item.branch = derived;
|
|
4455
|
+
} catch (e) { /* identity resolver best-effort; engine will derive on dispatch */ }
|
|
4456
|
+
}
|
|
4444
4457
|
const createResult = createWorkItemWithDedup(wiPath, item);
|
|
4445
4458
|
if (!createResult.created) {
|
|
4446
4459
|
const duplicateId = createResult.duplicateOf || createResult.item?.id;
|
|
@@ -7632,6 +7645,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7632
7645
|
const config = queries.getConfig();
|
|
7633
7646
|
const routing = safeRead(path.join(MINIONS_DIR, 'routing.md')) || '';
|
|
7634
7647
|
const engine = { ...shared.ENGINE_DEFAULTS, ...(config.engine || {}) };
|
|
7648
|
+
// W-mpejf0fq000e84d6: surface the auto-resolved operator login so the
|
|
7649
|
+
// Settings UI can render it as the placeholder for the optional
|
|
7650
|
+
// engine.operatorLogin override. Best-effort — never let identity
|
|
7651
|
+
// resolution fail the settings read.
|
|
7652
|
+
try { engine._resolvedOperatorLogin = shared.getOperatorLogin(config); }
|
|
7653
|
+
catch { engine._resolvedOperatorLogin = null; }
|
|
7635
7654
|
return jsonReply(res, 200, {
|
|
7636
7655
|
engine,
|
|
7637
7656
|
claude: settingsClaudeConfig(config),
|
|
@@ -7705,6 +7724,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7705
7724
|
}
|
|
7706
7725
|
// String fields
|
|
7707
7726
|
if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
|
|
7727
|
+
// W-mpejf0fq000e84d6: operator login override. Empty string clears
|
|
7728
|
+
// the override (engine falls back to gh/git/os resolution); any other
|
|
7729
|
+
// value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
|
|
7730
|
+
// Reset the identity-resolver cache so the change takes effect on the
|
|
7731
|
+
// next dispatch instead of after `minions restart`.
|
|
7732
|
+
if (e.operatorLogin !== undefined) {
|
|
7733
|
+
const raw = String(e.operatorLogin || '').trim();
|
|
7734
|
+
if (raw) _setEngineConfig('operatorLogin', raw);
|
|
7735
|
+
else _deleteEngineConfig('operatorLogin');
|
|
7736
|
+
try { require('./engine/operator-identity')._resetOperatorLoginCacheForTest(); }
|
|
7737
|
+
catch { /* identity module missing — safe to ignore */ }
|
|
7738
|
+
}
|
|
7708
7739
|
|
|
7709
7740
|
// ── Runtime fleet (P-7a5c1f8e) ─────────────────────────────────────
|
|
7710
7741
|
// Empty string clears the override — the dashboard's "Default (CLI
|
|
@@ -8427,6 +8458,71 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8427
8458
|
return;
|
|
8428
8459
|
}
|
|
8429
8460
|
|
|
8461
|
+
// ── QA Runbooks (W-mpeiwz6k0005bf34-a) ──────────────────────────────────────
|
|
8462
|
+
// CRUD over per-project test plans stored at
|
|
8463
|
+
// <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
|
|
8464
|
+
// dispatch + run records + UI are deferred to follow-up plan items.
|
|
8465
|
+
function handleQaRunbooksList(req, res) {
|
|
8466
|
+
try {
|
|
8467
|
+
const qaRunbooks = require('./engine/qa-runbooks');
|
|
8468
|
+
const u = new URL(req.url, 'http://x');
|
|
8469
|
+
const project = (u.searchParams.get('project') || '').trim();
|
|
8470
|
+
const items = qaRunbooks.listRunbooks(project || undefined);
|
|
8471
|
+
return jsonReply(res, 200, { items }, req);
|
|
8472
|
+
} catch (e) {
|
|
8473
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8474
|
+
}
|
|
8475
|
+
}
|
|
8476
|
+
|
|
8477
|
+
function handleQaRunbooksGet(req, res, match) {
|
|
8478
|
+
try {
|
|
8479
|
+
const qaRunbooks = require('./engine/qa-runbooks');
|
|
8480
|
+
const id = match && match[1] ? decodeURIComponent(match[1]) : '';
|
|
8481
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' }, req);
|
|
8482
|
+
const rec = qaRunbooks.getRunbook(id);
|
|
8483
|
+
if (!rec) return jsonReply(res, 404, { error: `runbook not found: ${id}` }, req);
|
|
8484
|
+
return jsonReply(res, 200, rec, req);
|
|
8485
|
+
} catch (e) {
|
|
8486
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8487
|
+
}
|
|
8488
|
+
}
|
|
8489
|
+
|
|
8490
|
+
async function handleQaRunbooksSave(req, res) {
|
|
8491
|
+
try {
|
|
8492
|
+
const qaRunbooks = require('./engine/qa-runbooks');
|
|
8493
|
+
const body = await readBody(req);
|
|
8494
|
+
const validation = qaRunbooks.validateRunbook(body);
|
|
8495
|
+
if (!validation.ok) {
|
|
8496
|
+
return jsonReply(res, 400, { error: 'invalid runbook', details: validation.errors }, req);
|
|
8497
|
+
}
|
|
8498
|
+
let saved;
|
|
8499
|
+
try { saved = qaRunbooks.saveRunbook(body); }
|
|
8500
|
+
catch (e) {
|
|
8501
|
+
// Cross-project collision is a client error (409), not a server error.
|
|
8502
|
+
if (/already exists under project/.test(e.message)) {
|
|
8503
|
+
return jsonReply(res, 409, { error: e.message }, req);
|
|
8504
|
+
}
|
|
8505
|
+
throw e;
|
|
8506
|
+
}
|
|
8507
|
+
return jsonReply(res, 200, saved, req);
|
|
8508
|
+
} catch (e) {
|
|
8509
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8510
|
+
}
|
|
8511
|
+
}
|
|
8512
|
+
|
|
8513
|
+
function handleQaRunbooksDelete(req, res, match) {
|
|
8514
|
+
try {
|
|
8515
|
+
const qaRunbooks = require('./engine/qa-runbooks');
|
|
8516
|
+
const id = match && match[1] ? decodeURIComponent(match[1]) : '';
|
|
8517
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' }, req);
|
|
8518
|
+
const removed = qaRunbooks.deleteRunbook(id);
|
|
8519
|
+
if (!removed) return jsonReply(res, 404, { error: `runbook not found: ${id}` }, req);
|
|
8520
|
+
return jsonReply(res, 200, { ok: true, id }, req);
|
|
8521
|
+
} catch (e) {
|
|
8522
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8523
|
+
}
|
|
8524
|
+
}
|
|
8525
|
+
|
|
8430
8526
|
// ── QA runs + artifact serving (W-mpeiwz6k0005bf34-b) ──────────────────────
|
|
8431
8527
|
// Lifecycle for QA validation runs lives in engine/qa-runs.js. These three
|
|
8432
8528
|
// handlers expose: list (with optional ?limit= + ?status= filters), single-
|
|
@@ -8645,6 +8741,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8645
8741
|
_trackSseClient(_hotReloadClients, req, res);
|
|
8646
8742
|
}},
|
|
8647
8743
|
|
|
8744
|
+
// QA Runbooks (W-mpeiwz6k0005bf34-a) — per-project test plans stored at
|
|
8745
|
+
// <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
|
|
8746
|
+
// dispatch + run records + UI live in follow-up plan items.
|
|
8747
|
+
{ method: 'GET', path: '/api/qa/runbooks', desc: 'List QA runbooks across all projects. Optional ?project= filter.', handler: handleQaRunbooksList },
|
|
8748
|
+
{ method: 'GET', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Get a single QA runbook by id.', handler: handleQaRunbooksGet },
|
|
8749
|
+
{ 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 },
|
|
8750
|
+
{ method: 'DELETE', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Delete a QA runbook by id.', handler: handleQaRunbooksDelete },
|
|
8751
|
+
|
|
8648
8752
|
// Work items
|
|
8649
8753
|
{ 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?', handler: handleWorkItemsCreate },
|
|
8650
8754
|
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# QA Runbooks
|
|
2
|
+
|
|
3
|
+
> Plan item **W-mpeiwz6k0005bf34-a** — schema + persistence + CRUD endpoints.
|
|
4
|
+
> Run dispatch, run records, and UI live in follow-up items.
|
|
5
|
+
|
|
6
|
+
## Storage location
|
|
7
|
+
|
|
8
|
+
Runbooks are per-project test plans. Each runbook is a single JSON file at:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
<MINIONS_DIR>/projects/<project-name>/runbooks/<runbook-id>.json
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This mirrors the `projects/<name>/pull-requests.json` precedent — anything
|
|
15
|
+
scoped to a single project lives under its `projects/<name>/` state dir
|
|
16
|
+
rather than a root-level `runbooks/` directory. Two reasons:
|
|
17
|
+
|
|
18
|
+
1. **Lifecycle parity with the project.** When a project is removed via
|
|
19
|
+
`engine/projects.js removeProject`, its `projects/<name>/` dir is
|
|
20
|
+
archived as one unit. Co-locating runbooks under that dir means they
|
|
21
|
+
travel with the project rather than dangling in a global `runbooks/`
|
|
22
|
+
that has no relationship to the project being removed.
|
|
23
|
+
2. **No central collision with multi-project setups.** Two projects can
|
|
24
|
+
pick the same human-readable runbook name without stepping on each
|
|
25
|
+
other on disk. The runbook **id** is still globally unique (kebab-case,
|
|
26
|
+
≤ 64 chars) so single-id lookups don't need a project hint.
|
|
27
|
+
|
|
28
|
+
## Schema
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
{
|
|
32
|
+
"id": "kebab-case-id", // required, kebab-case, ≤ 64 chars, globally unique
|
|
33
|
+
"name": "Human-readable name", // required, ≤ 200 chars
|
|
34
|
+
"project": "project-name", // required, the owning project (matches projects/<name>/)
|
|
35
|
+
"targetName": "string", // required, the system under test (e.g. process name, URL, target)
|
|
36
|
+
"steps": [ // ≤ 20 steps
|
|
37
|
+
{
|
|
38
|
+
"description": "Step 1 description", // required, ≤ 500 chars
|
|
39
|
+
"command": "optional shell command" // optional, ≤ 2000 chars
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"expectedArtifacts": [ // ≤ 20 artifacts
|
|
43
|
+
{
|
|
44
|
+
"type": "screenshot", // required, one of: screenshot | video | log | other
|
|
45
|
+
"label": "Login page", // required, ≤ 200 chars
|
|
46
|
+
"path": "screenshots/login.png" // optional hint, ≤ 500 chars
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"createdAt": "2026-05-20T20:42:00.000Z", // ISO-8601, set on first save
|
|
50
|
+
"updatedAt": "2026-05-20T20:42:00.000Z" // ISO-8601, set on every save
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`id`, `createdAt`, and `updatedAt` are managed by `saveRunbook`. The id
|
|
55
|
+
must match `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`.
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
| Method | Path | Notes |
|
|
60
|
+
| ------ | ----------------------------- | --------------------------------------------------------- |
|
|
61
|
+
| GET | `/api/qa/runbooks` | List all. Optional `?project=<name>` filter. |
|
|
62
|
+
| GET | `/api/qa/runbooks/<id>` | Fetch a single runbook by globally-unique id. |
|
|
63
|
+
| POST | `/api/qa/runbooks` | Create or update. Body is the full runbook spec. |
|
|
64
|
+
| DELETE | `/api/qa/runbooks/<id>` | Remove a runbook. Returns 404 when not found. |
|
|
65
|
+
|
|
66
|
+
Responses:
|
|
67
|
+
|
|
68
|
+
- `200 { items: [...] }` — list
|
|
69
|
+
- `200 { ...runbook }` — get/save
|
|
70
|
+
- `200 { ok: true, id }` — delete
|
|
71
|
+
- `400 { error, details? }` — validation failure (`details` is the
|
|
72
|
+
`validateRunbook` error array)
|
|
73
|
+
- `404 { error }` — not found
|
|
74
|
+
- `409 { error }` — cross-project id collision; `deleteRunbook(id)` then
|
|
75
|
+
retry with the new project
|
|
76
|
+
|
|
77
|
+
## Module
|
|
78
|
+
|
|
79
|
+
`engine/qa-runbooks.js` exports:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
{
|
|
83
|
+
ARTIFACT_TYPES, // ['screenshot','video','log','other']
|
|
84
|
+
LIMITS, // schema bounds (idMax, nameMax, stepsMax, ...)
|
|
85
|
+
validateRunbook(spec) // → { ok: boolean, errors: string[] } — never throws
|
|
86
|
+
listRunbooks(project?) // → array of parsed runbook records
|
|
87
|
+
getRunbook(id) // → record | null (scans all projects by id)
|
|
88
|
+
saveRunbook(spec) // upsert; throws on validation or cross-project collision
|
|
89
|
+
deleteRunbook(id) // → boolean; locks the runbook's file before unlink
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
All writes use `mutateJsonFileLocked` per the repo convention. Deletes use
|
|
94
|
+
`withFileLock` directly to coordinate with concurrent saves before the
|
|
95
|
+
unlink (so an in-progress `saveRunbook` rename can't race with the
|
|
96
|
+
unlink).
|
|
97
|
+
|
|
98
|
+
## Out of scope (deferred items)
|
|
99
|
+
|
|
100
|
+
This module deliberately does NOT:
|
|
101
|
+
|
|
102
|
+
- Spawn a QA agent or dispatch a run (W-mpeiwz6k0005bf34-c).
|
|
103
|
+
- Persist run records or artifacts (W-mpeiwz6k0005bf34-b).
|
|
104
|
+
- Render any UI (W-mpeiwz6k0005bf34-d).
|
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`
|
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
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,104 @@
|
|
|
1
|
+
// engine/operator-identity.js — W-mpejf0fq000e84d6
|
|
2
|
+
//
|
|
3
|
+
// Resolve the human operator's platform login for branch naming and other
|
|
4
|
+
// dispatch-time identity needs. The convention is documented in CLAUDE.md
|
|
5
|
+
// ("Branch naming convention") and shared with agents via playbook context.
|
|
6
|
+
//
|
|
7
|
+
// Resolution chain (first non-empty wins, cached at module scope):
|
|
8
|
+
// 1. `config.engine.operatorLogin` — explicit override from the Settings UI
|
|
9
|
+
// 2. `gh api user --jq .login` — works in any GitHub-authed install
|
|
10
|
+
// 3. `git config user.email` localpart (`user@host` → `user`)
|
|
11
|
+
// 4. `os.userInfo().username` — last-resort fallback
|
|
12
|
+
// 5. literal string `'unknown'` — if all four fail
|
|
13
|
+
//
|
|
14
|
+
// The resolved value is cached in module state. The cache is intentionally
|
|
15
|
+
// process-lifetime: `minions restart` re-resolves; the per-tick dispatch hot
|
|
16
|
+
// path does not. Test helpers expose cache reset + exec/os.username injection
|
|
17
|
+
// so unit tests stay hermetic.
|
|
18
|
+
|
|
19
|
+
const { execSync } = require('child_process');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
let _cached = null;
|
|
23
|
+
|
|
24
|
+
// Test seams. The default impls shell out; tests inject pure functions.
|
|
25
|
+
let _execImpl = (cmd) => {
|
|
26
|
+
try {
|
|
27
|
+
return String(execSync(cmd, {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
})).trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let _osUsernameOverride = null; // null = call real os.userInfo()
|
|
38
|
+
|
|
39
|
+
function _osUsername() {
|
|
40
|
+
if (_osUsernameOverride !== null) return _osUsernameOverride;
|
|
41
|
+
try {
|
|
42
|
+
const u = os.userInfo().username;
|
|
43
|
+
return u ? String(u) : '';
|
|
44
|
+
} catch {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveOperatorLogin(config, { force = false } = {}) {
|
|
50
|
+
if (!force && _cached) return _cached;
|
|
51
|
+
|
|
52
|
+
// 1. Explicit override
|
|
53
|
+
const override = config?.engine?.operatorLogin;
|
|
54
|
+
if (override && typeof override === 'string' && override.trim()) {
|
|
55
|
+
_cached = override.trim();
|
|
56
|
+
return _cached;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. gh CLI
|
|
60
|
+
const ghLogin = _execImpl('gh api user --jq .login');
|
|
61
|
+
if (ghLogin) { _cached = ghLogin; return _cached; }
|
|
62
|
+
|
|
63
|
+
// 3. git email localpart
|
|
64
|
+
const email = _execImpl('git config user.email');
|
|
65
|
+
if (email) {
|
|
66
|
+
const local = String(email).split('@')[0].trim();
|
|
67
|
+
if (local) { _cached = local; return _cached; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4. OS username
|
|
71
|
+
const user = _osUsername();
|
|
72
|
+
if (user) { _cached = user; return _cached; }
|
|
73
|
+
|
|
74
|
+
// 5. Last-resort sentinel
|
|
75
|
+
_cached = 'unknown';
|
|
76
|
+
return _cached;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Test helpers (not part of the public API) ────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function _resetOperatorLoginCacheForTest() { _cached = null; }
|
|
82
|
+
function _setExecImplForTest(fn) { _execImpl = typeof fn === 'function' ? fn : _execImpl; }
|
|
83
|
+
function _resetExecImplForTest() {
|
|
84
|
+
_execImpl = (cmd) => {
|
|
85
|
+
try {
|
|
86
|
+
return String(execSync(cmd, {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
89
|
+
timeout: 5000,
|
|
90
|
+
})).trim();
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function _setOsUsernameForTest(value) { _osUsernameOverride = value; }
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
resolveOperatorLogin,
|
|
100
|
+
_resetOperatorLoginCacheForTest,
|
|
101
|
+
_setExecImplForTest,
|
|
102
|
+
_resetExecImplForTest,
|
|
103
|
+
_setOsUsernameForTest,
|
|
104
|
+
};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/qa-runbooks.js — W-mpeiwz6k0005bf34-a
|
|
3
|
+
*
|
|
4
|
+
* Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
|
|
5
|
+
* that travel with a project entry, mirroring the
|
|
6
|
+
* projects/<name>/pull-requests.json precedent. Each runbook is one JSON file
|
|
7
|
+
* at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
|
|
8
|
+
*
|
|
9
|
+
* Pure persistence + validation only — this module does NOT spawn agents,
|
|
10
|
+
* dispatch runs, or touch UI. The dispatch endpoint, run records, and QA UI
|
|
11
|
+
* are intentionally deferred to follow-up plan items.
|
|
12
|
+
*
|
|
13
|
+
* All writes go through mutateJsonFileLocked per the repo convention. The id
|
|
14
|
+
* field is globally unique across projects (kebab-case, ≤64 chars) so reads
|
|
15
|
+
* by id can locate the file without the caller knowing the project.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const shared = require('./shared');
|
|
21
|
+
|
|
22
|
+
const RUNBOOKS_DIR = 'runbooks';
|
|
23
|
+
|
|
24
|
+
const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
25
|
+
|
|
26
|
+
// Mirrors shared.PROJECT_NAME_RE — kept local to avoid a require cycle and to
|
|
27
|
+
// keep this module self-contained for path-traversal hardening (review feedback
|
|
28
|
+
// on PR #2694: id/project params previously flowed into path.join without
|
|
29
|
+
// validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
|
|
30
|
+
// and DELETE could wipe dispatch.json).
|
|
31
|
+
const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
|
|
32
|
+
|
|
33
|
+
const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
|
|
34
|
+
|
|
35
|
+
const LIMITS = {
|
|
36
|
+
idMax: 64,
|
|
37
|
+
nameMax: 200,
|
|
38
|
+
targetNameMax: 200,
|
|
39
|
+
stepDescriptionMax: 500,
|
|
40
|
+
stepCommandMax: 2000,
|
|
41
|
+
artifactLabelMax: 200,
|
|
42
|
+
artifactPathMax: 500,
|
|
43
|
+
stepsMax: 20,
|
|
44
|
+
artifactsMax: 20,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function _projectsDir() {
|
|
48
|
+
return path.join(shared.MINIONS_DIR, 'projects');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _runbooksDir(projectName) {
|
|
52
|
+
return path.join(_projectsDir(), projectName, RUNBOOKS_DIR);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _runbookPath(projectName, id) {
|
|
56
|
+
return path.join(_runbooksDir(projectName), id + '.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _isNonEmptyString(v) {
|
|
60
|
+
return typeof v === 'string' && v.length > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Guards against path traversal at the module boundary. Mirrors the validation
|
|
64
|
+
// saveRunbook already applies via validateRunbook(). Reject anything that isn't
|
|
65
|
+
// a safe kebab-case id ≤ idMax chars so it can never reach path.join().
|
|
66
|
+
function _isSafeId(id) {
|
|
67
|
+
return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Guards against path traversal via the project segment. Project directory
|
|
71
|
+
// names on disk follow shared.PROJECT_NAME_RE — anything outside that set
|
|
72
|
+
// (path separators, `..`, null bytes, whitespace) cannot be a real project.
|
|
73
|
+
function _isSafeProjectName(name) {
|
|
74
|
+
return _isNonEmptyString(name) && _PROJECT_NAME_RE.test(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a runbook spec. Returns { ok: boolean, errors: string[] }.
|
|
79
|
+
* Never throws.
|
|
80
|
+
*/
|
|
81
|
+
function validateRunbook(spec) {
|
|
82
|
+
const errors = [];
|
|
83
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
84
|
+
return { ok: false, errors: ['spec must be a plain object'] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!_isNonEmptyString(spec.id)) {
|
|
88
|
+
errors.push('id is required (non-empty string)');
|
|
89
|
+
} else {
|
|
90
|
+
if (spec.id.length > LIMITS.idMax) errors.push('id exceeds ' + LIMITS.idMax + ' chars');
|
|
91
|
+
if (!_KEBAB_RE.test(spec.id)) errors.push('id must be kebab-case (a-z, 0-9, hyphens; no leading/trailing hyphen)');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!_isNonEmptyString(spec.name)) {
|
|
95
|
+
errors.push('name is required (non-empty string)');
|
|
96
|
+
} else if (spec.name.length > LIMITS.nameMax) {
|
|
97
|
+
errors.push('name exceeds ' + LIMITS.nameMax + ' chars');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!_isNonEmptyString(spec.project)) {
|
|
101
|
+
errors.push('project is required (non-empty string)');
|
|
102
|
+
} else if (!_PROJECT_NAME_RE.test(spec.project)) {
|
|
103
|
+
// Reject path-traversal / illegal project names at the schema layer so
|
|
104
|
+
// they never reach path.join in saveRunbook (review feedback on PR #2694:
|
|
105
|
+
// POST /api/qa/runbooks with project="../engine" previously wrote arbitrary
|
|
106
|
+
// JSON outside MINIONS_DIR).
|
|
107
|
+
errors.push('project must match ' + _PROJECT_NAME_RE.source + ' (alphanumerics, underscore, hyphen; 1-64 chars)');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!_isNonEmptyString(spec.targetName)) {
|
|
111
|
+
errors.push('targetName is required (non-empty string)');
|
|
112
|
+
} else if (spec.targetName.length > LIMITS.targetNameMax) {
|
|
113
|
+
errors.push('targetName exceeds ' + LIMITS.targetNameMax + ' chars');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!Array.isArray(spec.steps)) {
|
|
117
|
+
errors.push('steps must be an array');
|
|
118
|
+
} else {
|
|
119
|
+
if (spec.steps.length > LIMITS.stepsMax) {
|
|
120
|
+
errors.push('steps exceeds max of ' + LIMITS.stepsMax);
|
|
121
|
+
}
|
|
122
|
+
for (let i = 0; i < spec.steps.length; i++) {
|
|
123
|
+
const s = spec.steps[i];
|
|
124
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
|
125
|
+
errors.push('steps[' + i + '] must be an object');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!_isNonEmptyString(s.description)) {
|
|
129
|
+
errors.push('steps[' + i + '].description is required (non-empty string)');
|
|
130
|
+
} else if (s.description.length > LIMITS.stepDescriptionMax) {
|
|
131
|
+
errors.push('steps[' + i + '].description exceeds ' + LIMITS.stepDescriptionMax + ' chars');
|
|
132
|
+
}
|
|
133
|
+
if (s.command !== undefined && s.command !== null) {
|
|
134
|
+
if (typeof s.command !== 'string') {
|
|
135
|
+
errors.push('steps[' + i + '].command must be a string when present');
|
|
136
|
+
} else if (s.command.length > LIMITS.stepCommandMax) {
|
|
137
|
+
errors.push('steps[' + i + '].command exceeds ' + LIMITS.stepCommandMax + ' chars');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!Array.isArray(spec.expectedArtifacts)) {
|
|
144
|
+
errors.push('expectedArtifacts must be an array');
|
|
145
|
+
} else {
|
|
146
|
+
if (spec.expectedArtifacts.length > LIMITS.artifactsMax) {
|
|
147
|
+
errors.push('expectedArtifacts exceeds max of ' + LIMITS.artifactsMax);
|
|
148
|
+
}
|
|
149
|
+
for (let i = 0; i < spec.expectedArtifacts.length; i++) {
|
|
150
|
+
const a = spec.expectedArtifacts[i];
|
|
151
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) {
|
|
152
|
+
errors.push('expectedArtifacts[' + i + '] must be an object');
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!_isNonEmptyString(a.type) || !ARTIFACT_TYPES.includes(a.type)) {
|
|
156
|
+
errors.push('expectedArtifacts[' + i + '].type must be one of: ' + ARTIFACT_TYPES.join(', '));
|
|
157
|
+
}
|
|
158
|
+
if (!_isNonEmptyString(a.label)) {
|
|
159
|
+
errors.push('expectedArtifacts[' + i + '].label is required (non-empty string)');
|
|
160
|
+
} else if (a.label.length > LIMITS.artifactLabelMax) {
|
|
161
|
+
errors.push('expectedArtifacts[' + i + '].label exceeds ' + LIMITS.artifactLabelMax + ' chars');
|
|
162
|
+
}
|
|
163
|
+
if (a.path !== undefined && a.path !== null) {
|
|
164
|
+
if (typeof a.path !== 'string') {
|
|
165
|
+
errors.push('expectedArtifacts[' + i + '].path must be a string when present');
|
|
166
|
+
} else if (a.path.length > LIMITS.artifactPathMax) {
|
|
167
|
+
errors.push('expectedArtifacts[' + i + '].path exceeds ' + LIMITS.artifactPathMax + ' chars');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { ok: errors.length === 0, errors };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _readRunbookFile(filePath) {
|
|
177
|
+
let raw;
|
|
178
|
+
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
|
179
|
+
catch (_e) { return null; }
|
|
180
|
+
try { return JSON.parse(raw); }
|
|
181
|
+
catch (_e) { return null; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _listProjectNames() {
|
|
185
|
+
const dir = _projectsDir();
|
|
186
|
+
let entries;
|
|
187
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
188
|
+
catch (_e) { return []; }
|
|
189
|
+
return entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* List runbooks across all projects, or filtered to a single project. Each
|
|
194
|
+
* returned record is the parsed file contents (already includes id + project
|
|
195
|
+
* + timestamps).
|
|
196
|
+
*/
|
|
197
|
+
function listRunbooks(project) {
|
|
198
|
+
let projects;
|
|
199
|
+
if (project === undefined || project === null || project === '') {
|
|
200
|
+
projects = _listProjectNames();
|
|
201
|
+
} else {
|
|
202
|
+
// Hardened: reject traversal/illegal project names instead of letting them
|
|
203
|
+
// flow into path.join (review feedback on PR #2694).
|
|
204
|
+
if (!_isSafeProjectName(project)) return [];
|
|
205
|
+
projects = [project];
|
|
206
|
+
}
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const name of projects) {
|
|
209
|
+
const dir = _runbooksDir(name);
|
|
210
|
+
let files;
|
|
211
|
+
try { files = fs.readdirSync(dir); }
|
|
212
|
+
catch (_e) { continue; }
|
|
213
|
+
for (const f of files) {
|
|
214
|
+
if (!f.endsWith('.json')) continue;
|
|
215
|
+
const parsed = _readRunbookFile(path.join(dir, f));
|
|
216
|
+
if (parsed && typeof parsed === 'object') out.push(parsed);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Find a runbook by globally-unique id. Returns the parsed record or null.
|
|
224
|
+
*/
|
|
225
|
+
function getRunbook(id) {
|
|
226
|
+
// Hardened: reject traversal ids before they can reach path.join + existsSync
|
|
227
|
+
// (review feedback on PR #2694).
|
|
228
|
+
if (!_isSafeId(id)) return null;
|
|
229
|
+
for (const name of _listProjectNames()) {
|
|
230
|
+
const filePath = _runbookPath(name, id);
|
|
231
|
+
if (fs.existsSync(filePath)) {
|
|
232
|
+
return _readRunbookFile(filePath);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Locate the project that currently owns id, or null if not present.
|
|
240
|
+
*/
|
|
241
|
+
function _findOwningProject(id) {
|
|
242
|
+
for (const name of _listProjectNames()) {
|
|
243
|
+
if (fs.existsSync(_runbookPath(name, id))) return name;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Create or update a runbook. Sets createdAt on first save and updatedAt on
|
|
250
|
+
* every save. Throws on validation failure. Rejects cross-project renames —
|
|
251
|
+
* if id already exists under a different project, the caller must
|
|
252
|
+
* deleteRunbook(id) first.
|
|
253
|
+
*/
|
|
254
|
+
function saveRunbook(spec) {
|
|
255
|
+
const v = validateRunbook(spec);
|
|
256
|
+
if (!v.ok) {
|
|
257
|
+
const err = new Error('invalid runbook: ' + v.errors.join('; '));
|
|
258
|
+
err.validationErrors = v.errors;
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
const existingProject = _findOwningProject(spec.id);
|
|
262
|
+
if (existingProject && existingProject !== spec.project) {
|
|
263
|
+
throw new Error('runbook id "' + spec.id + '" already exists under project "' + existingProject + '" — delete it before saving under "' + spec.project + '"');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const filePath = _runbookPath(spec.project, spec.id);
|
|
267
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
268
|
+
|
|
269
|
+
const nowIso = new Date().toISOString();
|
|
270
|
+
const result = shared.mutateJsonFileLocked(filePath, (data) => {
|
|
271
|
+
const prior = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
|
|
272
|
+
return {
|
|
273
|
+
id: spec.id,
|
|
274
|
+
name: spec.name,
|
|
275
|
+
project: spec.project,
|
|
276
|
+
targetName: spec.targetName,
|
|
277
|
+
steps: spec.steps.map(s => {
|
|
278
|
+
const out = { description: s.description };
|
|
279
|
+
if (typeof s.command === 'string' && s.command.length > 0) out.command = s.command;
|
|
280
|
+
return out;
|
|
281
|
+
}),
|
|
282
|
+
expectedArtifacts: spec.expectedArtifacts.map(a => {
|
|
283
|
+
const out = { type: a.type, label: a.label };
|
|
284
|
+
if (typeof a.path === 'string' && a.path.length > 0) out.path = a.path;
|
|
285
|
+
return out;
|
|
286
|
+
}),
|
|
287
|
+
createdAt: _isNonEmptyString(prior.createdAt) ? prior.createdAt : nowIso,
|
|
288
|
+
updatedAt: nowIso,
|
|
289
|
+
};
|
|
290
|
+
}, { defaultValue: {} });
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Remove a runbook by id. No-op when the id is not found. Returns true when
|
|
296
|
+
* a file was removed.
|
|
297
|
+
*
|
|
298
|
+
* Coordination: acquires the runbook's lock via withFileLock so a concurrent
|
|
299
|
+
* saveRunbook can't be mid-rename when we unlink. The unlink happens inside
|
|
300
|
+
* the lock callback (single fs call — keeps the callback synchronous and
|
|
301
|
+
* fast per the repo convention).
|
|
302
|
+
*/
|
|
303
|
+
function deleteRunbook(id) {
|
|
304
|
+
// Hardened: reject traversal ids before they can reach _findOwningProject /
|
|
305
|
+
// path.join / unlink (review feedback on PR #2694).
|
|
306
|
+
if (!_isSafeId(id)) return false;
|
|
307
|
+
const owning = _findOwningProject(id);
|
|
308
|
+
if (!owning) return false;
|
|
309
|
+
const filePath = _runbookPath(owning, id);
|
|
310
|
+
shared.withFileLock(filePath + '.lock', () => {
|
|
311
|
+
try { fs.unlinkSync(filePath); } catch (_e) { /* already gone */ }
|
|
312
|
+
try { fs.unlinkSync(filePath + '.backup'); } catch (_e) { /* optional */ }
|
|
313
|
+
});
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
ARTIFACT_TYPES,
|
|
319
|
+
LIMITS,
|
|
320
|
+
validateRunbook,
|
|
321
|
+
listRunbooks,
|
|
322
|
+
getRunbook,
|
|
323
|
+
saveRunbook,
|
|
324
|
+
deleteRunbook,
|
|
325
|
+
// internals exposed for testing
|
|
326
|
+
_runbookPath,
|
|
327
|
+
_runbooksDir,
|
|
328
|
+
};
|
package/engine/queries.js
CHANGED
|
@@ -673,12 +673,22 @@ function getPullRequests(config) {
|
|
|
673
673
|
}
|
|
674
674
|
}
|
|
675
675
|
allPrs.sort((a, b) => {
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
//
|
|
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/engine/shared.js
CHANGED
|
@@ -1921,6 +1921,13 @@ const ENGINE_DEFAULTS = {
|
|
|
1921
1921
|
constellationBridge: {
|
|
1922
1922
|
enabled: false,
|
|
1923
1923
|
},
|
|
1924
|
+
// ── Operator identity (W-mpejf0fq000e84d6) ──────────────────────────────────
|
|
1925
|
+
// Explicit override for the human operator's platform login used in branch
|
|
1926
|
+
// names (see `deriveWorkItemBranchName`). `null` (default) means auto-resolve
|
|
1927
|
+
// via `engine/operator-identity.js` (gh → git email localpart → os user).
|
|
1928
|
+
// Settings UI exposes this as a free-text input; clearing the field deletes
|
|
1929
|
+
// the override and falls back to auto-resolution.
|
|
1930
|
+
operatorLogin: null,
|
|
1924
1931
|
};
|
|
1925
1932
|
|
|
1926
1933
|
// ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
|
|
@@ -3205,6 +3212,41 @@ function sanitizeBranch(name) {
|
|
|
3205
3212
|
return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
|
|
3206
3213
|
}
|
|
3207
3214
|
|
|
3215
|
+
// ── Branch name derivation (W-mpejf0fq000e84d6) ──────────────────────────────
|
|
3216
|
+
//
|
|
3217
|
+
// Single source of truth for the canonical work-item branch name. The convention
|
|
3218
|
+
// is `user/<loginname>/<wi-id-lowercased>-<title-slug>` (≤120 chars total).
|
|
3219
|
+
//
|
|
3220
|
+
// Callers MUST use this helper rather than templating `work/<id>` inline — the
|
|
3221
|
+
// branch-naming unit test asserts the literal `work/${item.id}` fallback is
|
|
3222
|
+
// gone from engine.js. PR-targeted dispatches and `shared-branch` plans bypass
|
|
3223
|
+
// this helper entirely (they reuse the existing branch).
|
|
3224
|
+
//
|
|
3225
|
+
// `getOperatorLogin` is a thin shim around `engine/operator-identity` so other
|
|
3226
|
+
// modules don't need a second require. Required lazily to keep shared.js free
|
|
3227
|
+
// of side-effecting child_process imports at module load.
|
|
3228
|
+
|
|
3229
|
+
function getOperatorLogin(config) {
|
|
3230
|
+
try {
|
|
3231
|
+
return require('./operator-identity').resolveOperatorLogin(config || {});
|
|
3232
|
+
} catch {
|
|
3233
|
+
return null;
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function deriveWorkItemBranchName(item, config) {
|
|
3238
|
+
const login = getOperatorLogin(config) || 'unknown';
|
|
3239
|
+
const wid = String(item?.id || '').toLowerCase();
|
|
3240
|
+
const src = String(item?.title || item?.description || '').toLowerCase();
|
|
3241
|
+
let slug = src.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
3242
|
+
const prefix = `user/${login}/${wid}-`;
|
|
3243
|
+
// Cap total length at 120 chars by trimming the slug, leaving at least 8
|
|
3244
|
+
// chars of slug room. Strip any trailing dash exposed by truncation.
|
|
3245
|
+
const budget = Math.max(8, 120 - prefix.length);
|
|
3246
|
+
if (slug.length > budget) slug = slug.slice(0, budget).replace(/-+$/, '');
|
|
3247
|
+
return sanitizeBranch(prefix + (slug || 'work'));
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3208
3250
|
function _worktreeNameSuffix(dispatchId, projectName, branchName) {
|
|
3209
3251
|
const id = String(dispatchId || '').split('-').filter(Boolean).pop();
|
|
3210
3252
|
if (id) return safeSlugComponent(id, 32);
|
|
@@ -4812,6 +4854,8 @@ module.exports = {
|
|
|
4812
4854
|
getAdoOrgBase,
|
|
4813
4855
|
sanitizePath,
|
|
4814
4856
|
sanitizeBranch,
|
|
4857
|
+
getOperatorLogin,
|
|
4858
|
+
deriveWorkItemBranchName,
|
|
4815
4859
|
safeSlugComponent,
|
|
4816
4860
|
buildWorktreeDirName, // exported for testing
|
|
4817
4861
|
isPathInside,
|
package/engine.js
CHANGED
|
@@ -4601,7 +4601,7 @@ function refreshDeferredWorkItemPrompt(item, config) {
|
|
|
4601
4601
|
const project = projectFromDispatchMeta(item.meta.project, config);
|
|
4602
4602
|
const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
|
|
4603
4603
|
const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
|
|
4604
|
-
const branchName = item.meta.branch || item.meta.item.branch ||
|
|
4604
|
+
const branchName = item.meta.branch || item.meta.item.branch || shared.deriveWorkItemBranchName(item.meta.item, config);
|
|
4605
4605
|
const rendered = renderProjectWorkItemPromptForAgent(item.meta.item, workType, item.agent, config, project, root, branchName);
|
|
4606
4606
|
if (rendered.prompt) item.prompt = rendered.prompt;
|
|
4607
4607
|
item.meta.deferAgentResolution = false;
|
|
@@ -4802,7 +4802,24 @@ function discoverFromWorkItems(config, project) {
|
|
|
4802
4802
|
continue;
|
|
4803
4803
|
}
|
|
4804
4804
|
const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
|
|
4805
|
-
|
|
4805
|
+
// W-mpejf0fq000e84d6: when no branch is explicitly set, derive the
|
|
4806
|
+
// canonical `user/<loginname>/<wi-id>-<slug>` name once and persist it
|
|
4807
|
+
// back onto the work item so re-dispatches land on the same branch and
|
|
4808
|
+
// the dashboard surfaces the right value.
|
|
4809
|
+
let branchName;
|
|
4810
|
+
if (isPrTargeted && prBranch) {
|
|
4811
|
+
branchName = prBranch;
|
|
4812
|
+
} else if (isShared) {
|
|
4813
|
+
branchName = item.featureBranch;
|
|
4814
|
+
} else if (item.branch) {
|
|
4815
|
+
branchName = item.branch;
|
|
4816
|
+
} else {
|
|
4817
|
+
branchName = shared.deriveWorkItemBranchName(item, config);
|
|
4818
|
+
if (branchName && item.branch !== branchName) {
|
|
4819
|
+
item.branch = branchName;
|
|
4820
|
+
needsWrite = true;
|
|
4821
|
+
}
|
|
4822
|
+
}
|
|
4806
4823
|
const deferredAgentResolution = agentId === routing.ANY_AGENT;
|
|
4807
4824
|
|
|
4808
4825
|
// Branch mutex: skip if target branch is locked by an active dispatch
|
|
@@ -5356,8 +5373,19 @@ function discoverCentralWorkItems(config) {
|
|
|
5356
5373
|
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, projectMutation));
|
|
5357
5374
|
}
|
|
5358
5375
|
|
|
5359
|
-
// Branch mutex: skip if target branch is locked by an active dispatch
|
|
5360
|
-
|
|
5376
|
+
// Branch mutex: skip if target branch is locked by an active dispatch.
|
|
5377
|
+
// W-mpejf0fq000e84d6: fall back to the canonical user/<login>/<wi>-<slug>
|
|
5378
|
+
// name (instead of the legacy `work/<id>`) and persist it back on the
|
|
5379
|
+
// central WI so subsequent ticks see the resolved branch.
|
|
5380
|
+
let centralBranch;
|
|
5381
|
+
if (item.branch) centralBranch = item.branch;
|
|
5382
|
+
else if (item.featureBranch) centralBranch = item.featureBranch;
|
|
5383
|
+
else {
|
|
5384
|
+
centralBranch = shared.deriveWorkItemBranchName(item, config);
|
|
5385
|
+
if (centralBranch) {
|
|
5386
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { branch: centralBranch }));
|
|
5387
|
+
}
|
|
5388
|
+
}
|
|
5361
5389
|
const centralBranchConflict = isBranchActive(centralBranch);
|
|
5362
5390
|
if (centralBranchConflict) {
|
|
5363
5391
|
log('info', `Branch mutex: skipping central ${item.id} — branch ${centralBranch} locked by ${centralBranchConflict.id} (${centralBranchConflict.agent})`);
|
|
@@ -5512,7 +5540,7 @@ function discoverCentralWorkItems(config) {
|
|
|
5512
5540
|
agentRole,
|
|
5513
5541
|
task: item.title || item.description?.slice(0, 80) || item.id,
|
|
5514
5542
|
prompt,
|
|
5515
|
-
meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch:
|
|
5543
|
+
meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: centralBranch, ...(targetProject ? { project: { name: targetProject.name, localPath: targetProject.localPath } } : {}) }
|
|
5516
5544
|
});
|
|
5517
5545
|
|
|
5518
5546
|
setCooldown(key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1996",
|
|
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"
|
package/playbooks/implement.md
CHANGED
|
@@ -7,9 +7,15 @@ Repository ID is injected as `{{ado_project}}` and `{{repo_name}}` template vari
|
|
|
7
7
|
Repo: {{repo_name}} | Org: {{ado_org}} | Project: {{ado_project}}
|
|
8
8
|
|
|
9
9
|
## Branch Naming Convention
|
|
10
|
-
Branch format: `
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Branch format: `user/<loginname>/{{item_id}}-<slug>` — see the canonical "Branch Naming Convention" section in shared-rules above.
|
|
11
|
+
|
|
12
|
+
`<loginname>` is the **human operator's platform login** (e.g. `yemi33` on GitHub, `yemishin` on ADO), resolved via `gh api user --jq .login` or `az account show --query user.name -o tsv`. **Do NOT use the AI agent persona name** (`dallas`, `ripley`, …).
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
- `user/yemi33/M001-hr-agent`
|
|
16
|
+
- `user/yemishin/M013-multimodal-input`
|
|
17
|
+
|
|
18
|
+
The engine pre-creates your worktree on a branch matching this convention. The branch is already injected as `{{branch_name}}` — push to that branch as-is; do not create or rename branches.
|
|
13
19
|
|
|
14
20
|
## Your Task
|
|
15
21
|
|
package/playbooks/plan-to-prd.md
CHANGED
|
@@ -42,7 +42,7 @@ This file is NOT checked into the repo. The engine reads it on every tick and di
|
|
|
42
42
|
"status": "awaiting-approval",
|
|
43
43
|
"requires_approval": true,
|
|
44
44
|
"branch_strategy": "shared-branch|parallel",
|
|
45
|
-
"feature_branch": "
|
|
45
|
+
"feature_branch": "user/<loginname>/PL-<short-kebab-slug>",
|
|
46
46
|
"missing_features": [
|
|
47
47
|
{
|
|
48
48
|
"id": "P-<uuid>",
|
|
@@ -75,12 +75,12 @@ Choose one of the following strategies based on how the items relate to each oth
|
|
|
75
75
|
{{branch_strategy_hint}}
|
|
76
76
|
|
|
77
77
|
When using `shared-branch`:
|
|
78
|
-
- Generate a `feature_branch` name: `
|
|
78
|
+
- Generate a `feature_branch` name using the canonical convention: `user/<loginname>/PL-<short-kebab-description>` (≤ 120 chars, lowercase). `<loginname>` is the human operator's platform login (e.g. `yemi33` on GitHub) — never an AI agent persona. See `shared-rules.md` → "Branch Naming Convention".
|
|
79
79
|
- Use `depends_on` to express the ordering — items execute in dependency order
|
|
80
80
|
- Each item should be able to build on the prior items' work
|
|
81
81
|
|
|
82
82
|
When using `parallel`:
|
|
83
|
-
- Omit `feature_branch` (the engine
|
|
83
|
+
- Omit `feature_branch` (the engine derives per-item branches as `user/<loginname>/<wi-id>-<slug>`)
|
|
84
84
|
- `depends_on` is still respected but items can dispatch concurrently if no deps
|
|
85
85
|
|
|
86
86
|
Rules for items:
|
|
@@ -29,6 +29,29 @@ Bias toward senior-engineer restraint:
|
|
|
29
29
|
- Clean up only artifacts introduced by your own work, such as now-unused imports, variables, helpers, docs, or tests. Mention unrelated dead code instead of deleting it.
|
|
30
30
|
- Turn the task into verifiable goals before editing. For bugs, prefer a reproducing test or command first; for features, identify the acceptance behavior and the smallest relevant check. Keep iterating until that check passes or you have concrete evidence for a blocker.
|
|
31
31
|
|
|
32
|
+
## Branch Naming Convention
|
|
33
|
+
|
|
34
|
+
All branches use the format:
|
|
35
|
+
|
|
36
|
+
user/<loginname>/<wi-id>-<slug>
|
|
37
|
+
|
|
38
|
+
- `<loginname>` is the **human operator's platform login** — never the AI agent's persona (`dallas`, `ripley`, `lambert`, …). Resolve in this order:
|
|
39
|
+
1. GitHub repos: `gh api user --jq .login` (e.g. `yemi33`, `yemishin_microsoft`)
|
|
40
|
+
2. Azure DevOps repos: `az account show --query user.name -o tsv` and take the localpart before `@` (e.g. `yemishin`)
|
|
41
|
+
3. Fallback: `git config user.email` localpart, then `$USER` / `$USERNAME`
|
|
42
|
+
- `<wi-id>` is the work-item or PRD-item id verbatim (`W-mp7abc123`, `P-a1b2c3d4`, `PL-…`).
|
|
43
|
+
- `<slug>` is a short lowercase kebab-case summary derived from the title. ASCII only, words separated by `-`, ≤ 40 chars, no leading/trailing hyphens.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
- `user/yemi33/W-mp7abc123-fix-login-redirect`
|
|
47
|
+
- `user/yemishin/P-a1b2c3d4-shared-schemas`
|
|
48
|
+
- `user/yemishin_microsoft/PL-feature-rollout-stage-1`
|
|
49
|
+
|
|
50
|
+
Application:
|
|
51
|
+
- The engine pre-creates your worktree on a branch matching this convention. Push to that branch as injected via `{{branch_name}}` — do not create or rename branches.
|
|
52
|
+
- When you create a work item programmatically (API, plan-to-prd, scripts), set the WI's `branch` (or PRD `feature_branch`) to the conventional name so the engine creates the worktree on the right branch from the start. `dashboard.js` derives this automatically when callers omit `branch`.
|
|
53
|
+
- The legacy `feat/<id>-<slug>` and bare `work/<id>` formats are deprecated; the engine no longer falls back to them.
|
|
54
|
+
|
|
32
55
|
## Engine Rules (apply to all tasks)
|
|
33
56
|
|
|
34
57
|
**Context compaction:** Your context window may be compacted mid-task by Claude's infrastructure. If you notice your earlier conversation history appears truncated or summarized, this is normal and expected. Do not interpret compaction as a signal to stop early or wrap up. Continue working toward your task objective — all relevant instructions and state remain available.
|
package/playbooks/work-item.md
CHANGED
|
@@ -17,8 +17,9 @@ Team root: {{team_root}}
|
|
|
17
17
|
{{additional_context}}
|
|
18
18
|
|
|
19
19
|
## Branch Naming Convention
|
|
20
|
-
Branch format: `
|
|
21
|
-
|
|
20
|
+
Branch format: `user/<loginname>/{{item_id}}-<slug>` — see the canonical "Branch Naming Convention" section in shared-rules.
|
|
21
|
+
|
|
22
|
+
The engine pre-creates the worktree on a branch matching this convention; it is already injected as `{{branch_name}}`. Push to that branch — do not create or rename branches.
|
|
22
23
|
|
|
23
24
|
## Delivery Contract
|
|
24
25
|
|
|
@@ -41,7 +42,7 @@ git push -u origin {{branch_name}}
|
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
{{pr_create_instructions}}
|
|
44
|
-
- sourceRefName: `refs/heads/
|
|
45
|
+
- sourceRefName: `refs/heads/{{branch_name}}`
|
|
45
46
|
- targetRefName: `refs/heads/{{main_branch}}`
|
|
46
47
|
- title: `feat({{item_id}}): <description>`
|
|
47
48
|
|