@yemi33/minions 0.1.2036 → 0.1.2038
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/refresh.js +274 -14
- package/dashboard/js/render-work-items.js +122 -15
- package/dashboard/js/settings.js +2 -0
- package/dashboard/js/state.js +7 -4
- package/dashboard.js +229 -3
- package/engine/features.js +14 -0
- package/engine/queries.js +68 -14
- package/engine/shared.js +9 -0
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -84,6 +84,12 @@ const RENDER_VERSIONS = {
|
|
|
84
84
|
const _sectionCache = {};
|
|
85
85
|
const _lastValueByKey = {};
|
|
86
86
|
const _sectionCacheVersions = {};
|
|
87
|
+
// Refresh diagnostics: when refresh() opens a tick, it sets _lastChangedFlags
|
|
88
|
+
// to a fresh object so every _changed() call publishes its boolean result
|
|
89
|
+
// here. After _processStatusUpdate() returns, refresh() captures the map
|
|
90
|
+
// into the ring-buffer entry then resets _lastChangedFlags to null so steady-
|
|
91
|
+
// state has no side effect. See "Refresh diagnostics" block below.
|
|
92
|
+
let _lastChangedFlags = null;
|
|
87
93
|
function _changed(key, value, version) {
|
|
88
94
|
var v = version == null ? (RENDER_VERSIONS[key] || 0) : version;
|
|
89
95
|
// Drop the stale-version entry so the cache doesn't grow unbounded across bumps.
|
|
@@ -98,11 +104,18 @@ function _changed(key, value, version) {
|
|
|
98
104
|
var cacheKey = key + ':v' + v;
|
|
99
105
|
// Reference-equality short-circuit: skip the stringify entirely when the
|
|
100
106
|
// server returned 304 and the same object reference is being re-checked.
|
|
101
|
-
if (_lastValueByKey[key] === value)
|
|
107
|
+
if (_lastValueByKey[key] === value) {
|
|
108
|
+
if (_lastChangedFlags) _lastChangedFlags[key] = false;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
102
111
|
_lastValueByKey[key] = value;
|
|
103
112
|
var json = JSON.stringify(value);
|
|
104
|
-
if (_sectionCache[cacheKey] === json)
|
|
113
|
+
if (_sectionCache[cacheKey] === json) {
|
|
114
|
+
if (_lastChangedFlags) _lastChangedFlags[key] = false;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
105
117
|
_sectionCache[cacheKey] = json;
|
|
118
|
+
if (_lastChangedFlags) _lastChangedFlags[key] = true;
|
|
106
119
|
return true;
|
|
107
120
|
}
|
|
108
121
|
|
|
@@ -122,7 +135,7 @@ function _formatCcDrawerLabel(autoMode) {
|
|
|
122
135
|
|
|
123
136
|
function _processStatusUpdate(data) {
|
|
124
137
|
// Detect fresh install — clear stale browser state AND reload so module-scoped
|
|
125
|
-
// JS caches (_sectionCache, _prevCounts,
|
|
138
|
+
// JS caches (_sectionCache, _prevCounts, _managedProcessesLastItems,
|
|
126
139
|
// _pipelinePollHash, _meetingPollHash, etc.) reinitialise against the new install's
|
|
127
140
|
// data shape. localStorage.clear() alone leaves those caches stale after a
|
|
128
141
|
// MINIONS_HOME swap that doesn't restart the dashboard (F11 / W-mpgcijjo000ce878).
|
|
@@ -252,16 +265,25 @@ function _processStatusUpdate(data) {
|
|
|
252
265
|
if (swi) swi.textContent = (data.workItems || []).length || '';
|
|
253
266
|
const spr = document.getElementById('sidebar-pr');
|
|
254
267
|
if (spr) spr.textContent = (data.pullRequests || []).length || '';
|
|
255
|
-
// Refresh KB and plans
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
// Refresh KB and plans every status cycle (~4s) so plan status flips
|
|
269
|
+
// (approve/archive/complete) and KB additions surface within the SPA's
|
|
270
|
+
// 4s poll target. Server-side caches keep this cheap:
|
|
271
|
+
// - /api/plans: 5s in-memory TTL + invalidation on every mutating endpoint
|
|
272
|
+
// (dashboard.js _plansCache / invalidatePlansCache) — back-to-back polls
|
|
273
|
+
// hit the cache; external file edits surface within 5s.
|
|
274
|
+
// - /api/knowledge: 30s in-memory TTL + invalidation on POST /api/knowledge
|
|
275
|
+
// and after every kb-sweep (engine/queries.js _kbCache / kb-sweep.js).
|
|
276
|
+
// Previously throttled to every 3rd cycle (~12s) — see W-mphfb6ss000a3b9e
|
|
277
|
+
// for the cadence audit + Playwright coverage.
|
|
278
|
+
refreshKnowledgeBase();
|
|
279
|
+
refreshPlans();
|
|
258
280
|
|
|
259
281
|
// Cross-slice render triggers (F1/F3, W-mpgb0xbh000e3b86): renderPrs reads
|
|
260
282
|
// window._lastWorkItems for the +N follow-up chip count and derivePlanStatus
|
|
261
283
|
// reads window._lastStatus.pullRequests for verify-follow-up reconciliation.
|
|
262
284
|
// When workItems OR pullRequests change, re-render the dependents against the
|
|
263
|
-
// freshest cached data — without waiting
|
|
264
|
-
//
|
|
285
|
+
// freshest cached data — without waiting for the next refreshPlans cycle (F3)
|
|
286
|
+
// or for a PR object to mutate (F1). Runs after the window._last*
|
|
265
287
|
// assignments above so the cached globals these renderers consult are fresh.
|
|
266
288
|
if (_workItemsChanged && !_prsChanged) {
|
|
267
289
|
// F1: only the work-item slice moved this tick — renderPrs wasn't called
|
|
@@ -271,9 +293,9 @@ function _processStatusUpdate(data) {
|
|
|
271
293
|
}
|
|
272
294
|
if ((_workItemsChanged || _prsChanged) && Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
|
|
273
295
|
// F3: derivePlanStatus + _renderVerifyBadge derive from pullRequests +
|
|
274
|
-
// workItems. Re-render against
|
|
275
|
-
//
|
|
276
|
-
//
|
|
296
|
+
// workItems. Re-render against cached plans so plan status flips within
|
|
297
|
+
// one /api/status tick (~4s) instead of one refreshPlans poll. No-op
|
|
298
|
+
// until _lastPlans is populated.
|
|
277
299
|
renderPlans(window._lastPlans);
|
|
278
300
|
}
|
|
279
301
|
|
|
@@ -311,9 +333,78 @@ let _lastStatusData = null;
|
|
|
311
333
|
// when the first hasn't finished — the next interval fire picks up the
|
|
312
334
|
// fresh state anyway.
|
|
313
335
|
let _refreshInFlight = false;
|
|
336
|
+
|
|
337
|
+
// ── Refresh diagnostics (W-mphejzx100081972) ─────────────────────────────
|
|
338
|
+
// Ring buffer capturing the last 50 /api/status poll cycles so a user
|
|
339
|
+
// reporting "the dashboard didn't auto-update when X changed" can paste
|
|
340
|
+
// `window._refreshDiagnostics()` from devtools or click the "diag" footer
|
|
341
|
+
// chip to surface a table. Gated behind feature flag
|
|
342
|
+
// `dashboard-refresh-diagnostics` (engine/features.js) — disabled in
|
|
343
|
+
// production, enabled in dev. When disabled, the helpers below are no-ops
|
|
344
|
+
// (the boolean check is the first line of every collection path) so the
|
|
345
|
+
// steady-state poll loop is byte-identical to its pre-flag behaviour.
|
|
346
|
+
const _DIAG_RING_SIZE = 50;
|
|
347
|
+
const _refreshDiagBuf = [];
|
|
348
|
+
let _prevRefreshTs = 0;
|
|
349
|
+
let _diagHiddenSinceMs = 0; // ts when tab last went hidden; 0 when visible
|
|
350
|
+
let _lastVisibilityChangeAt = 0;
|
|
351
|
+
function _isRefreshDiagOn() {
|
|
352
|
+
try {
|
|
353
|
+
return !!(window.MinionsFeatures && window.MinionsFeatures.isOn('dashboard-refresh-diagnostics'));
|
|
354
|
+
} catch { return false; }
|
|
355
|
+
}
|
|
356
|
+
function _pushDiagEntry(entry) {
|
|
357
|
+
_refreshDiagBuf.push(entry);
|
|
358
|
+
if (_refreshDiagBuf.length > _DIAG_RING_SIZE) _refreshDiagBuf.shift();
|
|
359
|
+
}
|
|
360
|
+
// Expose a copy of the ring buffer so devtools paste-back captures a
|
|
361
|
+
// stable snapshot — not a live reference the user could accidentally mutate.
|
|
362
|
+
window._refreshDiagnostics = function() { return _refreshDiagBuf.slice(); };
|
|
363
|
+
window._refreshDiagnosticsClear = function() { _refreshDiagBuf.length = 0; };
|
|
364
|
+
document.addEventListener('visibilitychange', function() {
|
|
365
|
+
if (!_isRefreshDiagOn()) return;
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
const vs = document.visibilityState;
|
|
368
|
+
let hiddenForMs = null;
|
|
369
|
+
if (vs === 'visible' && _diagHiddenSinceMs > 0) {
|
|
370
|
+
hiddenForMs = now - _diagHiddenSinceMs;
|
|
371
|
+
_diagHiddenSinceMs = 0;
|
|
372
|
+
} else if (vs === 'hidden') {
|
|
373
|
+
_diagHiddenSinceMs = now;
|
|
374
|
+
}
|
|
375
|
+
_pushDiagEntry({
|
|
376
|
+
ts: now,
|
|
377
|
+
kind: 'visibility',
|
|
378
|
+
visibility: vs,
|
|
379
|
+
hidden_for_ms: hiddenForMs,
|
|
380
|
+
gap_since_prev_ms: _lastVisibilityChangeAt ? now - _lastVisibilityChangeAt : null,
|
|
381
|
+
});
|
|
382
|
+
_lastVisibilityChangeAt = now;
|
|
383
|
+
});
|
|
384
|
+
|
|
314
385
|
async function refresh() {
|
|
315
386
|
if (_refreshInFlight) return;
|
|
316
387
|
_refreshInFlight = true;
|
|
388
|
+
const _diagOn = _isRefreshDiagOn();
|
|
389
|
+
const _t0 = _diagOn ? Date.now() : 0;
|
|
390
|
+
const _gap = _diagOn && _prevRefreshTs ? _t0 - _prevRefreshTs : null;
|
|
391
|
+
if (_diagOn) _prevRefreshTs = _t0;
|
|
392
|
+
const _diagEntry = _diagOn ? {
|
|
393
|
+
ts: _t0,
|
|
394
|
+
kind: 'refresh',
|
|
395
|
+
gap_since_prev_ms: _gap,
|
|
396
|
+
visibility: (typeof document !== 'undefined' && document.visibilityState) || null,
|
|
397
|
+
response_status: null,
|
|
398
|
+
etag_sent: _lastStatusEtag,
|
|
399
|
+
etag_received: null,
|
|
400
|
+
bytes_received: null,
|
|
401
|
+
statusCacheVersion: null,
|
|
402
|
+
workItems_changed: null,
|
|
403
|
+
workItems_count: null,
|
|
404
|
+
changed: null,
|
|
405
|
+
render_duration_ms: null,
|
|
406
|
+
error_message: null,
|
|
407
|
+
} : null;
|
|
317
408
|
try {
|
|
318
409
|
const headers = {};
|
|
319
410
|
if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
|
|
@@ -322,6 +413,10 @@ async function refresh() {
|
|
|
322
413
|
if (res.status === 304 && _lastStatusData) {
|
|
323
414
|
// Cache hit — reuse last payload, skip parsing entirely.
|
|
324
415
|
data = _lastStatusData;
|
|
416
|
+
if (_diagEntry) {
|
|
417
|
+
_diagEntry.response_status = '304';
|
|
418
|
+
_diagEntry.bytes_received = 0;
|
|
419
|
+
}
|
|
325
420
|
} else {
|
|
326
421
|
data = await res.json();
|
|
327
422
|
const etag = res.headers && (res.headers.get ? res.headers.get('etag') : null);
|
|
@@ -329,6 +424,12 @@ async function refresh() {
|
|
|
329
424
|
_lastStatusEtag = etag;
|
|
330
425
|
_lastStatusData = data;
|
|
331
426
|
}
|
|
427
|
+
if (_diagEntry) {
|
|
428
|
+
_diagEntry.response_status = String(res.status);
|
|
429
|
+
_diagEntry.etag_received = etag || null;
|
|
430
|
+
const cl = res.headers && (res.headers.get ? res.headers.get('content-length') : null);
|
|
431
|
+
_diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
|
|
432
|
+
}
|
|
332
433
|
}
|
|
333
434
|
// Auto-reload when dashboard restarts (stale connections cause "Failed to fetch" on CC/doc-chat)
|
|
334
435
|
const dashId = (data.version && data.version.dashboardStartedAt) || null;
|
|
@@ -348,9 +449,37 @@ async function refresh() {
|
|
|
348
449
|
return;
|
|
349
450
|
}
|
|
350
451
|
if (buildId) _knownDashboardBuildId = buildId;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
452
|
+
const _renderStart = _diagOn ? Date.now() : 0;
|
|
453
|
+
let _diagChanges = null;
|
|
454
|
+
if (_diagOn) {
|
|
455
|
+
_diagChanges = {};
|
|
456
|
+
_lastChangedFlags = _diagChanges;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
_processStatusUpdate(data);
|
|
460
|
+
} finally {
|
|
461
|
+
if (_diagOn) {
|
|
462
|
+
_lastChangedFlags = null;
|
|
463
|
+
_diagEntry.render_duration_ms = Date.now() - _renderStart;
|
|
464
|
+
_diagEntry.changed = _diagChanges;
|
|
465
|
+
_diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
|
|
466
|
+
_diagEntry.workItems_count = (data.workItems || []).length;
|
|
467
|
+
_diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
|
|
468
|
+
? data.version.statusCacheVersion
|
|
469
|
+
: null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch(e) {
|
|
473
|
+
console.error('refresh error', e);
|
|
474
|
+
if (_diagEntry && !_diagEntry.response_status) {
|
|
475
|
+
_diagEntry.response_status = (e && e.name === 'AbortError') ? 'abort' : 'error';
|
|
476
|
+
_diagEntry.error_message = String((e && e.message) || e);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
_refreshInFlight = false;
|
|
481
|
+
if (_diagEntry) _pushDiagEntry(_diagEntry);
|
|
482
|
+
}
|
|
354
483
|
}
|
|
355
484
|
|
|
356
485
|
refresh();
|
|
@@ -366,3 +495,134 @@ document.querySelectorAll('.sidebar-link').forEach(link => {
|
|
|
366
495
|
switchPage(currentPage);
|
|
367
496
|
|
|
368
497
|
window.MinionsRefresh = { refresh };
|
|
498
|
+
|
|
499
|
+
// ── Refresh-diagnostic footer chip + modal (W-mphejzx100081972) ───────────
|
|
500
|
+
// Injected only when the dashboard-refresh-diagnostics feature flag is on
|
|
501
|
+
// (engine/features.js → MINIONS_FEATURES.flags). The chip floats bottom-right
|
|
502
|
+
// and opens a simple table view of the ring buffer plus a "send to engine"
|
|
503
|
+
// button that POSTs the snapshot to /api/diagnostics/refresh. No DOM is
|
|
504
|
+
// touched (and no listeners installed beyond the visibilitychange handler
|
|
505
|
+
// above, which itself short-circuits when the flag is off) when the flag is
|
|
506
|
+
// disabled — production behaviour stays untouched.
|
|
507
|
+
(function installRefreshDiagChip() {
|
|
508
|
+
if (!_isRefreshDiagOn()) return;
|
|
509
|
+
try {
|
|
510
|
+
const chip = document.createElement('button');
|
|
511
|
+
chip.id = 'refresh-diag-chip';
|
|
512
|
+
chip.type = 'button';
|
|
513
|
+
chip.textContent = 'diag';
|
|
514
|
+
chip.title = 'Open dashboard refresh diagnostics';
|
|
515
|
+
chip.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9999;background:var(--surface,#222);color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:10px;padding:3px 8px;font-size:10px;font-family:inherit;cursor:pointer;opacity:0.75';
|
|
516
|
+
chip.addEventListener('click', _openRefreshDiagModal);
|
|
517
|
+
document.body.appendChild(chip);
|
|
518
|
+
} catch { /* DOM may not be ready in unusual test embeds */ }
|
|
519
|
+
})();
|
|
520
|
+
|
|
521
|
+
function _openRefreshDiagModal() {
|
|
522
|
+
// Remove any prior instance so re-clicks re-render against the latest buffer.
|
|
523
|
+
const prior = document.getElementById('refresh-diag-modal');
|
|
524
|
+
if (prior) prior.remove();
|
|
525
|
+
const entries = window._refreshDiagnostics();
|
|
526
|
+
const overlay = document.createElement('div');
|
|
527
|
+
overlay.id = 'refresh-diag-modal';
|
|
528
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px';
|
|
529
|
+
overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); });
|
|
530
|
+
const panel = document.createElement('div');
|
|
531
|
+
panel.style.cssText = 'background:var(--surface,#1c1c1c);color:var(--text,#ddd);border:1px solid var(--border,#444);border-radius:8px;max-width:1200px;max-height:80vh;width:100%;display:flex;flex-direction:column;overflow:hidden;font-family:inherit;font-size:11px';
|
|
532
|
+
const head = document.createElement('div');
|
|
533
|
+
head.style.cssText = 'padding:10px 14px;border-bottom:1px solid var(--border,#444);display:flex;align-items:center;justify-content:space-between;gap:8px';
|
|
534
|
+
const title = document.createElement('div');
|
|
535
|
+
title.style.cssText = 'font-weight:700;color:var(--blue,#58a6ff);font-size:13px';
|
|
536
|
+
title.textContent = 'Refresh diagnostics — ' + entries.length + ' / ' + _DIAG_RING_SIZE + ' entries';
|
|
537
|
+
const actions = document.createElement('div');
|
|
538
|
+
actions.style.cssText = 'display:flex;gap:6px';
|
|
539
|
+
const sendBtn = document.createElement('button');
|
|
540
|
+
sendBtn.type = 'button';
|
|
541
|
+
sendBtn.textContent = 'Send to engine';
|
|
542
|
+
sendBtn.style.cssText = 'background:var(--blue,#1f6feb);color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer';
|
|
543
|
+
sendBtn.addEventListener('click', function() {
|
|
544
|
+
sendBtn.disabled = true;
|
|
545
|
+
sendBtn.textContent = 'Sending…';
|
|
546
|
+
safeFetch('/api/diagnostics/refresh', {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
headers: { 'Content-Type': 'application/json' },
|
|
549
|
+
body: JSON.stringify({ entries: window._refreshDiagnostics() }),
|
|
550
|
+
})
|
|
551
|
+
.then(function(r) { return r.json().catch(function() { return {}; }).then(function(j) { return { ok: r.ok, body: j }; }); })
|
|
552
|
+
.then(function(r) {
|
|
553
|
+
sendBtn.textContent = r.ok ? ('Sent (' + (r.body && r.body.written) + ')') : ('Failed: ' + (r.body && r.body.error || r.ok));
|
|
554
|
+
})
|
|
555
|
+
.catch(function(e) { sendBtn.textContent = 'Error: ' + (e && e.message || e); })
|
|
556
|
+
.finally(function() { setTimeout(function() { sendBtn.disabled = false; sendBtn.textContent = 'Send to engine'; }, 2500); });
|
|
557
|
+
});
|
|
558
|
+
const clearBtn = document.createElement('button');
|
|
559
|
+
clearBtn.type = 'button';
|
|
560
|
+
clearBtn.textContent = 'Clear';
|
|
561
|
+
clearBtn.style.cssText = 'background:none;color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer';
|
|
562
|
+
clearBtn.addEventListener('click', function() { window._refreshDiagnosticsClear(); overlay.remove(); });
|
|
563
|
+
const closeBtn = document.createElement('button');
|
|
564
|
+
closeBtn.type = 'button';
|
|
565
|
+
closeBtn.textContent = 'Close';
|
|
566
|
+
closeBtn.style.cssText = 'background:none;color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer';
|
|
567
|
+
closeBtn.addEventListener('click', function() { overlay.remove(); });
|
|
568
|
+
actions.appendChild(sendBtn);
|
|
569
|
+
actions.appendChild(clearBtn);
|
|
570
|
+
actions.appendChild(closeBtn);
|
|
571
|
+
head.appendChild(title);
|
|
572
|
+
head.appendChild(actions);
|
|
573
|
+
const body = document.createElement('div');
|
|
574
|
+
body.style.cssText = 'overflow:auto;padding:8px 14px';
|
|
575
|
+
body.appendChild(_renderRefreshDiagTable(entries));
|
|
576
|
+
panel.appendChild(head);
|
|
577
|
+
panel.appendChild(body);
|
|
578
|
+
overlay.appendChild(panel);
|
|
579
|
+
document.body.appendChild(overlay);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function _renderRefreshDiagTable(entries) {
|
|
583
|
+
const table = document.createElement('table');
|
|
584
|
+
table.style.cssText = 'width:100%;border-collapse:collapse;font-family:monospace;font-size:11px';
|
|
585
|
+
const cols = ['ts', 'kind', 'gap', 'vis', 'status', 'etag↓', 'bytes', 'cacheV', 'wi', 'wiΔ', 'changed', 'render(ms)', 'note'];
|
|
586
|
+
const thead = document.createElement('thead');
|
|
587
|
+
const trh = document.createElement('tr');
|
|
588
|
+
for (const c of cols) {
|
|
589
|
+
const th = document.createElement('th');
|
|
590
|
+
th.textContent = c;
|
|
591
|
+
th.style.cssText = 'text-align:left;padding:3px 6px;border-bottom:1px solid var(--border,#444);color:var(--muted,#aaa);font-weight:600';
|
|
592
|
+
trh.appendChild(th);
|
|
593
|
+
}
|
|
594
|
+
thead.appendChild(trh);
|
|
595
|
+
table.appendChild(thead);
|
|
596
|
+
const tbody = document.createElement('tbody');
|
|
597
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
598
|
+
const e = entries[i];
|
|
599
|
+
const tr = document.createElement('tr');
|
|
600
|
+
const changedKeys = e.changed ? Object.keys(e.changed).filter(function(k) { return e.changed[k]; }) : [];
|
|
601
|
+
const isVis = e.kind === 'visibility';
|
|
602
|
+
const cells = [
|
|
603
|
+
new Date(e.ts).toLocaleTimeString(),
|
|
604
|
+
e.kind || 'refresh',
|
|
605
|
+
e.gap_since_prev_ms != null ? e.gap_since_prev_ms + 'ms' : '',
|
|
606
|
+
e.visibility || '',
|
|
607
|
+
isVis ? (e.hidden_for_ms != null ? 'hidden ' + e.hidden_for_ms + 'ms' : '—') : (e.response_status || ''),
|
|
608
|
+
e.etag_received ? String(e.etag_received).slice(0, 12) + '…' : '',
|
|
609
|
+
e.bytes_received != null ? e.bytes_received : '',
|
|
610
|
+
e.statusCacheVersion != null ? e.statusCacheVersion : '',
|
|
611
|
+
e.workItems_count != null ? e.workItems_count : '',
|
|
612
|
+
e.workItems_changed === true ? '✓' : (e.workItems_changed === false ? '' : ''),
|
|
613
|
+
changedKeys.join(',') || '',
|
|
614
|
+
e.render_duration_ms != null ? e.render_duration_ms : '',
|
|
615
|
+
e.error_message || '',
|
|
616
|
+
];
|
|
617
|
+
for (const v of cells) {
|
|
618
|
+
const td = document.createElement('td');
|
|
619
|
+
td.textContent = String(v);
|
|
620
|
+
td.style.cssText = 'padding:3px 6px;border-bottom:1px solid rgba(255,255,255,0.05);vertical-align:top';
|
|
621
|
+
tr.appendChild(td);
|
|
622
|
+
}
|
|
623
|
+
if (isVis) tr.style.background = 'rgba(63,185,80,0.05)';
|
|
624
|
+
tbody.appendChild(tr);
|
|
625
|
+
}
|
|
626
|
+
table.appendChild(tbody);
|
|
627
|
+
return table;
|
|
628
|
+
}
|
|
@@ -82,8 +82,20 @@ function wiRow(item) {
|
|
|
82
82
|
'<td>' + prLink + '</td>' +
|
|
83
83
|
'<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
|
|
84
84
|
'<td style="white-space:nowrap;font-size:9px;color:var(--muted)">' +
|
|
85
|
-
(
|
|
86
|
-
|
|
85
|
+
(function() {
|
|
86
|
+
// /api/status slims the full arrays into integer counts
|
|
87
|
+
// (dashboard.js:1796-1797: referencesCount / acceptanceCriteriaCount).
|
|
88
|
+
// Read those first; fall back to the array length for callers that
|
|
89
|
+
// still pass the full record (e.g. legacy fixtures, hydrated detail).
|
|
90
|
+
var refCount = (typeof item.referencesCount === 'number')
|
|
91
|
+
? item.referencesCount
|
|
92
|
+
: (Array.isArray(item.references) ? item.references.length : 0);
|
|
93
|
+
var acCount = (typeof item.acceptanceCriteriaCount === 'number')
|
|
94
|
+
? item.acceptanceCriteriaCount
|
|
95
|
+
: (Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0);
|
|
96
|
+
return (refCount ? '<span title="' + refCount + ' reference(s)" style="margin-right:4px">🔗' + refCount + '</span>' : '') +
|
|
97
|
+
(acCount ? '<span title="' + acCount + ' acceptance criteria">☑' + acCount + '</span>' : '');
|
|
98
|
+
})() +
|
|
87
99
|
'</td>' +
|
|
88
100
|
'<td style="white-space:nowrap">' +
|
|
89
101
|
((item.status === 'pending' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Edit work item">✎</button>' : '') +
|
|
@@ -144,9 +156,42 @@ function renderWorkItems(items) {
|
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
|
|
147
|
-
function editWorkItem(id, source) {
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
159
|
+
async function editWorkItem(id, source) {
|
|
160
|
+
const cached = allWorkItems.find(i => i.id === id);
|
|
161
|
+
if (!cached) return;
|
|
162
|
+
// Hydrate the full record before rendering the form. The /api/status slice
|
|
163
|
+
// strips `description`, `references`, and `acceptanceCriteria` (replacing
|
|
164
|
+
// the arrays with referencesCount / acceptanceCriteriaCount integers — see
|
|
165
|
+
// dashboard.js:1796-1797). Without hydration the textareas would pre-fill
|
|
166
|
+
// with empty values, and Save would POST description='', references=[],
|
|
167
|
+
// acceptanceCriteria=[] back to handleWorkItemsUpdate, which treats those
|
|
168
|
+
// as defined (dashboard.js:5157, :5164-5165) and silently wipes the stored
|
|
169
|
+
// values: data-loss on every pencil-edit of a pending/failed WI.
|
|
170
|
+
// See PR #2816 review feedback.
|
|
171
|
+
let item = cached;
|
|
172
|
+
const needsHydration = !cached.description ||
|
|
173
|
+
(cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
|
|
174
|
+
(cached.referencesCount > 0 && !Array.isArray(cached.references));
|
|
175
|
+
if (needsHydration) {
|
|
176
|
+
try {
|
|
177
|
+
const r = await fetch('/api/work-items/' + encodeURIComponent(id));
|
|
178
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
179
|
+
const data = await r.json();
|
|
180
|
+
const full = data && data.item;
|
|
181
|
+
if (!full) throw new Error('no item in response');
|
|
182
|
+
// Cached cross-slice fields (_pr, _source, _artifacts, _pendingReason,
|
|
183
|
+
// etc.) WIN over the on-disk record — they only exist on the engine's
|
|
184
|
+
// in-memory enrichment pass. The full record contributes description,
|
|
185
|
+
// acceptanceCriteria, and references back.
|
|
186
|
+
item = Object.assign({}, full, cached);
|
|
187
|
+
if (full.description != null) item.description = full.description;
|
|
188
|
+
if (Array.isArray(full.acceptanceCriteria)) item.acceptanceCriteria = full.acceptanceCriteria;
|
|
189
|
+
if (Array.isArray(full.references)) item.references = full.references;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
alert('Failed to load work item for editing: ' + (e && e.message ? e.message : e) + '. Refusing to open the edit form with empty fields — saving would wipe the stored values.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
150
195
|
const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs', 'setup'];
|
|
151
196
|
const priorities = ['critical', 'high', 'medium', 'low'];
|
|
152
197
|
const agentOpts = (cmdAgents || []).map(a => '<option value="' + escapeHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escapeHtml(a.name) + '</option>').join('');
|
|
@@ -467,11 +512,7 @@ async function _submitCreateWorkItem(e) {
|
|
|
467
512
|
} catch (e) { alert('Error: ' + e.message); openCreateWorkItemModal(); }
|
|
468
513
|
}
|
|
469
514
|
|
|
470
|
-
function
|
|
471
|
-
if (window.getSelection && window.getSelection().toString().length > 0) return;
|
|
472
|
-
const item = allWorkItems.find(i => i.id === id);
|
|
473
|
-
if (!item) return;
|
|
474
|
-
|
|
515
|
+
function _wiRenderDetail(item) {
|
|
475
516
|
const field = (label, value) => value ? '<div style="margin-bottom:8px"><span style="color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.5px">' + label + '</span><div style="margin-top:2px">' + value + '</div></div>' : '';
|
|
476
517
|
const badge = (cls, text) => '<span class="pr-badge ' + cls + '">' + escapeHtml(text) + '</span>';
|
|
477
518
|
const statusCls = item.status === 'failed' ? 'rejected' : item.status === 'dispatched' ? 'building' : item.status === 'done' ? 'approved' : 'active';
|
|
@@ -483,7 +524,20 @@ function openWorkItemDetail(id) {
|
|
|
483
524
|
'<span class="dispatch-type ' + (item.type || 'implement') + '">' + escapeHtml(item.type || 'implement') + '</span>' +
|
|
484
525
|
'<span class="prd-item-priority ' + (item.priority || '') + '">' + escapeHtml(item.priority || 'medium') + '</span>' +
|
|
485
526
|
'</div>';
|
|
486
|
-
|
|
527
|
+
// Description: rendered from the FULL record once it's hydrated. The /api/status
|
|
528
|
+
// slice drops `description` to keep the SPA payload <500KB; on first paint we
|
|
529
|
+
// either render the title as a placeholder or, when description is already
|
|
530
|
+
// present (legacy/full record), render it directly. The hydration pass below
|
|
531
|
+
// re-renders this section in place once the fetch lands.
|
|
532
|
+
var _descHtml;
|
|
533
|
+
if (item.description) {
|
|
534
|
+
_descHtml = renderMd(item.description);
|
|
535
|
+
} else if (item._descriptionLoading) {
|
|
536
|
+
_descHtml = '<span style="color:var(--muted)">Loading description…</span>';
|
|
537
|
+
} else {
|
|
538
|
+
_descHtml = escapeHtml(item.title || '—');
|
|
539
|
+
}
|
|
540
|
+
html += field('Description', '<div id="wi-detail-desc" style="font-size:12px;max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + _descHtml + '</div>');
|
|
487
541
|
html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
|
|
488
542
|
html += field('Source', escapeHtml(item._source || 'central'));
|
|
489
543
|
if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
|
|
@@ -522,9 +576,19 @@ function openWorkItemDetail(id) {
|
|
|
522
576
|
: (typeof item.acceptanceCriteria === 'string' && item.acceptanceCriteria.trim()
|
|
523
577
|
? item.acceptanceCriteria.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
|
524
578
|
: []);
|
|
525
|
-
if (ac.length)
|
|
579
|
+
if (ac.length) {
|
|
580
|
+
html += field('Acceptance Criteria', '<ul style="margin:0;padding-left:20px">' + ac.map(c => '<li>' + escapeHtml(c) + '</li>').join('') + '</ul>');
|
|
581
|
+
} else if (item.acceptanceCriteriaCount > 0) {
|
|
582
|
+
// Count-only marker from the slim /api/status slice; full list arrives
|
|
583
|
+
// on the next hydration pass (or is already loading).
|
|
584
|
+
html += field('Acceptance Criteria', '<span style="color:var(--muted)">' + item.acceptanceCriteriaCount + ' criteria — loading…</span>');
|
|
585
|
+
}
|
|
526
586
|
var refs = Array.isArray(item.references) ? item.references.filter(r => r && typeof r === 'object' && r.url) : [];
|
|
527
|
-
if (refs.length)
|
|
587
|
+
if (refs.length) {
|
|
588
|
+
html += field('References', refs.map(r => '<a href="' + escapeHtml(r.url) + '" target="_blank" style="color:var(--blue)">' + escapeHtml(r.title || r.url) + '</a>' + (r.type ? ' <span style="color:var(--muted);font-size:10px">(' + escapeHtml(r.type) + ')</span>' : '')).join('<br>'));
|
|
589
|
+
} else if (item.referencesCount > 0) {
|
|
590
|
+
html += field('References', '<span style="color:var(--muted)">' + item.referencesCount + ' reference(s) — loading…</span>');
|
|
591
|
+
}
|
|
528
592
|
if (item._humanFeedback) html += field('Human Feedback', (item._humanFeedback.rating === 'up' ? '👍' : '👎') + (item._humanFeedback.comment ? ' — ' + escapeHtml(item._humanFeedback.comment) : ''));
|
|
529
593
|
if (item._pr) html += field('Pull Request', '<a href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="color:var(--blue)">' + escapeHtml(item._pr) + '</a>');
|
|
530
594
|
|
|
@@ -562,12 +626,55 @@ function openWorkItemDetail(id) {
|
|
|
562
626
|
if (item._totalInputTokens) html += field('Total Input Tokens', Number(item._totalInputTokens).toLocaleString());
|
|
563
627
|
if (item._totalOutputTokens) html += field('Total Output Tokens', Number(item._totalOutputTokens).toLocaleString());
|
|
564
628
|
html += '</div>';
|
|
629
|
+
return html;
|
|
630
|
+
}
|
|
565
631
|
|
|
566
|
-
|
|
567
|
-
|
|
632
|
+
function openWorkItemDetail(id) {
|
|
633
|
+
if (window.getSelection && window.getSelection().toString().length > 0) return;
|
|
634
|
+
const cached = allWorkItems.find(i => i.id === id);
|
|
635
|
+
if (!cached) return;
|
|
636
|
+
|
|
637
|
+
// Render the modal immediately from the cached (slim) record so the click
|
|
638
|
+
// feels instant. The /api/status slice is the source of badges, status,
|
|
639
|
+
// PR link, artifacts, etc. — everything except the heavy free-text fields
|
|
640
|
+
// (description, full acceptanceCriteria, full references) — see
|
|
641
|
+
// W-mphejzmj000718bf. We then hydrate the missing fields from
|
|
642
|
+
// GET /api/work-items/<id> and re-render in place.
|
|
643
|
+
const needsHydration = !cached.description ||
|
|
644
|
+
(cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
|
|
645
|
+
(cached.referencesCount > 0 && !Array.isArray(cached.references));
|
|
646
|
+
|
|
647
|
+
const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
|
|
648
|
+
document.getElementById('modal-title').textContent = initial.title || initial.id;
|
|
649
|
+
document.getElementById('modal-body').innerHTML = _wiRenderDetail(initial);
|
|
568
650
|
document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
569
651
|
document.getElementById('modal-body').style.whiteSpace = 'normal';
|
|
570
652
|
document.getElementById('modal').classList.add('open');
|
|
653
|
+
|
|
654
|
+
if (!needsHydration) return;
|
|
655
|
+
|
|
656
|
+
fetch('/api/work-items/' + encodeURIComponent(id))
|
|
657
|
+
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
658
|
+
.then(function(data) {
|
|
659
|
+
// Guard against modal navigation away from this WI during the fetch.
|
|
660
|
+
var title = document.getElementById('modal-title');
|
|
661
|
+
if (!title || title.textContent !== (initial.title || initial.id)) return;
|
|
662
|
+
var full = data && data.item;
|
|
663
|
+
if (!full) return;
|
|
664
|
+
// Merge: cached cross-slice fields (_pr, _artifacts, etc.) WIN over
|
|
665
|
+
// the on-disk record so we don't lose engine enrichment that lives
|
|
666
|
+
// only on the in-memory pass. The full record contributes description,
|
|
667
|
+
// acceptanceCriteria, and references back to the rendered shape.
|
|
668
|
+
var merged = Object.assign({}, full, cached);
|
|
669
|
+
merged.description = full.description || cached.description || '';
|
|
670
|
+
if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
|
|
671
|
+
if (Array.isArray(full.references)) merged.references = full.references;
|
|
672
|
+
document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
|
|
673
|
+
})
|
|
674
|
+
.catch(function() {
|
|
675
|
+
var desc = document.getElementById('wi-detail-desc');
|
|
676
|
+
if (desc) desc.innerHTML = '<span style="color:var(--red)">Failed to load full record.</span>';
|
|
677
|
+
});
|
|
571
678
|
}
|
|
572
679
|
|
|
573
680
|
function openAllWorkItems() {
|
package/dashboard/js/settings.js
CHANGED
|
@@ -88,6 +88,7 @@ async function openSettings() {
|
|
|
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
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') + ')') +
|
|
91
|
+
settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 7, 'days', 'Trim done/failed/cancelled work items older than N days from the /api/status workItems slice (active items are always shipped). Cuts SPA payload from ~3MB to <500KB. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
|
|
91
92
|
'</div>' +
|
|
92
93
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
|
|
93
94
|
'<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
|
|
@@ -597,6 +598,7 @@ async function saveSettings() {
|
|
|
597
598
|
restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
|
|
598
599
|
meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
|
|
599
600
|
operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
|
|
601
|
+
statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
|
|
600
602
|
autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
|
|
601
603
|
evalLoop: document.getElementById('set-evalLoop').checked,
|
|
602
604
|
autoDecompose: document.getElementById('set-autoDecompose').checked,
|
package/dashboard/js/state.js
CHANGED
|
@@ -27,10 +27,13 @@ let currentPage = getPageFromUrl();
|
|
|
27
27
|
|
|
28
28
|
// W-mpgb0xa7000d90d4 (dashboard-refresh-audit.md F5) — switchPage previously
|
|
29
29
|
// only cleaned intervals and flipped CSS; tabs like /plans and /inbox waited
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// for refresh.js's next slow-cycle (refreshKnowledgeBase + refreshPlans) before
|
|
31
|
+
// showing fresh data. The QA tab worked around this with its own switchPage
|
|
32
|
+
// monkey-patch in qa.js (__qaWrapped) — that per-tab pattern is now generalized
|
|
33
|
+
// into one canonical lifecycle below. (As of W-mphfb6ss000a3b9e the slow-cycle
|
|
34
|
+
// modulo was dropped, so refreshPlans/refreshKnowledgeBase now run every 4s
|
|
35
|
+
// status tick — but the page-enter loader still wins on tab-switch because it
|
|
36
|
+
// fires synchronously without waiting for the next interval boundary.)
|
|
34
37
|
//
|
|
35
38
|
// PAGE_LAZY_LOADERS: functions to invoke when ENTERING a page (resolved by
|
|
36
39
|
// name through window so the function body can live alongside its tab UI).
|
package/dashboard.js
CHANGED
|
@@ -1628,6 +1628,20 @@ function _getSlowMtimes() {
|
|
|
1628
1628
|
return result;
|
|
1629
1629
|
}
|
|
1630
1630
|
|
|
1631
|
+
// Reset the per-source caches that outlive the slow-state TTL when a tracked
|
|
1632
|
+
// source file changes (W-mphfdgwv000bf549). The slow-state mtime tracker now
|
|
1633
|
+
// covers skill discovery dirs and MCP config files, but
|
|
1634
|
+
// `queries._skillsCache` (30 s) and the local `_mcpServersCache` (5 min)
|
|
1635
|
+
// would still serve stale data into `_buildStatusSlowState()` — defeating
|
|
1636
|
+
// the <4 s freshness goal. Only call this when an mtime delta is detected;
|
|
1637
|
+
// TTL-driven rebuilds keep using the inner caches so we don't pay disk-scan
|
|
1638
|
+
// cost on every 60 s slow-state rollover.
|
|
1639
|
+
function _invalidateSlowInnerCachesForMtimeChange() {
|
|
1640
|
+
try { queries.invalidateSkillsCache(); } catch { /* optional */ }
|
|
1641
|
+
_mcpServersCache = null;
|
|
1642
|
+
_mcpServersCacheTs = 0;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1631
1645
|
function _mtimesChanged(prev, curr) {
|
|
1632
1646
|
for (const fp of Object.keys(curr)) {
|
|
1633
1647
|
if (prev[fp] !== curr[fp]) return true;
|
|
@@ -1694,7 +1708,7 @@ function _buildStatusFastState() {
|
|
|
1694
1708
|
dispatch: getDispatchQueue(),
|
|
1695
1709
|
engineLog: getEngineLog(),
|
|
1696
1710
|
metrics: getMetrics(),
|
|
1697
|
-
workItems: getWorkItems(),
|
|
1711
|
+
workItems: _slimWorkItemsForStatus(getWorkItems()),
|
|
1698
1712
|
watches: watchesMod.getWatches(),
|
|
1699
1713
|
meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
|
|
1700
1714
|
// QA runs — surfaced for the sidebar activity-dot counter and any future
|
|
@@ -1731,6 +1745,105 @@ function _safeStatusSlice(name, fn, fallback) {
|
|
|
1731
1745
|
}
|
|
1732
1746
|
}
|
|
1733
1747
|
|
|
1748
|
+
// ── /api/status workItems slimming (W-mphejzmj000718bf) ─────────────────────
|
|
1749
|
+
// Strip down the workItems slice shipped on /api/status to (a) drop terminal
|
|
1750
|
+
// (done/failed/cancelled) items older than engine.statusWorkItemsRetentionDays
|
|
1751
|
+
// and (b) project each surviving item onto a narrow shape that omits the
|
|
1752
|
+
// large free-text fields (description, full acceptanceCriteria, full
|
|
1753
|
+
// references). The dashboard never renders description/AC/references-detail
|
|
1754
|
+
// off the cached slice — `wiRow` only needs counts + status/badge fields,
|
|
1755
|
+
// and `openWorkItemDetail` fetches the full record on demand via
|
|
1756
|
+
// GET /api/work-items/<id>. Live measurement at task time: 796 items / ~3MB
|
|
1757
|
+
// → ~50–100 items / <500KB typical. Active items (pending/dispatched/queued)
|
|
1758
|
+
// are ALWAYS kept regardless of age.
|
|
1759
|
+
//
|
|
1760
|
+
// Set engine.statusWorkItemsRetentionDays = 0 to disable trimming entirely
|
|
1761
|
+
// (returns the full list unchanged, restoring legacy behavior).
|
|
1762
|
+
const _ACTIVE_WI_STATUSES_FOR_STATUS = new Set(['pending', 'dispatched', 'queued', 'paused', 'decomposed']);
|
|
1763
|
+
const _TERMINAL_WI_STATUSES_FOR_STATUS = new Set(['done', 'failed', 'cancelled']);
|
|
1764
|
+
function _resolveStatusWorkItemsRetentionDays() {
|
|
1765
|
+
const raw = CONFIG?.engine?.statusWorkItemsRetentionDays;
|
|
1766
|
+
if (raw === 0 || raw === '0') return 0;
|
|
1767
|
+
const n = Number(raw);
|
|
1768
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
1769
|
+
return shared.ENGINE_DEFAULTS.statusWorkItemsRetentionDays;
|
|
1770
|
+
}
|
|
1771
|
+
function _slimWorkItemForStatus(item) {
|
|
1772
|
+
const slim = {
|
|
1773
|
+
id: item.id,
|
|
1774
|
+
title: item.title,
|
|
1775
|
+
status: item.status,
|
|
1776
|
+
type: item.type,
|
|
1777
|
+
priority: item.priority,
|
|
1778
|
+
_source: item._source,
|
|
1779
|
+
created: item.created,
|
|
1780
|
+
};
|
|
1781
|
+
if (item.dispatched_to !== undefined) slim.dispatched_to = item.dispatched_to;
|
|
1782
|
+
if (item.agent !== undefined) slim.agent = item.agent;
|
|
1783
|
+
if (item._pr !== undefined) slim._pr = item._pr;
|
|
1784
|
+
if (item._prUrl !== undefined) slim._prUrl = item._prUrl;
|
|
1785
|
+
if (item._skipReason !== undefined) slim._skipReason = item._skipReason;
|
|
1786
|
+
if (item._blockedBy !== undefined) slim._blockedBy = item._blockedBy;
|
|
1787
|
+
if (item._humanFeedback !== undefined) slim._humanFeedback = item._humanFeedback;
|
|
1788
|
+
if (Array.isArray(item.completedAgents)) slim.completedAgents = item.completedAgents;
|
|
1789
|
+
if (item.failReason !== undefined) slim.failReason = item.failReason;
|
|
1790
|
+
if (item.branchStrategy !== undefined) slim.branchStrategy = item.branchStrategy;
|
|
1791
|
+
if (item.scope !== undefined) slim.scope = item.scope;
|
|
1792
|
+
if (item.completedAt !== undefined) slim.completedAt = item.completedAt;
|
|
1793
|
+
if (item.dispatched_at !== undefined) slim.dispatched_at = item.dispatched_at;
|
|
1794
|
+
if (item._reopened !== undefined) slim._reopened = item._reopened;
|
|
1795
|
+
if (item._pendingReason !== undefined) slim._pendingReason = item._pendingReason;
|
|
1796
|
+
if (item._managedSpawnPartial !== undefined) slim._managedSpawnPartial = item._managedSpawnPartial;
|
|
1797
|
+
if (item._securityFlag !== undefined) slim._securityFlag = item._securityFlag;
|
|
1798
|
+
if (item._artifacts !== undefined) slim._artifacts = item._artifacts;
|
|
1799
|
+
// Cross-slice fields read by derivePlanStatus / render-prd / refresh.js
|
|
1800
|
+
// (sourcePlan, planFile, itemType, project, parent_id). Without these the
|
|
1801
|
+
// plan-status derivation and decomposed-children rollup regress.
|
|
1802
|
+
if (item.sourcePlan !== undefined) slim.sourcePlan = item.sourcePlan;
|
|
1803
|
+
if (item.planFile !== undefined) slim.planFile = item.planFile;
|
|
1804
|
+
if (item.itemType !== undefined) slim.itemType = item.itemType;
|
|
1805
|
+
if (item.project !== undefined) slim.project = item.project;
|
|
1806
|
+
if (item.parent_id !== undefined) slim.parent_id = item.parent_id;
|
|
1807
|
+
// PR follow-up subobject is small and drives the +N chip + row badge.
|
|
1808
|
+
if (item.meta && item.meta.pr_followup) slim.meta = { pr_followup: item.meta.pr_followup };
|
|
1809
|
+
// Counts only — the modal fetches full arrays on demand via /api/work-items/<id>.
|
|
1810
|
+
slim.referencesCount = Array.isArray(item.references) ? item.references.length : 0;
|
|
1811
|
+
slim.acceptanceCriteriaCount = Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0;
|
|
1812
|
+
return slim;
|
|
1813
|
+
}
|
|
1814
|
+
function _slimWorkItemsForStatus(items) {
|
|
1815
|
+
if (!Array.isArray(items)) return items;
|
|
1816
|
+
const retentionDays = _resolveStatusWorkItemsRetentionDays();
|
|
1817
|
+
if (retentionDays <= 0) {
|
|
1818
|
+
// Trimming disabled — keep full list but still flatten via slim shape
|
|
1819
|
+
// so wire format is consistent (renderers never have to handle the old
|
|
1820
|
+
// heavy fields). Set retentionDays to a huge value if you want EVERY
|
|
1821
|
+
// terminal item, not just recent ones — 0 means "skip the date filter".
|
|
1822
|
+
return items.map(_slimWorkItemForStatus);
|
|
1823
|
+
}
|
|
1824
|
+
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
1825
|
+
const surviving = [];
|
|
1826
|
+
for (const item of items) {
|
|
1827
|
+
if (!item) continue;
|
|
1828
|
+
const status = item.status || 'pending';
|
|
1829
|
+
if (_TERMINAL_WI_STATUSES_FOR_STATUS.has(status)) {
|
|
1830
|
+
const ts = item.completedAt || item.dispatched_at || item.created || '';
|
|
1831
|
+
const tsMs = ts ? Date.parse(ts) : NaN;
|
|
1832
|
+
// Drop only when we have a parseable timestamp and it's beyond the
|
|
1833
|
+
// window. Items with missing/unparseable timestamps stay visible —
|
|
1834
|
+
// we'd rather over-include than silently hide them.
|
|
1835
|
+
if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
} else if (!_ACTIVE_WI_STATUSES_FOR_STATUS.has(status)) {
|
|
1839
|
+
// Unknown status — keep, so a future status value isn't silently
|
|
1840
|
+
// hidden until the constant set is updated.
|
|
1841
|
+
}
|
|
1842
|
+
surviving.push(_slimWorkItemForStatus(item));
|
|
1843
|
+
}
|
|
1844
|
+
return surviving;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1734
1847
|
// Build the slow-state slice (rarely-changing data: ~60s TTL).
|
|
1735
1848
|
function _buildStatusSlowState() {
|
|
1736
1849
|
const prdInfo = getPrdInfo();
|
|
@@ -1798,6 +1911,13 @@ function _buildStatusSlowState() {
|
|
|
1798
1911
|
// on the client (R3, W-mpgb0xgc000hf1d3). Quotes stripped from the
|
|
1799
1912
|
// weak/strong ETag form so consumers see a bare hex digest.
|
|
1800
1913
|
dashboardBuildId: HTML_ETAG ? HTML_ETAG.replace(/^"|"$/g, '') : null,
|
|
1914
|
+
// Monotonic counter bumped on every successful /api/status cache rebuild.
|
|
1915
|
+
// Surfaced so the dashboard refresh-diagnostics ring buffer
|
|
1916
|
+
// (dashboard/js/refresh.js, W-mphejzx100081972) can correlate "did the
|
|
1917
|
+
// server cache actually advance between two ticks" — distinguishes
|
|
1918
|
+
// (b) server kept returning 304 with stale data from (c) renderer
|
|
1919
|
+
// change-detection caches skipped the render on fresh data.
|
|
1920
|
+
statusCacheVersion: _statusCacheVersion,
|
|
1801
1921
|
disk: diskVersion,
|
|
1802
1922
|
diskCommit,
|
|
1803
1923
|
engineStale,
|
|
@@ -1838,9 +1958,13 @@ function getStatus() {
|
|
|
1838
1958
|
// so changes surface within one SPA poll (~4 s) instead of waiting up to
|
|
1839
1959
|
// 60 s for TTL. Same pre-build snapshot semantics as fast-state below.
|
|
1840
1960
|
let slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
|
|
1961
|
+
let slowMtimeChanged = false;
|
|
1841
1962
|
if (!slowStale) {
|
|
1842
1963
|
const currSlowMtimes = _getSlowMtimes();
|
|
1843
|
-
if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes))
|
|
1964
|
+
if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) {
|
|
1965
|
+
slowStale = true;
|
|
1966
|
+
slowMtimeChanged = true;
|
|
1967
|
+
}
|
|
1844
1968
|
}
|
|
1845
1969
|
|
|
1846
1970
|
// If nothing stale, return cached merged result
|
|
@@ -1867,6 +1991,7 @@ function getStatus() {
|
|
|
1867
1991
|
// Same pre-build snapshot pattern as fast-state — capture mtimes BEFORE
|
|
1868
1992
|
// disk reads so any write landing mid-build busts the next poll.
|
|
1869
1993
|
if (slowStale) {
|
|
1994
|
+
if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
|
|
1870
1995
|
const preBuildSlowMtimes = _getSlowMtimes();
|
|
1871
1996
|
_slowState = _buildStatusSlowState();
|
|
1872
1997
|
_slowStateTs = now;
|
|
@@ -1912,9 +2037,13 @@ function refreshStatusAsync() {
|
|
|
1912
2037
|
if (_mtimesChanged(_lastMtimes, currMtimes)) fastStale = true;
|
|
1913
2038
|
}
|
|
1914
2039
|
let slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
|
|
2040
|
+
let slowMtimeChanged = false;
|
|
1915
2041
|
if (!slowStale) {
|
|
1916
2042
|
const currSlowMtimes = _getSlowMtimes();
|
|
1917
|
-
if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes))
|
|
2043
|
+
if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) {
|
|
2044
|
+
slowStale = true;
|
|
2045
|
+
slowMtimeChanged = true;
|
|
2046
|
+
}
|
|
1918
2047
|
}
|
|
1919
2048
|
|
|
1920
2049
|
if (!fastStale && !slowStale && _statusCache) return _statusCache;
|
|
@@ -1944,6 +2073,7 @@ function refreshStatusAsync() {
|
|
|
1944
2073
|
let slow = _slowState;
|
|
1945
2074
|
let preBuildSlowMtimes = null;
|
|
1946
2075
|
if (slowStale) {
|
|
2076
|
+
if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
|
|
1947
2077
|
preBuildSlowMtimes = _getSlowMtimes();
|
|
1948
2078
|
slow = _buildStatusSlowState();
|
|
1949
2079
|
}
|
|
@@ -4797,6 +4927,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
4797
4927
|
} catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
4798
4928
|
}
|
|
4799
4929
|
|
|
4930
|
+
// GET /api/work-items/<id> — return a single FULL work-item record by id
|
|
4931
|
+
// (W-mphejzmj000718bf). The /api/status workItems slice ships a slimmed
|
|
4932
|
+
// shape that omits description, full acceptanceCriteria, and full references
|
|
4933
|
+
// to keep the SPA payload <500KB. The work-item detail modal calls this
|
|
4934
|
+
// endpoint on click to fetch the full record on demand.
|
|
4935
|
+
async function handleWorkItemsById(req, res, match) {
|
|
4936
|
+
try {
|
|
4937
|
+
const id = decodeURIComponent(match[1] || '').trim();
|
|
4938
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
4939
|
+
// Reserved sub-paths handled by their own static routes — never fall
|
|
4940
|
+
// through to this regex; defense-in-depth in case ordering ever
|
|
4941
|
+
// changes.
|
|
4942
|
+
if (id === 'archive' || id === 'retry' || id === 'update' || id === 'delete' ||
|
|
4943
|
+
id === 'cancel' || id === 'reopen' || id === 'feedback') {
|
|
4944
|
+
return jsonReply(res, 404, { error: 'work item not found' });
|
|
4945
|
+
}
|
|
4946
|
+
// Search central + every project's work-items.json for this id. The
|
|
4947
|
+
// engine never enforces global id uniqueness in the file system, but
|
|
4948
|
+
// collisions in practice are nil — first hit wins.
|
|
4949
|
+
const found = getWorkItems().find(w => w && w.id === id);
|
|
4950
|
+
if (!found) return jsonReply(res, 404, { error: 'work item not found' });
|
|
4951
|
+
return jsonReply(res, 200, { item: found });
|
|
4952
|
+
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
4953
|
+
}
|
|
4954
|
+
|
|
4800
4955
|
async function handleWorkItemsReopen(req, res) {
|
|
4801
4956
|
try {
|
|
4802
4957
|
const body = await readBody(req);
|
|
@@ -8368,6 +8523,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8368
8523
|
}
|
|
8369
8524
|
// String fields
|
|
8370
8525
|
if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
|
|
8526
|
+
// W-mphejzmj000718bf: /api/status workItems retention window. Handled
|
|
8527
|
+
// outside the numericFields loop because `Number(0) || D[key]`
|
|
8528
|
+
// collapses the "disable trim" value (0) back to the default — we
|
|
8529
|
+
// want 0 to actually persist as "skip the date filter".
|
|
8530
|
+
if (e.statusWorkItemsRetentionDays !== undefined) {
|
|
8531
|
+
const raw = e.statusWorkItemsRetentionDays;
|
|
8532
|
+
if (raw === '' || raw === null) {
|
|
8533
|
+
_deleteEngineConfig('statusWorkItemsRetentionDays');
|
|
8534
|
+
} else {
|
|
8535
|
+
let val = Number(raw);
|
|
8536
|
+
if (!Number.isFinite(val) || val < 0) val = D.statusWorkItemsRetentionDays;
|
|
8537
|
+
if (val > 365) { _clamped.push(`statusWorkItemsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
|
|
8538
|
+
_setEngineConfig('statusWorkItemsRetentionDays', val);
|
|
8539
|
+
}
|
|
8540
|
+
}
|
|
8371
8541
|
// W-mpejf0fq000e84d6: operator login override. Empty string clears
|
|
8372
8542
|
// the override (engine falls back to gh/git/os resolution); any other
|
|
8373
8543
|
// value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
|
|
@@ -8699,6 +8869,52 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8699
8869
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
8700
8870
|
}
|
|
8701
8871
|
|
|
8872
|
+
// Refresh diagnostics — append the browser-side ring buffer to a rotating
|
|
8873
|
+
// log so a user clicking "send to engine" from the diag chip persists the
|
|
8874
|
+
// last ~50 /api/status polls for offline triage (W-mphejzx100081972).
|
|
8875
|
+
// Rotated at 1 MB to bound disk usage; CSRF/origin-gated by the dispatcher
|
|
8876
|
+
// (MUTATING_METHODS guard at dashboard.js:4337+).
|
|
8877
|
+
const DASHBOARD_DIAGNOSTICS_LOG = path.join(ENGINE_DIR, 'dashboard-diagnostics.log');
|
|
8878
|
+
const DASHBOARD_DIAGNOSTICS_ROTATE_BYTES = 1024 * 1024;
|
|
8879
|
+
const DASHBOARD_DIAGNOSTICS_MAX_ENTRIES = 500;
|
|
8880
|
+
|
|
8881
|
+
async function handleDiagnosticsRefresh(req, res) {
|
|
8882
|
+
try {
|
|
8883
|
+
const body = await readBody(req);
|
|
8884
|
+
const entries = body && Array.isArray(body.entries) ? body.entries : null;
|
|
8885
|
+
if (!entries) return jsonReply(res, 400, { error: 'entries array required' });
|
|
8886
|
+
if (entries.length > DASHBOARD_DIAGNOSTICS_MAX_ENTRIES) {
|
|
8887
|
+
return jsonReply(res, 400, { error: `entries length ${entries.length} exceeds cap ${DASHBOARD_DIAGNOSTICS_MAX_ENTRIES}` });
|
|
8888
|
+
}
|
|
8889
|
+
// Rotate before write if the existing log is already at/over the cap.
|
|
8890
|
+
// Single rolled file (.1) — older rotations are discarded; this is a
|
|
8891
|
+
// diagnostic capture surface, not a long-term audit log.
|
|
8892
|
+
try {
|
|
8893
|
+
const st = fs.statSync(DASHBOARD_DIAGNOSTICS_LOG);
|
|
8894
|
+
if (st.size >= DASHBOARD_DIAGNOSTICS_ROTATE_BYTES) {
|
|
8895
|
+
const rolled = DASHBOARD_DIAGNOSTICS_LOG + '.1';
|
|
8896
|
+
try { fs.unlinkSync(rolled); } catch { /* may not exist */ }
|
|
8897
|
+
try { fs.renameSync(DASHBOARD_DIAGNOSTICS_LOG, rolled); } catch { /* best effort */ }
|
|
8898
|
+
}
|
|
8899
|
+
} catch { /* file may not exist yet */ }
|
|
8900
|
+
const header = {
|
|
8901
|
+
ts: new Date().toISOString(),
|
|
8902
|
+
ua: String(req.headers['user-agent'] || '').slice(0, 200),
|
|
8903
|
+
tab: String(req.headers['x-minions-dashboard-tab'] || '').slice(0, 80),
|
|
8904
|
+
url: String(req.headers['x-minions-dashboard-url'] || '').slice(0, 200),
|
|
8905
|
+
count: entries.length,
|
|
8906
|
+
};
|
|
8907
|
+
// One line per record: header line followed by one line per entry. JSON-
|
|
8908
|
+
// Lines makes the file trivially `jq`-able for later analysis.
|
|
8909
|
+
const lines = [JSON.stringify({ kind: 'batch', ...header })];
|
|
8910
|
+
for (const e of entries) {
|
|
8911
|
+
try { lines.push(JSON.stringify(e)); } catch { /* skip un-serialisable entry */ }
|
|
8912
|
+
}
|
|
8913
|
+
fs.appendFileSync(DASHBOARD_DIAGNOSTICS_LOG, lines.join('\n') + '\n');
|
|
8914
|
+
return jsonReply(res, 200, { ok: true, written: entries.length, path: path.relative(MINIONS_DIR, DASHBOARD_DIAGNOSTICS_LOG) });
|
|
8915
|
+
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
8916
|
+
}
|
|
8917
|
+
|
|
8702
8918
|
// Slim UX surface for the experimental redesigned dashboard.
|
|
8703
8919
|
// The HTML lives in dashboard/slim.html so the human can iterate on the
|
|
8704
8920
|
// markup directly — we read the file from disk on each request (no in-
|
|
@@ -9569,6 +9785,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9569
9785
|
invalidateStatusCache();
|
|
9570
9786
|
return jsonReply(res, 200, { ok: true });
|
|
9571
9787
|
}},
|
|
9788
|
+
// GET /api/work-items/<id> — fetch a single FULL work-item record by id.
|
|
9789
|
+
// Registered AFTER all static /api/work-items/* routes so the regex never
|
|
9790
|
+
// shadows them (route matching is sequential, first match wins).
|
|
9791
|
+
// W-mphejzmj000718bf: the /api/status workItems slice drops description /
|
|
9792
|
+
// acceptanceCriteria-detail / references-detail to cut payload size; the
|
|
9793
|
+
// detail modal calls this endpoint on click to fetch the full record.
|
|
9794
|
+
{ method: 'GET', path: /^\/api\/work-items\/([^/?]+)$/, template: '/api/work-items/<id>', desc: 'Fetch a single full work-item record by id (description, acceptanceCriteria, references). The /api/status workItems slice ships a slimmed shape; this endpoint backs the detail modal.', handler: handleWorkItemsById },
|
|
9572
9795
|
|
|
9573
9796
|
// Pinned notes
|
|
9574
9797
|
{ method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
|
|
@@ -10181,6 +10404,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10181
10404
|
// Feature flags (experimental / in-progress UX gates — see engine/features.js)
|
|
10182
10405
|
{ method: 'GET', path: '/api/features', desc: 'List registered feature flags with current enabled state', handler: handleFeaturesList },
|
|
10183
10406
|
{ method: 'POST', path: '/api/features/toggle', desc: 'Enable/disable a registered feature flag', params: 'id, enabled', handler: handleFeaturesToggle },
|
|
10407
|
+
|
|
10408
|
+
// Diagnostics — refresh ring buffer persistence (W-mphejzx100081972).
|
|
10409
|
+
{ method: 'POST', path: '/api/diagnostics/refresh', desc: 'Append a dashboard refresh-diagnostic ring buffer batch to engine/dashboard-diagnostics.log (rotated at 1 MB)', params: 'entries[]', handler: handleDiagnosticsRefresh },
|
|
10184
10410
|
];
|
|
10185
10411
|
|
|
10186
10412
|
// ── Route Dispatcher ────────────────────────────────────────────────────────
|
package/engine/features.js
CHANGED
|
@@ -53,6 +53,20 @@ const FEATURES = {
|
|
|
53
53
|
addedIn: '0.1.1916',
|
|
54
54
|
requiredCcRuntime: 'copilot',
|
|
55
55
|
},
|
|
56
|
+
// dashboard-refresh-diagnostics — W-mphejzx100081972. Enables the in-browser
|
|
57
|
+
// /api/status poll telemetry ring buffer in dashboard/js/refresh.js: per-cycle
|
|
58
|
+
// ETag/status/bytes/render-duration capture, visibility-transition tracking,
|
|
59
|
+
// a small "diag" footer chip that opens a table modal, and a "send to engine"
|
|
60
|
+
// button that POSTs the buffer to /api/diagnostics/refresh for offline triage.
|
|
61
|
+
// Default OFF in production; flip ON in dev (or via env-var override) before
|
|
62
|
+
// reproducing a "didn't auto-update" complaint so the telemetry is captured.
|
|
63
|
+
// Disabled => the poll loop is byte-identical to its pre-flag steady state
|
|
64
|
+
// (the diagnostic helpers no-op via _isRefreshDiagOn() at the top of refresh()).
|
|
65
|
+
'dashboard-refresh-diagnostics': {
|
|
66
|
+
description: 'Capture the last 50 dashboard /api/status poll cycles (timing, ETag, status code, render duration, per-renderer change flags) in a browser-side ring buffer accessible via window._refreshDiagnostics, a footer "diag" chip, and POST /api/diagnostics/refresh.',
|
|
67
|
+
default: false,
|
|
68
|
+
addedIn: '0.1.2034',
|
|
69
|
+
},
|
|
56
70
|
};
|
|
57
71
|
|
|
58
72
|
const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
|
package/engine/queries.js
CHANGED
|
@@ -2079,25 +2079,43 @@ function getStatusFastStateMtimePaths(config) {
|
|
|
2079
2079
|
* - Per-project paths must use `shared.project*` so newly-added projects
|
|
2080
2080
|
* are picked up without registry edits.
|
|
2081
2081
|
*
|
|
2082
|
+
* Skill and MCP source paths (W-mphfdgwv000bf549):
|
|
2083
|
+
* - Skill discovery roots (`~/.claude/skills`, `~/.copilot/skills`,
|
|
2084
|
+
* `~/.agents/skills`, `<project>/.claude/skills`, `<project>/.github/skills`,
|
|
2085
|
+
* `<project>/.agents/skills`) plus plugin registries are tracked here
|
|
2086
|
+
* because manual `SKILL.md` drops bypass the agent-close
|
|
2087
|
+
* `invalidateStatusCache({includeSlow:true})` path that previously
|
|
2088
|
+
* covered the agent-extraction case. Directory mtime advances reliably
|
|
2089
|
+
* on Windows NTFS when a subdirectory is added/removed (verified via
|
|
2090
|
+
* INBOX_DIR). Tracking the user-home dirs means non-Minions skill
|
|
2091
|
+
* installs on this machine also bust the fleet's slow-state — but skill
|
|
2092
|
+
* dirs only mutate on rare events (install / manual edit), not on every
|
|
2093
|
+
* CLI command, so the steady-state noise is negligible.
|
|
2094
|
+
* - MCP config files (`~/.claude.json`, `~/.copilot/mcp-config.json`,
|
|
2095
|
+
* `<project>/.mcp.json`) feed `getMcpServers()`. `~/.claude.json`
|
|
2096
|
+
* stores more than `mcpServers`, but it only flips on intentional
|
|
2097
|
+
* Claude CLI mutations (mcp add/remove, settings edits), not on every
|
|
2098
|
+
* prompt, so whole-file tracking is acceptable.
|
|
2099
|
+
*
|
|
2082
2100
|
* Files intentionally NOT tracked here:
|
|
2083
|
-
* -
|
|
2084
|
-
*
|
|
2085
|
-
* 60 s TTL.
|
|
2086
|
-
* - `~/.claude/skills/`, `~/.copilot/skills/`, `<project>/.claude/skills/`,
|
|
2087
|
-
* `<project>/.github/skills/` — `extractSkillsFromOutput` writes here
|
|
2088
|
-
* from the agent-close path, which already calls
|
|
2089
|
-
* `invalidateStatusCache({includeSlow: true})` directly. Tracking the
|
|
2090
|
-
* user-home dir is additionally harmful because it's shared with every
|
|
2091
|
-
* Claude Code session on the machine; non-Minions activity would
|
|
2092
|
-
* otherwise bust this fleet's dashboard cache.
|
|
2101
|
+
* - version, autoMode, installId — change only on human/CLI edits, which
|
|
2102
|
+
* already pop the slow-state via reloadConfig + the 60 s TTL.
|
|
2093
2103
|
* - project git state — already invalidated via the
|
|
2094
2104
|
* `_setOnProjectGitStatusChanged` callback into `invalidateStatusCache`
|
|
2095
2105
|
* (W-mpgrk5cy fix); also tracked in fast-state via `.git/logs/HEAD`.
|
|
2106
|
+
*
|
|
2107
|
+
* NOTE: Detecting a change here busts the dashboard's slow-state cache, but
|
|
2108
|
+
* the inner per-source caches (`queries._skillsCache` 30 s, dashboard's
|
|
2109
|
+
* `_mcpServersCache` 5 min) survive across `_buildStatusSlowState()` calls.
|
|
2110
|
+
* dashboard.js calls `queries.invalidateSkillsCache()` and resets
|
|
2111
|
+
* `_mcpServersCache` whenever this tracker fires, so the rebuild reads
|
|
2112
|
+
* fresh disk state. Keep that invalidation wired up if you add new sources.
|
|
2096
2113
|
*/
|
|
2097
|
-
function getStatusSlowStateMtimePaths(
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2114
|
+
function getStatusSlowStateMtimePaths(config) {
|
|
2115
|
+
config = config || getConfig();
|
|
2116
|
+
const projects = getProjects(config);
|
|
2117
|
+
const homeDir = os.homedir();
|
|
2118
|
+
const files = [
|
|
2101
2119
|
// prd/*.json (surfaced by getPrdInfo) — engine writes via syncPrdFromPrs,
|
|
2102
2120
|
// the materializer, and plan-to-prd outputs.
|
|
2103
2121
|
PRD_DIR,
|
|
@@ -2117,6 +2135,42 @@ function getStatusSlowStateMtimePaths(_config) {
|
|
|
2117
2135
|
// CLI/editor edit that bypasses the API.
|
|
2118
2136
|
path.join(MINIONS_DIR, 'pinned.md'),
|
|
2119
2137
|
];
|
|
2138
|
+
|
|
2139
|
+
// Skill discovery roots (surfaced by _buildStatusSlowState → getSkills).
|
|
2140
|
+
// Mirrors collectSkillFiles' source enumeration so adding a new runtime
|
|
2141
|
+
// adapter automatically extends the tracker. ENOENT is tolerated by
|
|
2142
|
+
// dashboard._statMtimeMs (returns 0), so absent dirs cost nothing.
|
|
2143
|
+
try {
|
|
2144
|
+
const { listRuntimes, resolveRuntime } = require('./runtimes');
|
|
2145
|
+
for (const runtimeName of listRuntimes()) {
|
|
2146
|
+
const runtime = resolveRuntime(runtimeName);
|
|
2147
|
+
if (typeof runtime.getSkillRoots !== 'function') continue;
|
|
2148
|
+
for (const root of runtime.getSkillRoots({ homeDir })) {
|
|
2149
|
+
if (root && root.dir) files.push(root.dir);
|
|
2150
|
+
}
|
|
2151
|
+
for (const project of projects) {
|
|
2152
|
+
if (!project || !project.localPath) continue;
|
|
2153
|
+
for (const root of runtime.getSkillRoots({ homeDir, project })) {
|
|
2154
|
+
if (root && root.dir) files.push(root.dir);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
} catch { /* runtime registry optional in partial installs */ }
|
|
2159
|
+
|
|
2160
|
+
// Plugin skill registries (also feed collectSkillFiles).
|
|
2161
|
+
files.push(path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'));
|
|
2162
|
+
files.push(path.join(homeDir, '.copilot', 'installed-plugins'));
|
|
2163
|
+
|
|
2164
|
+
// MCP server config files (surfaced by _buildStatusSlowState → getMcpServers).
|
|
2165
|
+
files.push(path.join(homeDir, '.claude.json'));
|
|
2166
|
+
files.push(path.join(homeDir, '.copilot', 'mcp-config.json'));
|
|
2167
|
+
for (const project of projects) {
|
|
2168
|
+
if (project && project.localPath) {
|
|
2169
|
+
files.push(path.join(project.localPath, '.mcp.json'));
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
return files;
|
|
2120
2174
|
}
|
|
2121
2175
|
|
|
2122
2176
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
package/engine/shared.js
CHANGED
|
@@ -2032,6 +2032,15 @@ const ENGINE_DEFAULTS = {
|
|
|
2032
2032
|
// Settings UI exposes this as a free-text input; clearing the field deletes
|
|
2033
2033
|
// the override and falls back to auto-resolution.
|
|
2034
2034
|
operatorLogin: null,
|
|
2035
|
+
// ── /api/status workItems retention (W-mphejzmj000718bf) ────────────────────
|
|
2036
|
+
// Trim done/failed/cancelled work items older than this many days from the
|
|
2037
|
+
// /api/status workItems slice to cut the SPA payload (live: 796 items / 3MB
|
|
2038
|
+
// → ~50–100 items / <500KB typical). Active items (pending/dispatched/queued)
|
|
2039
|
+
// are ALWAYS shipped regardless of age — only terminal items past the
|
|
2040
|
+
// window are dropped. The detail modal fetches the full record on demand
|
|
2041
|
+
// via GET /api/work-items/<id> when description/references/AC are needed.
|
|
2042
|
+
// 0 disables the trim (full list shipped, restoring legacy behavior).
|
|
2043
|
+
statusWorkItemsRetentionDays: 7,
|
|
2035
2044
|
};
|
|
2036
2045
|
|
|
2037
2046
|
// ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2038",
|
|
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"
|