@yemi33/minions 0.1.2286 → 0.1.2287
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/render-inbox.js +32 -3
- package/dashboard/js/render-other.js +1 -1
- package/dashboard/js/render-plans.js +3 -3
- package/dashboard/js/render-prs.js +45 -4
- package/dashboard/js/render-schedules.js +2 -2
- package/dashboard/js/render-skills.js +3 -3
- package/dashboard/js/render-watches.js +2 -2
- package/dashboard/js/render-work-items.js +3 -1
- package/dashboard/js/settings.js +45 -0
- package/dashboard/pages/engine.html +3 -3
- package/dashboard/pages/meetings.html +2 -2
- package/dashboard/pages/pipelines.html +1 -1
- package/dashboard/pages/tools.html +3 -3
- package/dashboard/pages/watches.html +1 -1
- package/dashboard/slim/js/status.js +29 -15
- package/dashboard/styles.css +22 -4
- package/dashboard.js +51 -12
- package/docs/deprecated.json +35 -161
- package/docs/live-checkout-mode.md +54 -0
- package/docs/project-skills.md +0 -1
- package/engine/cleanup.js +51 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/features.js +14 -0
- package/engine/llm.js +1 -0
- package/engine/playbook.js +4 -4
- package/engine/prd-store.js +264 -0
- package/engine/queries.js +97 -59
- package/engine/runtimes/copilot.js +32 -5
- package/engine/shared.js +24 -8
- package/engine/watch-actions.js +4 -1
- package/engine.js +42 -7
- package/package.json +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// engine/prd-store.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 10 step 2: SQL mirror for PRD state. PRD content writes go to the
|
|
4
|
+
// canonical prd/*.json file AND are mirrored into the plans/prds/prd_items/
|
|
5
|
+
// prd_verify_prs tables created by migration 015. JSON stays canonical and is
|
|
6
|
+
// still the only thing read in this step — the SQL side is a passive mirror so
|
|
7
|
+
// the read-flip (step 3) has a trustworthy source.
|
|
8
|
+
//
|
|
9
|
+
// All entry points are BEST-EFFORT: a SQLite failure (Node < 22.5, locked DB,
|
|
10
|
+
// schema not yet migrated) must never break a PRD JSON write. Callers wrap via
|
|
11
|
+
// the thin `mirror*`/`remove*` helpers which swallow and return {ok:false}.
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
|
|
16
|
+
function _toMs(v) {
|
|
17
|
+
if (v == null) return null;
|
|
18
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
19
|
+
const parsed = Date.parse(v);
|
|
20
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
21
|
+
}
|
|
22
|
+
function _bool01(v) { return v ? 1 : 0; }
|
|
23
|
+
|
|
24
|
+
// Resolve <MINIONS_DIR>/prd/<f>.json → {filename, archived:0}; the
|
|
25
|
+
// prd/archive/<f>.json variant → {filename, archived:1}; anything else → null.
|
|
26
|
+
// Path comparison is normalized + case-insensitive on the directory prefix so
|
|
27
|
+
// Windows drive-letter / separator differences don't cause a miss.
|
|
28
|
+
function parsePrdPath(filePath) {
|
|
29
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
30
|
+
let minionsDir;
|
|
31
|
+
try { minionsDir = require('./shared').MINIONS_DIR; } catch { return null; }
|
|
32
|
+
if (!minionsDir) return null;
|
|
33
|
+
const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
34
|
+
const full = norm(filePath);
|
|
35
|
+
const prdRoot = norm(path.join(minionsDir, 'prd'));
|
|
36
|
+
const archiveRoot = norm(path.join(minionsDir, 'prd', 'archive'));
|
|
37
|
+
if (!full.endsWith('.json')) return null;
|
|
38
|
+
const base = path.basename(filePath);
|
|
39
|
+
if (full === norm(path.join(archiveRoot, base)) && full.startsWith(archiveRoot + '/')) {
|
|
40
|
+
return { filename: base, archived: 1 };
|
|
41
|
+
}
|
|
42
|
+
if (full === norm(path.join(prdRoot, base)) && full.startsWith(prdRoot + '/')) {
|
|
43
|
+
return { filename: base, archived: 0 };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// (filename, archived) → plans.id, preferring the same archived bucket.
|
|
49
|
+
function _resolvePlanId(db, sourcePlan, archived) {
|
|
50
|
+
if (!sourcePlan) return null;
|
|
51
|
+
const same = db.prepare('SELECT id FROM plans WHERE filename=? AND archived=? ORDER BY id LIMIT 1').get(sourcePlan, archived);
|
|
52
|
+
if (same) return same.id;
|
|
53
|
+
const other = db.prepare('SELECT id FROM plans WHERE filename=? ORDER BY id LIMIT 1').get(sourcePlan);
|
|
54
|
+
return other ? other.id : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Core upsert: write/replace the prds row + its prd_items + prd_verify_prs for
|
|
58
|
+
// (filename, archived). Returns the prds.id. Runs inside a transaction.
|
|
59
|
+
function _upsertPrd(db, filename, archived, prd, now) {
|
|
60
|
+
const sourcePlan = prd.source_plan || prd.sourcePlan || null;
|
|
61
|
+
const planId = _resolvePlanId(db, sourcePlan, archived);
|
|
62
|
+
const existing = db.prepare('SELECT id, created_at FROM prds WHERE filename=? AND archived=?').get(filename, archived);
|
|
63
|
+
|
|
64
|
+
let prdId;
|
|
65
|
+
if (existing) {
|
|
66
|
+
prdId = existing.id;
|
|
67
|
+
db.prepare(`
|
|
68
|
+
UPDATE prds SET plan_id=?, project=?, version=?, status=?,
|
|
69
|
+
completed_at=?, completion_notified=?, plan_stale=?, source_plan=?,
|
|
70
|
+
source_plan_modified_at=?, data=?, updated_at=? WHERE id=?
|
|
71
|
+
`).run(
|
|
72
|
+
planId, prd.project || null, prd.version != null ? String(prd.version) : null, prd.status || null,
|
|
73
|
+
_toMs(prd.completedAt || prd.completed_at), _bool01(prd._completionNotified), _bool01(prd.planStale),
|
|
74
|
+
sourcePlan, prd.sourcePlanModifiedAt || prd.source_plan_modified_at || null,
|
|
75
|
+
JSON.stringify(prd), now, prdId,
|
|
76
|
+
);
|
|
77
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
|
|
78
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
|
|
79
|
+
} else {
|
|
80
|
+
const info = db.prepare(`
|
|
81
|
+
INSERT INTO prds (plan_id, filename, project, version, status, archived,
|
|
82
|
+
completed_at, completion_notified, plan_stale, source_plan,
|
|
83
|
+
source_plan_modified_at, data, created_at, updated_at)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
85
|
+
`).run(
|
|
86
|
+
planId, filename, prd.project || null, prd.version != null ? String(prd.version) : null, prd.status || null, archived,
|
|
87
|
+
_toMs(prd.completedAt || prd.completed_at), _bool01(prd._completionNotified), _bool01(prd.planStale),
|
|
88
|
+
sourcePlan, prd.sourcePlanModifiedAt || prd.source_plan_modified_at || null,
|
|
89
|
+
JSON.stringify(prd), _toMs(prd.generated_at || prd.createdAt) || now, now,
|
|
90
|
+
);
|
|
91
|
+
prdId = Number(info.lastInsertRowid);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const insItem = db.prepare(`
|
|
95
|
+
INSERT INTO prd_items (prd_id, feature_id, name, project, status, type, work_item_id, data, updated_at)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
97
|
+
`);
|
|
98
|
+
const features = Array.isArray(prd.missing_features) ? prd.missing_features : [];
|
|
99
|
+
for (const ft of features) {
|
|
100
|
+
if (!ft || typeof ft !== 'object') continue;
|
|
101
|
+
try {
|
|
102
|
+
insItem.run(prdId, ft.id || null, ft.name || null, ft.project || prd.project || null,
|
|
103
|
+
ft.status || null, ft.type || null, ft.workItemId || ft.work_item_id || null,
|
|
104
|
+
JSON.stringify(ft), now);
|
|
105
|
+
} catch { /* dup (prd_id, feature_id) → skip */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const insVp = db.prepare(`
|
|
109
|
+
INSERT INTO prd_verify_prs (prd_id, pr_id, url, title, status, project, merged_at, data, updated_at)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
111
|
+
`);
|
|
112
|
+
const verifyPrs = Array.isArray(prd.verifyPrs) ? prd.verifyPrs : [];
|
|
113
|
+
for (const vp of verifyPrs) {
|
|
114
|
+
if (!vp || typeof vp !== 'object') continue;
|
|
115
|
+
try {
|
|
116
|
+
insVp.run(prdId, vp.id != null ? String(vp.id) : null, vp.url || null, vp.title || null,
|
|
117
|
+
vp.status || null, vp.project || prd.project || null, _toMs(vp.mergedAt || vp.merged_at),
|
|
118
|
+
JSON.stringify(vp), now);
|
|
119
|
+
} catch { /* best-effort */ }
|
|
120
|
+
}
|
|
121
|
+
return prdId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Mirror a PRD object to SQL for the given prd/*.json path. Best-effort.
|
|
125
|
+
function mirrorPrdToSql(filePath, prd) {
|
|
126
|
+
const parsed = parsePrdPath(filePath);
|
|
127
|
+
if (!parsed || !prd || typeof prd !== 'object' || Array.isArray(prd)) return { ok: false, reason: 'not-a-prd' };
|
|
128
|
+
try {
|
|
129
|
+
const { getDb, withTransaction } = require('./db');
|
|
130
|
+
const db = getDb();
|
|
131
|
+
withTransaction(db, () => _upsertPrd(db, parsed.filename, parsed.archived, prd, Date.now()));
|
|
132
|
+
try { require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived }); } catch { /* event optional */ }
|
|
133
|
+
return { ok: true };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { ok: false, reason: e && e.message };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Remove a PRD (and its items/verify rows) from SQL for the given path. Best-effort.
|
|
140
|
+
function removePrdFromSql(filePath) {
|
|
141
|
+
const parsed = parsePrdPath(filePath);
|
|
142
|
+
if (!parsed) return { ok: false, reason: 'not-a-prd' };
|
|
143
|
+
try {
|
|
144
|
+
const { getDb, withTransaction } = require('./db');
|
|
145
|
+
const db = getDb();
|
|
146
|
+
withTransaction(db, () => {
|
|
147
|
+
const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
|
|
148
|
+
if (!row) return;
|
|
149
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(row.id);
|
|
150
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
|
|
151
|
+
db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
|
|
152
|
+
});
|
|
153
|
+
try { require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived, removed: true }); } catch { /* optional */ }
|
|
154
|
+
return { ok: true };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { ok: false, reason: e && e.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Read one PRD back from SQL as the original object (the `data` blob). Used by
|
|
161
|
+
// the divergence test now and the read-flip in step 3.
|
|
162
|
+
function readPrdFromSql(filePath) {
|
|
163
|
+
const parsed = parsePrdPath(filePath);
|
|
164
|
+
if (!parsed) return null;
|
|
165
|
+
try {
|
|
166
|
+
const { getDb } = require('./db');
|
|
167
|
+
const row = getDb().prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
|
|
168
|
+
return row ? JSON.parse(row.data) : null;
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Per-process mtime cache so reconciliation re-mirrors only files that actually
|
|
173
|
+
// changed on disk since this process last saw them (empty after restart → the
|
|
174
|
+
// first read re-mirrors everything once). Keyed `${archived} ${filename}`.
|
|
175
|
+
const _reconcileMtimes = new Map();
|
|
176
|
+
|
|
177
|
+
function _prdDirsFor(minionsDir) {
|
|
178
|
+
return [
|
|
179
|
+
{ dir: path.join(minionsDir, 'prd'), archived: 0 },
|
|
180
|
+
{ dir: path.join(minionsDir, 'prd', 'archive'), archived: 1 },
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Reconcile the SQL mirror from the prd/ + prd/archive/ directories: re-mirror
|
|
185
|
+
// files that are new or changed on disk (catches PRDs written OUTSIDE the
|
|
186
|
+
// mutateJsonFileLocked chokepoint — e.g. the plan-to-prd agent writing the JSON
|
|
187
|
+
// directly, or an archive file-move), and drop SQL rows whose backing file is
|
|
188
|
+
// gone. Best-effort; safe to call on every read. Returns a small stats object.
|
|
189
|
+
function reconcilePrdsFromDisk() {
|
|
190
|
+
let minionsDir;
|
|
191
|
+
try { minionsDir = require('./shared').MINIONS_DIR; } catch { return { ok: false }; }
|
|
192
|
+
if (!minionsDir) return { ok: false };
|
|
193
|
+
let db;
|
|
194
|
+
try { db = require('./db').getDb(); } catch { return { ok: false }; }
|
|
195
|
+
|
|
196
|
+
let mirrored = 0, removed = 0;
|
|
197
|
+
const seen = new Set(); // `${archived} ${filename}` present on disk
|
|
198
|
+
for (const { dir, archived } of _prdDirsFor(minionsDir)) {
|
|
199
|
+
let files;
|
|
200
|
+
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); } catch { continue; }
|
|
201
|
+
for (const f of files) {
|
|
202
|
+
const key = `${archived} ${f}`;
|
|
203
|
+
seen.add(key);
|
|
204
|
+
const full = path.join(dir, f);
|
|
205
|
+
let mtime;
|
|
206
|
+
try { mtime = Math.round(fs.statSync(full).mtimeMs); } catch { continue; }
|
|
207
|
+
if (_reconcileMtimes.get(key) === mtime) continue; // unchanged since last seen
|
|
208
|
+
const prd = _readJsonObj(full);
|
|
209
|
+
if (!prd) continue;
|
|
210
|
+
try {
|
|
211
|
+
const { withTransaction } = require('./db');
|
|
212
|
+
withTransaction(db, () => _upsertPrd(db, f, archived, prd, Date.now()));
|
|
213
|
+
_reconcileMtimes.set(key, mtime);
|
|
214
|
+
mirrored += 1;
|
|
215
|
+
} catch { /* best-effort */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Drop rows whose backing file disappeared (delete/move not caught by a hook).
|
|
219
|
+
try {
|
|
220
|
+
const rows = db.prepare('SELECT id, filename, archived FROM prds').all();
|
|
221
|
+
for (const r of rows) {
|
|
222
|
+
const key = `${r.archived} ${r.filename}`;
|
|
223
|
+
if (seen.has(key)) continue;
|
|
224
|
+
try {
|
|
225
|
+
require('./db').withTransaction(db, () => {
|
|
226
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(r.id);
|
|
227
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(r.id);
|
|
228
|
+
db.prepare('DELETE FROM prds WHERE id=?').run(r.id);
|
|
229
|
+
});
|
|
230
|
+
_reconcileMtimes.delete(key);
|
|
231
|
+
removed += 1;
|
|
232
|
+
} catch { /* best-effort */ }
|
|
233
|
+
}
|
|
234
|
+
} catch { /* best-effort */ }
|
|
235
|
+
return { ok: true, mirrored, removed };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// List every PRD row as { filename, archived, plan } where plan is the parsed
|
|
239
|
+
// data blob. Sorted by (archived, filename) for deterministic consumer order
|
|
240
|
+
// (matches the disk-scan order once the caller also sorts). Read-flip source.
|
|
241
|
+
function listPrdRows() {
|
|
242
|
+
try {
|
|
243
|
+
const db = require('./db').getDb();
|
|
244
|
+
const rows = db.prepare('SELECT filename, archived, data FROM prds ORDER BY archived, filename').all();
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const r of rows) {
|
|
247
|
+
let plan;
|
|
248
|
+
try { plan = JSON.parse(r.data); } catch { continue; }
|
|
249
|
+
out.push({ filename: r.filename, archived: !!r.archived, plan });
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
} catch { return []; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
parsePrdPath,
|
|
257
|
+
mirrorPrdToSql,
|
|
258
|
+
removePrdFromSql,
|
|
259
|
+
readPrdFromSql,
|
|
260
|
+
reconcilePrdsFromDisk,
|
|
261
|
+
listPrdRows,
|
|
262
|
+
_reconcileMtimes, // exported for tests (reset between cases)
|
|
263
|
+
_upsertPrd, // exported for tests
|
|
264
|
+
};
|
package/engine/queries.js
CHANGED
|
@@ -2150,70 +2150,108 @@ function getPrdInfo(config) {
|
|
|
2150
2150
|
{ dir: PRD_DIR, archived: false },
|
|
2151
2151
|
{ dir: path.join(PRD_DIR, 'archive'), archived: true },
|
|
2152
2152
|
];
|
|
2153
|
-
|
|
2153
|
+
|
|
2154
|
+
// Phase 10 step 3 (read-flip) — both the disk scan and the SQL-mirror source
|
|
2155
|
+
// funnel each PRD through this one processor, so the consumed shape
|
|
2156
|
+
// (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
|
|
2157
|
+
// The only difference is where `plan` + `mtimeMs` come from. Equivalence of the
|
|
2158
|
+
// two paths is proven by test/unit/db-phase10-read-flip.test.js.
|
|
2159
|
+
const processPrd = (pf, archived, plan, mtimeMs) => {
|
|
2160
|
+
if (!plan || !plan.missing_features) return;
|
|
2161
|
+
// Staleness: gate on actual source-plan content change rather than mtime
|
|
2162
|
+
// alone (W-mqfevwr60018bd09). Pure path / pure mtime drifts return
|
|
2163
|
+
// stale=false so a repointed source_plan doesn't masquerade as a revision.
|
|
2164
|
+
let planStale = false;
|
|
2165
|
+
if (!archived && plan.source_plan) {
|
|
2166
|
+
try { planStale = shared.isSourcePlanContentStale(PLANS_DIR, plan).stale; } catch { /* optional */ }
|
|
2167
|
+
}
|
|
2168
|
+
existingPrds.push({
|
|
2169
|
+
file: pf,
|
|
2170
|
+
status: plan.status || 'active',
|
|
2171
|
+
planStale: planStale || plan.planStale || false,
|
|
2172
|
+
completedAt: plan.completedAt || '',
|
|
2173
|
+
_archived: archived,
|
|
2174
|
+
});
|
|
2175
|
+
if (Array.isArray(plan.verifyPrs) && plan.verifyPrs.length > 0) {
|
|
2176
|
+
verifyPrsByPlan[pf] = plan.verifyPrs.map(r => ({
|
|
2177
|
+
id: r.id, url: r.url || '', title: r.title || '',
|
|
2178
|
+
status: r.status || 'active', project: r.project || '',
|
|
2179
|
+
...(r.mergedAt ? { mergedAt: r.mergedAt } : {}),
|
|
2180
|
+
}));
|
|
2181
|
+
}
|
|
2182
|
+
for (const f of plan.missing_features) {
|
|
2183
|
+
allPrdItems.push({
|
|
2184
|
+
...f, _source: pf, _planStatus: plan.status || 'active',
|
|
2185
|
+
_planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
|
|
2186
|
+
_archived: archived, _sourcePlan: plan.source_plan || '',
|
|
2187
|
+
_branchStrategy: plan.branch_strategy || 'parallel',
|
|
2188
|
+
_planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
|
|
2189
|
+
_prdUpdatedAt: new Date(mtimeMs).toISOString(),
|
|
2190
|
+
_prdCompletedAt: plan.completedAt || '',
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
|
|
2195
|
+
let useSqlReads = false;
|
|
2196
|
+
try { useSqlReads = require('./features').isFeatureOn('prdReadsFromSql', config); } catch { /* fall back to disk */ }
|
|
2197
|
+
|
|
2198
|
+
if (useSqlReads) {
|
|
2199
|
+
// SQL source: reconcile the mirror from disk (catches PRDs written outside
|
|
2200
|
+
// the dual-write chokepoint — plan-to-prd agent direct writes, archive
|
|
2201
|
+
// moves, deletes), then read each PRD's content from SQL. Files still live on
|
|
2202
|
+
// disk in this phase, so stat them for the mtime fields + `age`.
|
|
2154
2203
|
try {
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2204
|
+
const prdStore = require('./prd-store');
|
|
2205
|
+
prdStore.reconcilePrdsFromDisk();
|
|
2206
|
+
for (const { filename, archived, plan } of prdStore.listPrdRows()) {
|
|
2157
2207
|
try {
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
plan = cached.plan;
|
|
2167
|
-
} else {
|
|
2168
|
-
plan = readJsonNoRestore(filePath);
|
|
2169
|
-
_prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
|
|
2170
|
-
}
|
|
2171
|
-
if (!plan || !plan.missing_features) continue;
|
|
2172
|
-
|
|
2173
|
-
// Staleness: gate on actual source-plan content change rather
|
|
2174
|
-
// than mtime alone (W-mqfevwr60018bd09). Pure path / pure mtime
|
|
2175
|
-
// drifts return stale=false here so a repointed source_plan
|
|
2176
|
-
// doesn't masquerade as a real revision in the cached PRD info.
|
|
2177
|
-
let planStale = false;
|
|
2178
|
-
if (!archived && plan.source_plan) {
|
|
2179
|
-
try {
|
|
2180
|
-
planStale = shared.isSourcePlanContentStale(PLANS_DIR, plan).stale;
|
|
2181
|
-
} catch { /* optional */ }
|
|
2182
|
-
}
|
|
2183
|
-
existingPrds.push({
|
|
2184
|
-
file: pf,
|
|
2185
|
-
status: plan.status || 'active',
|
|
2186
|
-
planStale: planStale || plan.planStale || false,
|
|
2187
|
-
completedAt: plan.completedAt || '',
|
|
2188
|
-
_archived: archived,
|
|
2189
|
-
});
|
|
2190
|
-
if (Array.isArray(plan.verifyPrs) && plan.verifyPrs.length > 0) {
|
|
2191
|
-
verifyPrsByPlan[pf] = plan.verifyPrs.map(r => ({
|
|
2192
|
-
id: r.id, url: r.url || '', title: r.title || '',
|
|
2193
|
-
status: r.status || 'active', project: r.project || '',
|
|
2194
|
-
...(r.mergedAt ? { mergedAt: r.mergedAt } : {}),
|
|
2195
|
-
}));
|
|
2196
|
-
}
|
|
2197
|
-
for (const f of plan.missing_features) {
|
|
2198
|
-
allPrdItems.push({
|
|
2199
|
-
...f, _source: pf, _planStatus: plan.status || 'active',
|
|
2200
|
-
_planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
|
|
2201
|
-
_archived: archived, _sourcePlan: plan.source_plan || '',
|
|
2202
|
-
_branchStrategy: plan.branch_strategy || 'parallel',
|
|
2203
|
-
_planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
|
|
2204
|
-
_prdUpdatedAt: new Date(stat.mtimeMs).toISOString(),
|
|
2205
|
-
_prdCompletedAt: plan.completedAt || '',
|
|
2206
|
-
});
|
|
2207
|
-
}
|
|
2208
|
+
const dir = archived ? path.join(PRD_DIR, 'archive') : PRD_DIR;
|
|
2209
|
+
let mtimeMs = 0;
|
|
2210
|
+
try {
|
|
2211
|
+
const stat = fs.statSync(path.join(dir, filename));
|
|
2212
|
+
mtimeMs = stat.mtimeMs;
|
|
2213
|
+
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
2214
|
+
} catch { /* file may have just moved/deleted between reconcile and read */ }
|
|
2215
|
+
processPrd(filename, archived, plan, mtimeMs);
|
|
2208
2216
|
} catch { /* optional */ }
|
|
2209
2217
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2218
|
+
} catch {
|
|
2219
|
+
// Hard SQL failure → fall back to the disk scan so the dashboard never
|
|
2220
|
+
// goes blank on a transient DB problem.
|
|
2221
|
+
useSqlReads = false;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
if (!useSqlReads) {
|
|
2226
|
+
for (const { dir, archived } of planDirs) {
|
|
2227
|
+
try {
|
|
2228
|
+
const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json')).sort();
|
|
2229
|
+
for (const pf of planFiles) {
|
|
2230
|
+
try {
|
|
2231
|
+
const filePath = path.join(dir, pf);
|
|
2232
|
+
const stat = fs.statSync(filePath);
|
|
2233
|
+
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
2234
|
+
|
|
2235
|
+
// Per-file mtime cache: only re-read files that changed
|
|
2236
|
+
const cached = _prdFileCache.get(filePath);
|
|
2237
|
+
let plan;
|
|
2238
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
2239
|
+
plan = cached.plan;
|
|
2240
|
+
} else {
|
|
2241
|
+
plan = readJsonNoRestore(filePath);
|
|
2242
|
+
_prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
|
|
2243
|
+
}
|
|
2244
|
+
processPrd(pf, archived, plan, stat.mtimeMs);
|
|
2245
|
+
} catch { /* optional */ }
|
|
2214
2246
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2247
|
+
// Clean stale entries from file cache when dirs changed
|
|
2248
|
+
if (dirsChanged) {
|
|
2249
|
+
for (const cachedPath of _prdFileCache.keys()) {
|
|
2250
|
+
if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
} catch { /* optional */ }
|
|
2254
|
+
}
|
|
2217
2255
|
}
|
|
2218
2256
|
|
|
2219
2257
|
if (allPrdItems.length === 0) return { progress: null, status: null };
|
|
@@ -433,6 +433,17 @@ function _mapEffort(level) {
|
|
|
433
433
|
// `fallbackModel` opts are silently ignored (their capability flags are false
|
|
434
434
|
// so engine code shouldn't pass them, but we tolerate them gracefully).
|
|
435
435
|
|
|
436
|
+
// Map MIME type to file extension for --attachment temp file naming.
|
|
437
|
+
const _MIME_EXT = {
|
|
438
|
+
'image/png': 'png',
|
|
439
|
+
'image/jpeg': 'jpg',
|
|
440
|
+
'image/gif': 'gif',
|
|
441
|
+
'image/webp': 'webp',
|
|
442
|
+
};
|
|
443
|
+
function _mimeToExt(mimeType) {
|
|
444
|
+
return _MIME_EXT[String(mimeType || '').toLowerCase()] || 'bin';
|
|
445
|
+
}
|
|
446
|
+
|
|
436
447
|
function buildArgs(opts = {}) {
|
|
437
448
|
const {
|
|
438
449
|
model,
|
|
@@ -445,6 +456,8 @@ function buildArgs(opts = {}) {
|
|
|
445
456
|
suppressAgentsMd,
|
|
446
457
|
reasoningSummaries,
|
|
447
458
|
disabledMcpServers,
|
|
459
|
+
images,
|
|
460
|
+
tmpDir,
|
|
448
461
|
} = opts;
|
|
449
462
|
|
|
450
463
|
const args = [
|
|
@@ -498,6 +511,22 @@ function buildArgs(opts = {}) {
|
|
|
498
511
|
// the `=`, commander.js treats the next token as a positional, not the value.
|
|
499
512
|
if (sessionId) args.push(`--resume=${sessionId}`);
|
|
500
513
|
|
|
514
|
+
// W-mqv7324u0021db5d — image attachments. Write each base64 payload to a
|
|
515
|
+
// temp file in tmpDir and emit --attachment <path> (flag is repeatable).
|
|
516
|
+
// tmpDir is required; skip silently when absent so callers without image
|
|
517
|
+
// support don't crash (defense-in-depth on top of the caps.imageInput gate).
|
|
518
|
+
if (Array.isArray(images) && images.length && tmpDir) {
|
|
519
|
+
for (let i = 0; i < images.length; i++) {
|
|
520
|
+
const img = images[i];
|
|
521
|
+
if (!img || !img.dataBase64) continue;
|
|
522
|
+
const ext = _mimeToExt(img.mimeType);
|
|
523
|
+
const filename = `img-${i}-${Date.now().toString(36)}.${ext}`;
|
|
524
|
+
const filePath = path.join(tmpDir, filename);
|
|
525
|
+
fs.writeFileSync(filePath, Buffer.from(img.dataBase64, 'base64'));
|
|
526
|
+
args.push('--attachment', filePath);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
501
530
|
return args;
|
|
502
531
|
}
|
|
503
532
|
|
|
@@ -1214,11 +1243,9 @@ const capabilities = {
|
|
|
1214
1243
|
resumePromptCarryover: true,
|
|
1215
1244
|
// Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
|
|
1216
1245
|
streamConsumer: true,
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
// of the base64 payload (a different adapter-local path than Claude's inline).
|
|
1221
|
-
imageInput: false,
|
|
1246
|
+
// `--attachment <path>` works in non-interactive mode (live-tested; see W-mqv7324u0021db5d).
|
|
1247
|
+
// Base64 payloads are materialized to tmp files inside llmTmpDir before spawn.
|
|
1248
|
+
imageInput: true,
|
|
1222
1249
|
};
|
|
1223
1250
|
|
|
1224
1251
|
// Install hint surfaced when `resolveBinary()` returns null. Covers all
|
package/engine/shared.js
CHANGED
|
@@ -1866,6 +1866,17 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
1866
1866
|
try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
|
|
1867
1867
|
}
|
|
1868
1868
|
safeWrite(filePath, finalData);
|
|
1869
|
+
// Phase 10 step 2 — dual-write PRD content to SQL. The single chokepoint:
|
|
1870
|
+
// every prd/*.json mutation through mutateJsonFileLocked mirrors into the
|
|
1871
|
+
// prds/prd_items/prd_verify_prs tables. JSON stays canonical (nothing
|
|
1872
|
+
// reads SQL yet); this keeps the mirror trustworthy for the read-flip.
|
|
1873
|
+
// BEST-EFFORT: parsePrdPath fast-rejects non-PRD paths, and a SQLite
|
|
1874
|
+
// failure must never break the JSON write. mirrorPrdToSql swallows
|
|
1875
|
+
// internally; the require is guarded for the (engine-agnostic) load order.
|
|
1876
|
+
try {
|
|
1877
|
+
const prdStore = require('./prd-store');
|
|
1878
|
+
if (prdStore.parsePrdPath(filePath)) prdStore.mirrorPrdToSql(filePath, finalData);
|
|
1879
|
+
} catch { /* mirror is best-effort — never break the canonical JSON write */ }
|
|
1869
1880
|
// Side-effect hook fired only when an actual write happened. Callers
|
|
1870
1881
|
// use this to emit cache-invalidation signals (events table row) so
|
|
1871
1882
|
// skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
|
|
@@ -2635,12 +2646,21 @@ const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
|
|
|
2635
2646
|
function resolveCheckoutMode(project, workItemType) {
|
|
2636
2647
|
if (!project || typeof project !== 'object') return CHECKOUT_MODES.WORKTREE;
|
|
2637
2648
|
const canonical = project.checkoutMode;
|
|
2638
|
-
//
|
|
2639
|
-
|
|
2649
|
+
// Resolve the effective mode: canonical field first, then legacy worktreeMode fallback.
|
|
2650
|
+
let effectiveMode;
|
|
2651
|
+
if (canonical === CHECKOUT_MODES.LIVE || canonical === CHECKOUT_MODES.WORKTREE) {
|
|
2652
|
+
effectiveMode = canonical;
|
|
2653
|
+
} else if (project.worktreeMode === 'live') {
|
|
2654
|
+
effectiveMode = CHECKOUT_MODES.LIVE;
|
|
2655
|
+
} else {
|
|
2656
|
+
effectiveMode = CHECKOUT_MODES.WORKTREE;
|
|
2657
|
+
}
|
|
2658
|
+
// liveValidation without an effective live mode is a misconfiguration — warn and ignore.
|
|
2659
|
+
if (project.liveValidation && effectiveMode !== CHECKOUT_MODES.LIVE) {
|
|
2640
2660
|
log('warn', 'resolveCheckoutMode: liveValidation is set but checkoutMode is not "live" — liveValidation ignored',
|
|
2641
2661
|
{ projectName: project.name });
|
|
2642
2662
|
}
|
|
2643
|
-
if (
|
|
2663
|
+
if (effectiveMode === CHECKOUT_MODES.LIVE) {
|
|
2644
2664
|
// Apply liveValidation routing when the block is present and workItemType is provided.
|
|
2645
2665
|
if (project.liveValidation && workItemType !== undefined) {
|
|
2646
2666
|
return workItemType === project.liveValidation.type
|
|
@@ -2649,11 +2669,6 @@ function resolveCheckoutMode(project, workItemType) {
|
|
|
2649
2669
|
}
|
|
2650
2670
|
return CHECKOUT_MODES.LIVE;
|
|
2651
2671
|
}
|
|
2652
|
-
if (canonical === CHECKOUT_MODES.WORKTREE) return CHECKOUT_MODES.WORKTREE;
|
|
2653
|
-
// Legacy field fallback (only consulted when checkoutMode is absent/unknown).
|
|
2654
|
-
const legacy = project.worktreeMode;
|
|
2655
|
-
if (legacy === 'live') return CHECKOUT_MODES.LIVE;
|
|
2656
|
-
// legacy 'isolated' (and anything else) → the default worktree behavior.
|
|
2657
2672
|
return CHECKOUT_MODES.WORKTREE;
|
|
2658
2673
|
}
|
|
2659
2674
|
|
|
@@ -4387,6 +4402,7 @@ const FAILURE_CLASS = {
|
|
|
4387
4402
|
SPAWN_PHASE_STALL: 'spawn-phase-stall', // W-mq0e2dae000a003d: process spawned and ran startup-only events (MCP init / hooks) but never emitted real task progress; CPU usage stayed below threshold past the grace window. Engine kills the wedged child and treats this as retryable (fresh-session) so a re-spawn can clear a transient MCP wedge.
|
|
4388
4403
|
OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
|
|
4389
4404
|
VERIFY_MISSING_PR: 'verify-missing-pr', // W-mqsk1ip00006cbae: a verify WI exited done but no PR was attached (neither _prUrl/_pr on the item nor a matching pull-requests.json entry for the plan). Flipped to failed so the plan doesn't silently advance without an E2E PR. Retryable — agent may have phantom-crashed before pushing the branch.
|
|
4405
|
+
PROJECT_NOT_FOUND: 'project_not_found', // W-mqv2wsy30002e090: a work item's `project` field names a project that is not configured (resolveConfiguredProject / formatUnknownProjectError → `Project "<name>" not found. Known projects: …`). Stamped at discovery time so the dashboard PR-column renderer surfaces the red failReason snippet like other non-retryable failures instead of burying it in the Agent column. Non-retryable — operator must fix the WI's project field or configure the project.
|
|
4390
4406
|
UNKNOWN: 'unknown', // Unclassified failure
|
|
4391
4407
|
};
|
|
4392
4408
|
|
package/engine/watch-actions.js
CHANGED
|
@@ -562,7 +562,10 @@ registerActionType(WATCH_ACTION_TYPE.WEBHOOK, {
|
|
|
562
562
|
// minions-api — first-class loopback API caller for the in-process dashboard.
|
|
563
563
|
// Restricts `endpoint` to paths starting with `/api/` so a watch can't reach
|
|
564
564
|
// external hosts (use `webhook` for that). Always targets
|
|
565
|
-
// `http://127.0.0.1:${
|
|
565
|
+
// `http://127.0.0.1:${shared.readDashboardPortFile(MINIONS_DIR)?.port||7331}${endpoint}`
|
|
566
|
+
// — the *actual* bound dashboard port from the dashboard-port.json beacon, not
|
|
567
|
+
// the frozen MINIONS_PORT env (which stays at the requested value when the
|
|
568
|
+
// dashboard falls back to 7332+ on EADDRINUSE) — and sets
|
|
566
569
|
// `X-Minions-Internal: 1` so dashboard handlers can recognize that the
|
|
567
570
|
// request originated from the engine's own action surface (Origin/Referer
|
|
568
571
|
// are intentionally absent — the dashboard's origin gate already allows
|