@yemi33/minions 0.1.2037 → 0.1.2039
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 +256 -5
- package/dashboard/js/render-work-items.js +122 -15
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +203 -1
- package/engine/features.js +18 -0
- 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
|
|
|
@@ -320,9 +333,78 @@ let _lastStatusData = null;
|
|
|
320
333
|
// when the first hasn't finished — the next interval fire picks up the
|
|
321
334
|
// fresh state anyway.
|
|
322
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
|
+
|
|
323
385
|
async function refresh() {
|
|
324
386
|
if (_refreshInFlight) return;
|
|
325
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;
|
|
326
408
|
try {
|
|
327
409
|
const headers = {};
|
|
328
410
|
if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
|
|
@@ -331,6 +413,10 @@ async function refresh() {
|
|
|
331
413
|
if (res.status === 304 && _lastStatusData) {
|
|
332
414
|
// Cache hit — reuse last payload, skip parsing entirely.
|
|
333
415
|
data = _lastStatusData;
|
|
416
|
+
if (_diagEntry) {
|
|
417
|
+
_diagEntry.response_status = '304';
|
|
418
|
+
_diagEntry.bytes_received = 0;
|
|
419
|
+
}
|
|
334
420
|
} else {
|
|
335
421
|
data = await res.json();
|
|
336
422
|
const etag = res.headers && (res.headers.get ? res.headers.get('etag') : null);
|
|
@@ -338,6 +424,12 @@ async function refresh() {
|
|
|
338
424
|
_lastStatusEtag = etag;
|
|
339
425
|
_lastStatusData = data;
|
|
340
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
|
+
}
|
|
341
433
|
}
|
|
342
434
|
// Auto-reload when dashboard restarts (stale connections cause "Failed to fetch" on CC/doc-chat)
|
|
343
435
|
const dashId = (data.version && data.version.dashboardStartedAt) || null;
|
|
@@ -357,9 +449,37 @@ async function refresh() {
|
|
|
357
449
|
return;
|
|
358
450
|
}
|
|
359
451
|
if (buildId) _knownDashboardBuildId = buildId;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
}
|
|
363
483
|
}
|
|
364
484
|
|
|
365
485
|
refresh();
|
|
@@ -375,3 +495,134 @@ document.querySelectorAll('.sidebar-link').forEach(link => {
|
|
|
375
495
|
switchPage(currentPage);
|
|
376
496
|
|
|
377
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
CHANGED
|
@@ -1708,7 +1708,7 @@ function _buildStatusFastState() {
|
|
|
1708
1708
|
dispatch: getDispatchQueue(),
|
|
1709
1709
|
engineLog: getEngineLog(),
|
|
1710
1710
|
metrics: getMetrics(),
|
|
1711
|
-
workItems: getWorkItems(),
|
|
1711
|
+
workItems: _slimWorkItemsForStatus(getWorkItems()),
|
|
1712
1712
|
watches: watchesMod.getWatches(),
|
|
1713
1713
|
meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
|
|
1714
1714
|
// QA runs — surfaced for the sidebar activity-dot counter and any future
|
|
@@ -1745,6 +1745,105 @@ function _safeStatusSlice(name, fn, fallback) {
|
|
|
1745
1745
|
}
|
|
1746
1746
|
}
|
|
1747
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
|
+
|
|
1748
1847
|
// Build the slow-state slice (rarely-changing data: ~60s TTL).
|
|
1749
1848
|
function _buildStatusSlowState() {
|
|
1750
1849
|
const prdInfo = getPrdInfo();
|
|
@@ -1812,6 +1911,13 @@ function _buildStatusSlowState() {
|
|
|
1812
1911
|
// on the client (R3, W-mpgb0xgc000hf1d3). Quotes stripped from the
|
|
1813
1912
|
// weak/strong ETag form so consumers see a bare hex digest.
|
|
1814
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,
|
|
1815
1921
|
disk: diskVersion,
|
|
1816
1922
|
diskCommit,
|
|
1817
1923
|
engineStale,
|
|
@@ -4821,6 +4927,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
4821
4927
|
} catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
4822
4928
|
}
|
|
4823
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
|
+
|
|
4824
4955
|
async function handleWorkItemsReopen(req, res) {
|
|
4825
4956
|
try {
|
|
4826
4957
|
const body = await readBody(req);
|
|
@@ -8392,6 +8523,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8392
8523
|
}
|
|
8393
8524
|
// String fields
|
|
8394
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
|
+
}
|
|
8395
8541
|
// W-mpejf0fq000e84d6: operator login override. Empty string clears
|
|
8396
8542
|
// the override (engine falls back to gh/git/os resolution); any other
|
|
8397
8543
|
// value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
|
|
@@ -8723,6 +8869,52 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8723
8869
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
8724
8870
|
}
|
|
8725
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
|
+
|
|
8726
8918
|
// Slim UX surface for the experimental redesigned dashboard.
|
|
8727
8919
|
// The HTML lives in dashboard/slim.html so the human can iterate on the
|
|
8728
8920
|
// markup directly — we read the file from disk on each request (no in-
|
|
@@ -9593,6 +9785,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9593
9785
|
invalidateStatusCache();
|
|
9594
9786
|
return jsonReply(res, 200, { ok: true });
|
|
9595
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 },
|
|
9596
9795
|
|
|
9597
9796
|
// Pinned notes
|
|
9598
9797
|
{ method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
|
|
@@ -10205,6 +10404,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10205
10404
|
// Feature flags (experimental / in-progress UX gates — see engine/features.js)
|
|
10206
10405
|
{ method: 'GET', path: '/api/features', desc: 'List registered feature flags with current enabled state', handler: handleFeaturesList },
|
|
10207
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 },
|
|
10208
10410
|
];
|
|
10209
10411
|
|
|
10210
10412
|
// ── Route Dispatcher ────────────────────────────────────────────────────────
|
package/engine/features.js
CHANGED
|
@@ -53,6 +53,24 @@ 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 ON (W-mphlr4lv0008c24f) — the diagnostic capture is cheap (in-memory
|
|
62
|
+
// ring buffer, no network, no DOM writes) and is gated by _isRefreshDiagOn()
|
|
63
|
+
// at the top of refresh() so the disabled path remains byte-identical to the
|
|
64
|
+
// pre-flag steady state. Having it always-on means the next staleness
|
|
65
|
+
// complaint can be diagnosed immediately from window._refreshDiagnostics
|
|
66
|
+
// without needing a settings flip first.
|
|
67
|
+
// Disable via config.features['dashboard-refresh-diagnostics']: false or
|
|
68
|
+
// env MINIONS_FEATURE_DASHBOARD_REFRESH_DIAGNOSTICS=0.
|
|
69
|
+
'dashboard-refresh-diagnostics': {
|
|
70
|
+
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.',
|
|
71
|
+
default: true,
|
|
72
|
+
addedIn: '0.1.2034',
|
|
73
|
+
},
|
|
56
74
|
};
|
|
57
75
|
|
|
58
76
|
const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
|
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.2039",
|
|
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"
|