@yemi33/minions 0.1.2378 → 0.1.2380
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/bin/minions.js +19 -9
- package/dashboard/js/refresh.js +2 -3
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard.js +393 -721
- package/docs/README.md +1 -0
- package/docs/architecture-review-2026-07-09.md +2 -4
- package/docs/auto-discovery.md +36 -49
- package/docs/blog-first-successful-dispatch.md +1 -1
- package/docs/branch-derivation.md +2 -2
- package/docs/command-center.md +1 -1
- package/docs/completion-reports.md +14 -4
- package/docs/constellation-bridge.md +59 -10
- package/docs/constellation-style-telemetry.md +6 -6
- package/docs/cooldown-merge-semantics.md +4 -0
- package/docs/copilot-cli-schema.md +3 -3
- package/docs/cross-repo-plans.md +17 -17
- package/docs/deprecated.json +2 -2
- package/docs/design-inbox-entries-schema.md +31 -39
- package/docs/design-state-storage.md +1 -1
- package/docs/documentation-audit-2026-07-09.md +2 -2
- package/docs/engine-restart.md +20 -8
- package/docs/harness-mode.md +1 -1
- package/docs/managed-spawn.md +4 -4
- package/docs/onboarding.md +1 -2
- package/docs/pr-comment-followup.md +3 -3
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
- package/docs/qa-runbook-lifecycle.md +4 -4
- package/docs/qa-runbooks.md +2 -2
- package/docs/rfc-completion-json.md +4 -1
- package/docs/runtime-adapters.md +1 -1
- package/docs/self-improvement.md +4 -5
- package/docs/shared-lifecycle-module-map.md +3 -1
- package/docs/slim-ux/architecture-suggestions.md +5 -6
- package/docs/slim-ux/concepts.md +23 -25
- package/docs/watches.md +7 -7
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +1 -1
- package/engine/abandoned-pr-reconciliation.js +4 -5
- package/engine/acp-transport.js +49 -8
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +124 -15
- package/engine/bridge.js +260 -5
- package/engine/cleanup.js +48 -131
- package/engine/cli.js +125 -83
- package/engine/cooldown.js +9 -16
- package/engine/db/index.js +22 -9
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/020-qa-session-scopes.js +23 -0
- package/engine/db/migrations/021-archived-work-items.js +72 -0
- package/engine/db/migrations/022-global-cc-session.js +31 -0
- package/engine/db/migrations/023-engine-state.js +30 -0
- package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
- package/engine/dispatch-store.js +2 -7
- package/engine/dispatch.js +34 -41
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +221 -283
- package/engine/llm.js +1 -1
- package/engine/logs-store.js +2 -2
- package/engine/managed-spawn.js +2 -23
- package/engine/meeting.js +6 -6
- package/engine/metrics-store.js +2 -2
- package/engine/note-link-backfill.js +6 -11
- package/engine/pipeline.js +18 -36
- package/engine/playbook.js +1 -1
- package/engine/pooled-agent-process.js +46 -26
- package/engine/prd-store.js +73 -54
- package/engine/preflight.js +2 -5
- package/engine/projects.js +15 -62
- package/engine/pull-requests-store.js +0 -17
- package/engine/qa-runbooks.js +2 -2
- package/engine/qa-runs.js +1 -8
- package/engine/qa-sessions.js +41 -64
- package/engine/queries.js +120 -219
- package/engine/routing.js +3 -5
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +132 -637
- package/engine/small-state-store.js +89 -10
- package/engine/state-operations.js +16 -4
- package/engine/stdio-timestamps.js +1 -1
- package/engine/timeout.js +5 -12
- package/engine/watch-actions.js +20 -22
- package/engine/watches-store.js +1 -1
- package/engine/watches.js +6 -10
- package/engine/work-item-validation.js +52 -0
- package/engine/work-items-store.js +127 -29
- package/engine/worktree-gc.js +2 -2
- package/engine/worktree-pool.js +8 -18
- package/engine.js +167 -349
- package/minions.js +2 -2
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -2
- package/playbooks/shared-rules.md +3 -3
- package/playbooks/templates/followup-dispatch.md +1 -1
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +2 -2
package/engine/prd-store.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// engine/prd-store.js
|
|
2
2
|
//
|
|
3
|
-
// SQL-authoritative PRD state.
|
|
4
|
-
// existing callers, but no runtime PRD JSON is read or written here.
|
|
5
|
-
|
|
6
|
-
const path = require('path');
|
|
3
|
+
// SQL-authoritative PRD state.
|
|
7
4
|
|
|
8
5
|
function _toMs(v) {
|
|
9
6
|
if (v == null) return null;
|
|
@@ -13,28 +10,12 @@ function _toMs(v) {
|
|
|
13
10
|
}
|
|
14
11
|
function _bool01(v) { return v ? 1 : 0; }
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
function parsePrdPath(filePath) {
|
|
21
|
-
if (!filePath || typeof filePath !== 'string') return null;
|
|
22
|
-
let minionsDir;
|
|
23
|
-
try { minionsDir = require('./shared').MINIONS_DIR; } catch { return null; }
|
|
24
|
-
if (!minionsDir) return null;
|
|
25
|
-
const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
26
|
-
const full = norm(filePath);
|
|
27
|
-
const prdRoot = norm(path.join(minionsDir, 'prd'));
|
|
28
|
-
const archiveRoot = norm(path.join(minionsDir, 'prd', 'archive'));
|
|
29
|
-
if (!full.endsWith('.json')) return null;
|
|
30
|
-
const base = path.basename(filePath);
|
|
31
|
-
if (full === norm(path.join(archiveRoot, base)) && full.startsWith(archiveRoot + '/')) {
|
|
32
|
-
return { filename: base, archived: 1 };
|
|
33
|
-
}
|
|
34
|
-
if (full === norm(path.join(prdRoot, base)) && full.startsWith(prdRoot + '/')) {
|
|
35
|
-
return { filename: base, archived: 0 };
|
|
13
|
+
function _validatePrdFilename(filename) {
|
|
14
|
+
if (typeof filename !== 'string' || !filename.endsWith('.json')
|
|
15
|
+
|| filename.includes('/') || filename.includes('\\') || filename === '.json') {
|
|
16
|
+
throw new TypeError('PRD filename must be a basename ending in .json');
|
|
36
17
|
}
|
|
37
|
-
return
|
|
18
|
+
return filename;
|
|
38
19
|
}
|
|
39
20
|
|
|
40
21
|
// (filename, archived) → plans.id, preferring the same archived bucket.
|
|
@@ -48,6 +29,22 @@ function _resolvePlanId(db, sourcePlan, archived) {
|
|
|
48
29
|
|
|
49
30
|
// Core upsert: write/replace the prds row + its prd_items + prd_verify_prs for
|
|
50
31
|
// (filename, archived). Returns the prds.id. Runs inside a transaction.
|
|
32
|
+
function _clearWorkItemLinks(db, prdId, predicate = '', params = []) {
|
|
33
|
+
db.prepare(`
|
|
34
|
+
UPDATE work_items SET prd_item_id=NULL
|
|
35
|
+
WHERE prd_item_id IN (
|
|
36
|
+
SELECT id FROM prd_items WHERE prd_id=? ${predicate}
|
|
37
|
+
)
|
|
38
|
+
`).run(prdId, ...params);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _deletePrdRow(db, prdId) {
|
|
42
|
+
_clearWorkItemLinks(db, prdId);
|
|
43
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
|
|
44
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
|
|
45
|
+
db.prepare('DELETE FROM prds WHERE id=?').run(prdId);
|
|
46
|
+
}
|
|
47
|
+
|
|
51
48
|
function _upsertPrd(db, filename, archived, prd, now) {
|
|
52
49
|
const sourcePlan = prd.source_plan || prd.sourcePlan || null;
|
|
53
50
|
const planId = _resolvePlanId(db, sourcePlan, archived);
|
|
@@ -98,10 +95,13 @@ function _upsertPrd(db, filename, archived, prd, now) {
|
|
|
98
95
|
for (const ft of features) { if (ft && typeof ft === 'object' && ft.id) keepIds.push(String(ft.id)); }
|
|
99
96
|
// Drop id-bearing items no longer on the PRD, plus all NULL-feature_id rows.
|
|
100
97
|
if (keepIds.length) {
|
|
98
|
+
const predicate = `AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`;
|
|
99
|
+
_clearWorkItemLinks(db, prdId, predicate, keepIds);
|
|
101
100
|
db.prepare(
|
|
102
101
|
`DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`
|
|
103
102
|
).run(prdId, ...keepIds);
|
|
104
103
|
} else {
|
|
104
|
+
_clearWorkItemLinks(db, prdId, 'AND feature_id IS NOT NULL');
|
|
105
105
|
db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL').run(prdId);
|
|
106
106
|
}
|
|
107
107
|
db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NULL').run(prdId);
|
|
@@ -181,27 +181,51 @@ function _upsertPrd(db, filename, archived, prd, now) {
|
|
|
181
181
|
return prdId;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
function writePrd(
|
|
185
|
-
|
|
186
|
-
if (!
|
|
187
|
-
throw new TypeError('prd-store.writePrd requires a
|
|
184
|
+
function writePrd(filename, prd, { archived = false } = {}) {
|
|
185
|
+
_validatePrdFilename(filename);
|
|
186
|
+
if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
|
|
187
|
+
throw new TypeError('prd-store.writePrd requires a filename and object');
|
|
188
188
|
}
|
|
189
189
|
const { getDb, withTransaction } = require('./db');
|
|
190
190
|
const db = getDb();
|
|
191
|
-
const
|
|
192
|
-
|
|
191
|
+
const archivedValue = archived ? 1 : 0;
|
|
192
|
+
const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue, prd, Date.now()));
|
|
193
|
+
require('./db-events').emitStateEvent('prds', { filename, archived: archivedValue });
|
|
193
194
|
return { ok: true, id: prdId, plan: prd };
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
if (!
|
|
199
|
-
throw new TypeError('prd-store.
|
|
197
|
+
function createPrdUnique(baseFilename, prd) {
|
|
198
|
+
_validatePrdFilename(baseFilename);
|
|
199
|
+
if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
|
|
200
|
+
throw new TypeError('prd-store.createPrdUnique requires a PRD object');
|
|
201
|
+
}
|
|
202
|
+
const stem = baseFilename.slice(0, -'.json'.length);
|
|
203
|
+
const { getDb, withTransaction } = require('./db');
|
|
204
|
+
const db = getDb();
|
|
205
|
+
const filename = withTransaction(db, () => {
|
|
206
|
+
const exists = db.prepare('SELECT 1 FROM prds WHERE filename=? LIMIT 1');
|
|
207
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
208
|
+
const candidate = attempt === 0 ? baseFilename : `${stem}-${attempt}.json`;
|
|
209
|
+
if (exists.get(candidate)) continue;
|
|
210
|
+
_upsertPrd(db, candidate, 0, prd, Date.now());
|
|
211
|
+
return candidate;
|
|
212
|
+
}
|
|
213
|
+
throw new Error('Could not reserve unique PRD filename after 100 attempts');
|
|
214
|
+
});
|
|
215
|
+
require('./db-events').emitStateEvent('prds', { filename, archived: 0 });
|
|
216
|
+
return filename;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function mutatePrd(filename, mutator, { archived = false, defaultValue = {} } = {}) {
|
|
220
|
+
_validatePrdFilename(filename);
|
|
221
|
+
if (typeof mutator !== 'function') {
|
|
222
|
+
throw new TypeError('prd-store.mutatePrd requires a filename and mutator');
|
|
200
223
|
}
|
|
201
224
|
const { getDb, withTransaction } = require('./db');
|
|
202
225
|
const db = getDb();
|
|
203
226
|
const result = withTransaction(db, () => {
|
|
204
|
-
const
|
|
227
|
+
const archivedValue = archived ? 1 : 0;
|
|
228
|
+
const row = db.prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(filename, archivedValue);
|
|
205
229
|
let current = row ? JSON.parse(row.data) : structuredClone(defaultValue);
|
|
206
230
|
if (!current || typeof current !== 'object' || Array.isArray(current)) current = structuredClone(defaultValue);
|
|
207
231
|
const next = mutator(current);
|
|
@@ -209,36 +233,33 @@ function mutatePrd(filePath, mutator, { defaultValue = {} } = {}) {
|
|
|
209
233
|
if (!finalData || typeof finalData !== 'object' || Array.isArray(finalData)) {
|
|
210
234
|
throw new TypeError('PRD mutator must return an object or mutate in place');
|
|
211
235
|
}
|
|
212
|
-
_upsertPrd(db,
|
|
236
|
+
_upsertPrd(db, filename, archivedValue, finalData, Date.now());
|
|
213
237
|
return finalData;
|
|
214
238
|
});
|
|
215
|
-
require('./db-events').emitStateEvent('prds', { filename
|
|
239
|
+
require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0 });
|
|
216
240
|
return result;
|
|
217
241
|
}
|
|
218
242
|
|
|
219
|
-
function deletePrd(
|
|
220
|
-
|
|
221
|
-
if (!parsed) throw new TypeError('prd-store.deletePrd requires a PRD path');
|
|
243
|
+
function deletePrd(filename, { archived = false } = {}) {
|
|
244
|
+
_validatePrdFilename(filename);
|
|
222
245
|
const { getDb, withTransaction } = require('./db');
|
|
223
246
|
const db = getDb();
|
|
224
247
|
const removed = withTransaction(db, () => {
|
|
225
|
-
const
|
|
248
|
+
const archivedValue = archived ? 1 : 0;
|
|
249
|
+
const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(filename, archivedValue);
|
|
226
250
|
if (!row) return false;
|
|
227
|
-
db
|
|
228
|
-
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
|
|
229
|
-
db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
|
|
251
|
+
_deletePrdRow(db, row.id);
|
|
230
252
|
return true;
|
|
231
253
|
});
|
|
232
|
-
if (removed) require('./db-events').emitStateEvent('prds', { filename
|
|
254
|
+
if (removed) require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0, removed: true });
|
|
233
255
|
return removed;
|
|
234
256
|
}
|
|
235
257
|
|
|
236
|
-
function readPrd(
|
|
237
|
-
|
|
238
|
-
if (!parsed) return null;
|
|
258
|
+
function readPrd(filename, { archived = false } = {}) {
|
|
259
|
+
_validatePrdFilename(filename);
|
|
239
260
|
const row = require('./db').getDb()
|
|
240
261
|
.prepare('SELECT data FROM prds WHERE filename=? AND archived=?')
|
|
241
|
-
.get(
|
|
262
|
+
.get(filename, archived ? 1 : 0);
|
|
242
263
|
return row ? JSON.parse(row.data) : null;
|
|
243
264
|
}
|
|
244
265
|
|
|
@@ -262,9 +283,7 @@ function restorePrdFromArchive(filename) {
|
|
|
262
283
|
if (!row) return null;
|
|
263
284
|
const prd = JSON.parse(row.data);
|
|
264
285
|
_upsertPrd(db, filename, 0, prd, Date.now());
|
|
265
|
-
db
|
|
266
|
-
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
|
|
267
|
-
db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
|
|
286
|
+
_deletePrdRow(db, row.id);
|
|
268
287
|
return prd;
|
|
269
288
|
});
|
|
270
289
|
if (restored) {
|
|
@@ -332,8 +351,8 @@ function listPrdRows() {
|
|
|
332
351
|
}
|
|
333
352
|
|
|
334
353
|
module.exports = {
|
|
335
|
-
parsePrdPath,
|
|
336
354
|
writePrd,
|
|
355
|
+
createPrdUnique,
|
|
337
356
|
mutatePrd,
|
|
338
357
|
deletePrd,
|
|
339
358
|
readPrd,
|
package/engine/preflight.js
CHANGED
|
@@ -266,11 +266,8 @@ function runPreflight(opts = {}) {
|
|
|
266
266
|
// discoverFromWorkItems / discoverFromPrs return [] without logging.
|
|
267
267
|
try {
|
|
268
268
|
const projectWarns = shared.projectWorkSourceWarnings(opts.config, (project) => {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
const safeJson = shared.safeJson;
|
|
272
|
-
const wi = (() => { try { const a = safeJson(wiPath); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
|
273
|
-
const pr = (() => { try { const a = safeJson(prPath); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
|
269
|
+
const wi = (() => { try { return shared.readWorkItems(project).length; } catch { return 0; } })();
|
|
270
|
+
const pr = (() => { try { return shared.readPullRequests(project).length; } catch { return 0; } })();
|
|
274
271
|
return { workItems: wi, pullRequests: pr };
|
|
275
272
|
});
|
|
276
273
|
for (const w of projectWarns) {
|
package/engine/projects.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const shared = require('./shared');
|
|
11
11
|
const dispatch = require('./dispatch');
|
|
12
|
+
const queries = require('./queries');
|
|
12
13
|
const projectDiscovery = require('./project-discovery');
|
|
13
14
|
const { MINIONS_DIR } = shared;
|
|
14
15
|
|
|
@@ -86,9 +87,8 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
|
|
89
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
90
90
|
const ids = new Set();
|
|
91
|
-
const state =
|
|
91
|
+
const state = queries.getDispatch();
|
|
92
92
|
for (const queue of ['pending', 'active']) {
|
|
93
93
|
for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
|
|
94
94
|
if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
|
|
@@ -99,9 +99,8 @@ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
|
|
|
99
99
|
|
|
100
100
|
function _requeueProjectlessCentralWorkItems(itemIds) {
|
|
101
101
|
if (!itemIds || itemIds.size === 0) return 0;
|
|
102
|
-
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
103
102
|
let requeued = 0;
|
|
104
|
-
shared.mutateWorkItems(
|
|
103
|
+
shared.mutateWorkItems('central', items => {
|
|
105
104
|
if (!Array.isArray(items)) return items;
|
|
106
105
|
for (const w of items) {
|
|
107
106
|
if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
|
|
@@ -124,10 +123,7 @@ function _requeueProjectlessCentralWorkItems(itemIds) {
|
|
|
124
123
|
* @param {object} [options]
|
|
125
124
|
* @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
|
|
126
125
|
* archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
|
|
127
|
-
* keep: leave projects/<name>/ in place
|
|
128
|
-
* pull-requests.json into projects/<name>/.dropped/ so dropScope()
|
|
129
|
-
* (step 9) isn't immediately undone by the *-store.js resync-on-
|
|
130
|
-
* read path re-hydrating the still-present JSON (W-mr96oira000g979a)
|
|
126
|
+
* keep: leave projects/<name>/ in place
|
|
131
127
|
* purge: rm -rf projects/<name>/
|
|
132
128
|
* @returns {object} summary { ok, project, cancelledItems, killedAgents,
|
|
133
129
|
* drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
|
|
@@ -147,7 +143,6 @@ function removeProject(target, options = {}) {
|
|
|
147
143
|
pipelineRefs: [],
|
|
148
144
|
archivedTo: null,
|
|
149
145
|
purgedDataDir: false,
|
|
150
|
-
tombstonedDataFiles: false,
|
|
151
146
|
// P-bfa2d-remove-project-cleanup — soft-delete tag counts for entries in
|
|
152
147
|
// the three central state files that survived the hard-cancel passes
|
|
153
148
|
// (steps 1, 2). See step 7.5 for the orphan-tag policy rationale.
|
|
@@ -168,15 +163,14 @@ function removeProject(target, options = {}) {
|
|
|
168
163
|
}
|
|
169
164
|
summary.project = { name: project.name, localPath: project.localPath };
|
|
170
165
|
|
|
171
|
-
// 1. Cancel pending/queued work items linked to this project
|
|
172
|
-
// file + central). Done items are preserved as history.
|
|
166
|
+
// 1. Cancel pending/queued work items linked to this project.
|
|
173
167
|
summary.cancelledItems += dispatch.cancelPendingWorkItems(
|
|
174
|
-
|
|
168
|
+
project,
|
|
175
169
|
() => true,
|
|
176
170
|
'project-removed',
|
|
177
171
|
);
|
|
178
172
|
summary.cancelledItems += dispatch.cancelPendingWorkItems(
|
|
179
|
-
|
|
173
|
+
'central',
|
|
180
174
|
w => _workItemMatchesProject(w, project, projects),
|
|
181
175
|
'project-removed',
|
|
182
176
|
);
|
|
@@ -190,7 +184,7 @@ function removeProject(target, options = {}) {
|
|
|
190
184
|
_requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
|
|
191
185
|
|
|
192
186
|
// 2.5. Managed-spawn cleanup (P-8a4d6f29). Centralised in managed-spawn.js
|
|
193
|
-
// so no other module needs to know
|
|
187
|
+
// so no other module needs to know the managed-process storage schema.
|
|
194
188
|
// Kills + unlinks every spec owned by this project, including the .log
|
|
195
189
|
// and .log.1 rotated sibling. Best-effort: failures only warn.
|
|
196
190
|
try {
|
|
@@ -316,11 +310,8 @@ function removeProject(target, options = {}) {
|
|
|
316
310
|
} catch (e) { writeError = e; }
|
|
317
311
|
if (writeError) return { ...summary, error: 'Failed to write config: ' + writeError.message };
|
|
318
312
|
|
|
319
|
-
// 7.5.
|
|
320
|
-
//
|
|
321
|
-
// `engine/dispatch.json`) so downstream consumers can filter or sweep
|
|
322
|
-
// them. Project-local files in `projects/<name>/` are handled by
|
|
323
|
-
// step 8 (archive or purge), so this step only touches central state.
|
|
313
|
+
// 7.5. Tag dangling refs in central state so downstream consumers can filter
|
|
314
|
+
// or sweep them. Project-scoped rows are removed in step 9.
|
|
324
315
|
//
|
|
325
316
|
// Policy is soft-delete (`_orphaned_at` + `_orphaned_project` tags)
|
|
326
317
|
// rather than hard-delete: preserves history for debugging, lets users
|
|
@@ -330,17 +321,11 @@ function removeProject(target, options = {}) {
|
|
|
330
321
|
// survivors (non-pending WIs, central PRs, defense-in-depth dispatch
|
|
331
322
|
// survivors).
|
|
332
323
|
//
|
|
333
|
-
// Lock acquisition is sequential and uses the mutate-* helpers from
|
|
334
|
-
// engine/dispatch.js and engine/shared.js so we never hold two file
|
|
335
|
-
// locks simultaneously. Order: dispatch.json → pull-requests.json →
|
|
336
|
-
// work-items.json (alphabetical by basename). Each helper acquires
|
|
337
|
-
// its own lock, runs the synchronous mutator, then releases before
|
|
338
|
-
// the next helper runs.
|
|
339
324
|
try {
|
|
340
325
|
const orphanedAt = new Date().toISOString();
|
|
341
326
|
const orphanedProject = project.name;
|
|
342
327
|
|
|
343
|
-
// (a)
|
|
328
|
+
// (a) Dispatch state — defense-in-depth. cleanDispatchEntries in
|
|
344
329
|
// step 2 should have already removed every matching entry; tag any
|
|
345
330
|
// survivor so the operator can see the residue.
|
|
346
331
|
try {
|
|
@@ -361,11 +346,10 @@ function removeProject(target, options = {}) {
|
|
|
361
346
|
|
|
362
347
|
// (b) Central PR registry. PRs in this scope lack an explicit project field; match by canonical scope
|
|
363
348
|
// (e.g. `github:owner/repo`) against the removed project's PR scope.
|
|
364
|
-
const centralPrPath = shared.centralPullRequestsPath();
|
|
365
349
|
try {
|
|
366
350
|
const projectScope = shared.getProjectPrScope(project);
|
|
367
351
|
if (projectScope) {
|
|
368
|
-
shared.mutatePullRequests(
|
|
352
|
+
shared.mutatePullRequests('central', (prs) => {
|
|
369
353
|
for (const pr of prs) {
|
|
370
354
|
if (!pr || pr._orphaned_at) continue;
|
|
371
355
|
const canonical = shared.parseCanonicalPrId(pr.id);
|
|
@@ -383,9 +367,8 @@ function removeProject(target, options = {}) {
|
|
|
383
367
|
// were already CANCELLED by cancelPendingWorkItems in step 1; tag
|
|
384
368
|
// the remaining matchers (dispatched / done / failed / etc.) so
|
|
385
369
|
// downstream consumers can filter them.
|
|
386
|
-
const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
387
370
|
try {
|
|
388
|
-
shared.mutateWorkItems(
|
|
371
|
+
shared.mutateWorkItems('central', (items) => {
|
|
389
372
|
for (const w of items) {
|
|
390
373
|
if (!w || w._orphaned_at) continue;
|
|
391
374
|
if (w.status === shared.WI_STATUS.CANCELLED) continue;
|
|
@@ -424,40 +407,10 @@ function removeProject(target, options = {}) {
|
|
|
424
407
|
summary.archiveError = e.code ? `${e.code}: ${e.message}` : e.message;
|
|
425
408
|
summary.warnings.push('archive data dir: ' + e.message);
|
|
426
409
|
}
|
|
427
|
-
} else if (dataMode === 'keep') {
|
|
428
|
-
// W-mr96oira000g979a — 'keep' leaves projects/<name>/ (and therefore
|
|
429
|
-
// work-items.json / pull-requests.json) at its normal, live-resolving
|
|
430
|
-
// path. dropScope() below DELETEs the SQL rows for this scope, but the
|
|
431
|
-
// *-store.js resync-on-read path (_resyncScopeIfJsonDiverged) treats a
|
|
432
|
-
// scope with an empty SQL table + a still-present JSON file as a
|
|
433
|
-
// fresh-install first-touch and re-hydrates straight from that JSON —
|
|
434
|
-
// silently undoing the drop the very next time anything reads this
|
|
435
|
-
// scope. Tombstone the two live-state files into a `.dropped/`
|
|
436
|
-
// subdirectory so `_filePathForScope(scope)` (and the read-side
|
|
437
|
-
// resync/fallback logic keyed off it) no longer resolves to them,
|
|
438
|
-
// while keeping the historical data on disk for manual inspection —
|
|
439
|
-
// exactly what 'keep' promises, minus the auto-resurrection.
|
|
440
|
-
try {
|
|
441
|
-
const droppedDir = path.join(dataDir, '.dropped');
|
|
442
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
443
|
-
for (const fname of ['work-items.json', 'pull-requests.json']) {
|
|
444
|
-
const src = path.join(dataDir, fname);
|
|
445
|
-
if (!fs.existsSync(src)) continue;
|
|
446
|
-
fs.mkdirSync(droppedDir, { recursive: true });
|
|
447
|
-
let dest = path.join(droppedDir, `${fname}.${stamp}`);
|
|
448
|
-
let n = 1;
|
|
449
|
-
while (fs.existsSync(dest)) dest = path.join(droppedDir, `${fname}.${stamp}-${++n}`);
|
|
450
|
-
_renameWithRetry(src, dest);
|
|
451
|
-
summary.tombstonedDataFiles = true;
|
|
452
|
-
}
|
|
453
|
-
} catch (e) {
|
|
454
|
-
summary.warnings.push('tombstone kept data files: ' + e.message);
|
|
455
|
-
}
|
|
456
410
|
}
|
|
457
411
|
}
|
|
458
412
|
|
|
459
|
-
// 9. Drop SQL rows for the project
|
|
460
|
-
// surface the removed project after the JSON file is archived.
|
|
413
|
+
// 9. Drop SQL rows for the removed project scope.
|
|
461
414
|
try { require('./work-items-store').dropScope(project.name); } catch { /* best-effort */ }
|
|
462
415
|
try { require('./pull-requests-store').dropScope(project.name); } catch { /* best-effort */ }
|
|
463
416
|
|
|
@@ -571,7 +524,7 @@ async function addProject(target, options = {}) {
|
|
|
571
524
|
assertProjectLinkOk(project);
|
|
572
525
|
|
|
573
526
|
// 7. Ensure project state files (projects/<name>/...).
|
|
574
|
-
shared.
|
|
527
|
+
shared.initializeProjectState(project);
|
|
575
528
|
|
|
576
529
|
// 8. Persist under config lock. Re-check inside the lock so a concurrent
|
|
577
530
|
// add can't insert a duplicate between our pre-check and the write.
|
|
@@ -13,21 +13,6 @@ function _toMs(v) {
|
|
|
13
13
|
return Number.isFinite(parsed) ? parsed : null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function scopeForFilePath(filePath) {
|
|
17
|
-
const norm = String(filePath).replace(/\\/g, '/');
|
|
18
|
-
const m = norm.match(/projects\/([^/]+)\/pull-requests\.json$/);
|
|
19
|
-
if (m) return m[1];
|
|
20
|
-
return 'central';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Compatibility adapter for callers that still pass a path-shaped scope token
|
|
24
|
-
// into shared.mutatePullRequests. No file is read or written.
|
|
25
|
-
function _filePathForScope(scope) {
|
|
26
|
-
const shared = require('./shared');
|
|
27
|
-
if (!scope || scope === 'central') return shared.centralPullRequestsPath(shared.MINIONS_DIR);
|
|
28
|
-
return require('path').join(shared.MINIONS_DIR, 'projects', scope, 'pull-requests.json');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
16
|
function _parseRow(row) {
|
|
32
17
|
if (!row || !row.data) return null;
|
|
33
18
|
try { return JSON.parse(row.data); }
|
|
@@ -169,8 +154,6 @@ function dropScope(scope) {
|
|
|
169
154
|
}
|
|
170
155
|
|
|
171
156
|
module.exports = {
|
|
172
|
-
_filePathForScope,
|
|
173
|
-
scopeForFilePath,
|
|
174
157
|
readPullRequestsForScope,
|
|
175
158
|
readAllPullRequests,
|
|
176
159
|
applyPullRequestsMutation,
|
package/engine/qa-runbooks.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
|
|
5
5
|
* that travel with a project entry, mirroring the
|
|
6
|
-
*
|
|
6
|
+
* project-local definition precedent. Each runbook is one JSON file
|
|
7
7
|
* at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
|
|
8
8
|
*
|
|
9
9
|
* Pure persistence + validation only — this module does NOT spawn agents,
|
|
@@ -27,7 +27,7 @@ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
27
27
|
// keep this module self-contained for path-traversal hardening (review feedback
|
|
28
28
|
// on PR #2694: id/project params previously flowed into path.join without
|
|
29
29
|
// validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
|
|
30
|
-
// and DELETE could wipe
|
|
30
|
+
// and DELETE could wipe unrelated runtime state).
|
|
31
31
|
const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
|
|
32
32
|
|
|
33
33
|
const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
|
package/engine/qa-runs.js
CHANGED
|
@@ -33,7 +33,7 @@ const { mutateQaRuns, uid, ts, log } = shared;
|
|
|
33
33
|
|
|
34
34
|
// Cap qa_runs so the table doesn't grow unboundedly
|
|
35
35
|
// over months of nightly QA dispatch. Mirrors the 2500-entry cap on
|
|
36
|
-
// engine
|
|
36
|
+
// the engine log. createRun trims oldest-by-createdAt when crossing the
|
|
37
37
|
// threshold; terminal-status runs that have already shipped completion
|
|
38
38
|
// notifications are safe to drop.
|
|
39
39
|
const QA_RUNS_MAX_RECORDS = 2000;
|
|
@@ -80,12 +80,6 @@ const ALLOWED_TRANSITIONS = {
|
|
|
80
80
|
[QA_RUN_STATUS.ERRORED]: new Set(),
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
// Dynamic paths — respect MINIONS_TEST_DIR for test isolation. Kept exported
|
|
84
|
-
// for back-compat with tests that probe the JSON sidecar on disk.
|
|
85
|
-
function qaRunsPath() {
|
|
86
|
-
return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
83
|
function qaArtifactsDir() {
|
|
90
84
|
return path.join(shared.MINIONS_DIR, 'engine', 'qa-artifacts');
|
|
91
85
|
}
|
|
@@ -416,7 +410,6 @@ module.exports = {
|
|
|
416
410
|
QA_RUN_STATUS,
|
|
417
411
|
TERMINAL_STATUSES,
|
|
418
412
|
ALLOWED_TRANSITIONS,
|
|
419
|
-
qaRunsPath,
|
|
420
413
|
qaArtifactsDir,
|
|
421
414
|
qaArtifactsDirForRun,
|
|
422
415
|
createRun,
|