@yemi33/minions 0.1.476 → 0.1.478
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/CHANGELOG.md +3 -1
- package/dashboard.js +71 -7
- package/engine/queries.js +72 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.478 (2026-04-07)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- Cache getStatus() JSON serialization and add mtime-based invalidation
|
|
7
|
+
- Add mtime-based caching to getPrdInfo()
|
|
6
8
|
- Optimize getAgentStatus() to read only head+tail of live-output.log
|
|
7
9
|
- Fix unlocked metrics.json in lifecycle.js post-merge hook
|
|
8
10
|
- Fix unlocked metrics.json read-modify-write in trackEngineUsage
|
package/dashboard.js
CHANGED
|
@@ -264,20 +264,59 @@ function parsePinnedEntries(content) {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
let _statusCache = null;
|
|
267
|
+
let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
|
|
267
268
|
let _statusCacheTs = 0;
|
|
268
269
|
const STATUS_CACHE_TTL = 10000; // 10s — reduces expensive aggregation frequency; mutations call invalidateStatusCache()
|
|
269
270
|
const _statusStreamClients = new Set();
|
|
270
271
|
let _statusPushTimer = null;
|
|
271
272
|
let _lastStatusHash = '';
|
|
272
273
|
|
|
274
|
+
// mtime-based cache invalidation — skip full rebuild if no tracked files changed
|
|
275
|
+
const _mtimeTrackedFiles = () => {
|
|
276
|
+
const files = [
|
|
277
|
+
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
278
|
+
path.join(ENGINE_DIR, 'control.json'),
|
|
279
|
+
path.join(ENGINE_DIR, 'log.json'),
|
|
280
|
+
path.join(ENGINE_DIR, 'metrics.json'),
|
|
281
|
+
];
|
|
282
|
+
// Add per-project work-items.json
|
|
283
|
+
for (const p of PROJECTS) {
|
|
284
|
+
if (p.localPath) files.push(path.join(p.localPath, '.minions', 'work-items.json'));
|
|
285
|
+
}
|
|
286
|
+
// Central work-items.json
|
|
287
|
+
files.push(path.join(MINIONS_DIR, 'work-items.json'));
|
|
288
|
+
return files;
|
|
289
|
+
};
|
|
290
|
+
let _lastMtimes = {}; // { filePath: mtimeMs }
|
|
291
|
+
|
|
292
|
+
function _getMtimes() {
|
|
293
|
+
const result = {};
|
|
294
|
+
for (const fp of _mtimeTrackedFiles()) {
|
|
295
|
+
try { result[fp] = fs.statSync(fp).mtimeMs; } catch { result[fp] = 0; }
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _mtimesChanged(prev, curr) {
|
|
301
|
+
for (const fp of Object.keys(curr)) {
|
|
302
|
+
if (prev[fp] !== curr[fp]) return true;
|
|
303
|
+
}
|
|
304
|
+
// Also check if keys differ (new files appeared)
|
|
305
|
+
for (const fp of Object.keys(prev)) {
|
|
306
|
+
if (!(fp in curr)) return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
273
311
|
function invalidateStatusCache() {
|
|
274
312
|
_statusCache = null;
|
|
313
|
+
_statusCacheJson = null;
|
|
275
314
|
// Push to SSE clients (debounced 500ms to avoid flooding during batch mutations)
|
|
276
315
|
if (_statusPushTimer) return;
|
|
277
316
|
_statusPushTimer = setTimeout(() => {
|
|
278
317
|
_statusPushTimer = null;
|
|
279
318
|
if (_statusStreamClients.size === 0) return;
|
|
280
|
-
const data =
|
|
319
|
+
const data = getStatusJson();
|
|
281
320
|
for (const res of _statusStreamClients) {
|
|
282
321
|
try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
|
|
283
322
|
}
|
|
@@ -286,7 +325,11 @@ function invalidateStatusCache() {
|
|
|
286
325
|
|
|
287
326
|
function getStatus() {
|
|
288
327
|
const now = Date.now();
|
|
289
|
-
if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL)
|
|
328
|
+
if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL) {
|
|
329
|
+
// Within TTL — check mtimes for early return (skip full rebuild if nothing changed)
|
|
330
|
+
const currMtimes = _getMtimes();
|
|
331
|
+
if (!_mtimesChanged(_lastMtimes, currMtimes)) return _statusCache;
|
|
332
|
+
}
|
|
290
333
|
|
|
291
334
|
// Reload config on each cache miss — picks up external changes (minions init, minions add)
|
|
292
335
|
reloadConfig();
|
|
@@ -352,17 +395,27 @@ function getStatus() {
|
|
|
352
395
|
timestamp: new Date().toISOString(),
|
|
353
396
|
};
|
|
354
397
|
_statusCacheTs = now;
|
|
398
|
+
_statusCacheJson = null; // invalidate cached JSON — will be lazily rebuilt by getStatusJson()
|
|
399
|
+
_lastMtimes = _getMtimes();
|
|
355
400
|
return _statusCache;
|
|
356
401
|
}
|
|
357
402
|
|
|
403
|
+
/** Return cached JSON string of status — single stringify, reused by SSE and /api/status */
|
|
404
|
+
function getStatusJson() {
|
|
405
|
+
getStatus(); // ensure _statusCache is fresh
|
|
406
|
+
if (!_statusCacheJson) {
|
|
407
|
+
_statusCacheJson = JSON.stringify(_statusCache);
|
|
408
|
+
}
|
|
409
|
+
return _statusCacheJson;
|
|
410
|
+
}
|
|
411
|
+
|
|
358
412
|
// Periodic push for engine-driven changes (dispatch.json, control.json) that bypass invalidateStatusCache
|
|
359
413
|
setInterval(() => {
|
|
360
414
|
if (_statusStreamClients.size === 0) return;
|
|
361
|
-
const
|
|
362
|
-
const hash = require('crypto').createHash('md5').update(
|
|
415
|
+
const data = getStatusJson();
|
|
416
|
+
const hash = require('crypto').createHash('md5').update(data).digest('hex');
|
|
363
417
|
if (hash === _lastStatusHash) return;
|
|
364
418
|
_lastStatusHash = hash;
|
|
365
|
-
const data = JSON.stringify(status);
|
|
366
419
|
for (const res of _statusStreamClients) {
|
|
367
420
|
try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
|
|
368
421
|
}
|
|
@@ -3500,7 +3553,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3500
3553
|
|
|
3501
3554
|
async function handleStatus(req, res) {
|
|
3502
3555
|
try {
|
|
3503
|
-
|
|
3556
|
+
// Use pre-serialized JSON to avoid double-stringify in jsonReply
|
|
3557
|
+
const json = getStatusJson();
|
|
3558
|
+
res.setHeader('Content-Type', 'application/json');
|
|
3559
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
3560
|
+
res.statusCode = 200;
|
|
3561
|
+
const ae = req && req.headers && req.headers['accept-encoding'] || '';
|
|
3562
|
+
if (ae.includes('gzip') && json.length > 1024) {
|
|
3563
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
3564
|
+
res.end(zlib.gzipSync(json));
|
|
3565
|
+
} else {
|
|
3566
|
+
res.end(json);
|
|
3567
|
+
}
|
|
3504
3568
|
} catch (e) {
|
|
3505
3569
|
return jsonReply(res, 500, { error: e.message }, req);
|
|
3506
3570
|
}
|
|
@@ -3548,7 +3612,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3548
3612
|
{ method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
|
|
3549
3613
|
{ method: 'GET', path: '/api/status-stream', desc: 'SSE stream of real-time status updates', handler: (req, res) => {
|
|
3550
3614
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
3551
|
-
res.write('data: ' +
|
|
3615
|
+
res.write('data: ' + getStatusJson() + '\n\n');
|
|
3552
3616
|
_statusStreamClients.add(res);
|
|
3553
3617
|
req.on('close', () => _statusStreamClients.delete(res));
|
|
3554
3618
|
}},
|
package/engine/queries.js
CHANGED
|
@@ -627,12 +627,51 @@ function getWorkItems(config) {
|
|
|
627
627
|
|
|
628
628
|
// ── PRD Progress ────────────────────────────────────────────────────────────
|
|
629
629
|
|
|
630
|
+
// Module-level caches for getPrdInfo() — avoids re-reading unchanged PRD files
|
|
631
|
+
const _prdFileCache = new Map(); // filePath → { mtimeMs, plan }
|
|
632
|
+
let _prdDirMtimes = { prd: 0, archive: 0 }; // directory mtimes to detect new/deleted files
|
|
633
|
+
let _prdResultCache = null; // cached final result
|
|
634
|
+
let _prdResultInputHash = ''; // hash of all input mtimes to detect any change
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Collect mtimes of all input files that affect getPrdInfo() output.
|
|
638
|
+
* Returns a string hash for quick equality check plus the dir mtimes.
|
|
639
|
+
*/
|
|
640
|
+
function _getPrdInputHash(projects) {
|
|
641
|
+
const mtimes = [];
|
|
642
|
+
// PRD directory mtimes (detect new/deleted files)
|
|
643
|
+
let prdDirMtime = 0, archiveDirMtime = 0;
|
|
644
|
+
try { prdDirMtime = fs.statSync(PRD_DIR).mtimeMs; } catch { /* optional */ }
|
|
645
|
+
const archiveDir = path.join(PRD_DIR, 'archive');
|
|
646
|
+
try { archiveDirMtime = fs.statSync(archiveDir).mtimeMs; } catch { /* optional */ }
|
|
647
|
+
mtimes.push(prdDirMtime, archiveDirMtime);
|
|
648
|
+
// Work-items file mtimes (affect status display)
|
|
649
|
+
for (const project of projects) {
|
|
650
|
+
try { mtimes.push(fs.statSync(projectWorkItemsPath(project)).mtimeMs); } catch { mtimes.push(0); }
|
|
651
|
+
}
|
|
652
|
+
try { mtimes.push(fs.statSync(path.join(MINIONS_DIR, 'work-items.json')).mtimeMs); } catch { mtimes.push(0); }
|
|
653
|
+
// PR file mtimes (affect PR links)
|
|
654
|
+
for (const project of projects) {
|
|
655
|
+
try { mtimes.push(fs.statSync(projectPrPath(project)).mtimeMs); } catch { mtimes.push(0); }
|
|
656
|
+
}
|
|
657
|
+
return { hash: mtimes.join(','), prdDirMtime, archiveDirMtime };
|
|
658
|
+
}
|
|
659
|
+
|
|
630
660
|
function getPrdInfo(config) {
|
|
631
661
|
config = config || getConfig();
|
|
632
662
|
const projects = getProjects(config);
|
|
663
|
+
|
|
664
|
+
// Quick mtime check — return cached result if nothing changed
|
|
665
|
+
const { hash, prdDirMtime, archiveDirMtime } = _getPrdInputHash(projects);
|
|
666
|
+
if (_prdResultCache && hash === _prdResultInputHash) return _prdResultCache;
|
|
667
|
+
|
|
633
668
|
let allPrdItems = [];
|
|
634
669
|
let latestStat = null;
|
|
635
670
|
|
|
671
|
+
// Check if directory listings need refresh
|
|
672
|
+
const dirsChanged = prdDirMtime !== _prdDirMtimes.prd || archiveDirMtime !== _prdDirMtimes.archive;
|
|
673
|
+
_prdDirMtimes = { prd: prdDirMtime, archive: archiveDirMtime };
|
|
674
|
+
|
|
636
675
|
// Scan active PRDs and archived PRDs (completed PRDs still need to show progress)
|
|
637
676
|
const planDirs = [
|
|
638
677
|
{ dir: PRD_DIR, archived: false },
|
|
@@ -643,10 +682,21 @@ function getPrdInfo(config) {
|
|
|
643
682
|
const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
644
683
|
for (const pf of planFiles) {
|
|
645
684
|
try {
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
const stat = fs.statSync(path.join(dir, pf));
|
|
685
|
+
const filePath = path.join(dir, pf);
|
|
686
|
+
const stat = fs.statSync(filePath);
|
|
649
687
|
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
688
|
+
|
|
689
|
+
// Per-file mtime cache: only re-read files that changed
|
|
690
|
+
const cached = _prdFileCache.get(filePath);
|
|
691
|
+
let plan;
|
|
692
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
693
|
+
plan = cached.plan;
|
|
694
|
+
} else {
|
|
695
|
+
plan = safeJson(filePath);
|
|
696
|
+
_prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
|
|
697
|
+
}
|
|
698
|
+
if (!plan || !plan.missing_features) continue;
|
|
699
|
+
|
|
650
700
|
// Staleness: compare source plan mtime to recorded sourcePlanModifiedAt
|
|
651
701
|
let planStale = false;
|
|
652
702
|
if (!archived && plan.source_plan) {
|
|
@@ -668,6 +718,12 @@ function getPrdInfo(config) {
|
|
|
668
718
|
}
|
|
669
719
|
} catch { /* optional */ }
|
|
670
720
|
}
|
|
721
|
+
// Clean stale entries from file cache when dirs changed
|
|
722
|
+
if (dirsChanged) {
|
|
723
|
+
for (const cachedPath of _prdFileCache.keys()) {
|
|
724
|
+
if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
671
727
|
} catch { /* optional */ }
|
|
672
728
|
}
|
|
673
729
|
|
|
@@ -766,7 +822,18 @@ function getPrdInfo(config) {
|
|
|
766
822
|
missingList: items.filter(i => i.status === 'missing').map(f => ({ id: f.id, name: f.name || f.title, priority: f.priority, complexity: f.estimated_complexity || f.size })),
|
|
767
823
|
};
|
|
768
824
|
|
|
769
|
-
|
|
825
|
+
const result = { progress, status };
|
|
826
|
+
_prdResultCache = result;
|
|
827
|
+
_prdResultInputHash = hash;
|
|
828
|
+
return result;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Reset PRD info cache — exported for testing */
|
|
832
|
+
function resetPrdInfoCache() {
|
|
833
|
+
_prdFileCache.clear();
|
|
834
|
+
_prdDirMtimes = { prd: 0, archive: 0 };
|
|
835
|
+
_prdResultCache = null;
|
|
836
|
+
_prdResultInputHash = '';
|
|
770
837
|
}
|
|
771
838
|
|
|
772
839
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
@@ -779,6 +846,7 @@ module.exports = {
|
|
|
779
846
|
// Helpers
|
|
780
847
|
timeSince,
|
|
781
848
|
readHeadTail, // exported for testing
|
|
849
|
+
resetPrdInfoCache,
|
|
782
850
|
|
|
783
851
|
// Core state
|
|
784
852
|
getConfig, getControl, getDispatch, getDispatchQueue,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.478",
|
|
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"
|