@yemi33/minions 0.1.2062 → 0.1.2064
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/modal.js +0 -5
- package/dashboard/js/render-agents.js +24 -39
- package/dashboard/js/render-work-items.js +6 -52
- package/dashboard.js +107 -183
- package/package.json +1 -1
package/dashboard/js/modal.js
CHANGED
|
@@ -4,11 +4,6 @@ function closeModal() {
|
|
|
4
4
|
const modalEl = document.querySelector('#modal .modal');
|
|
5
5
|
if (modalEl) modalEl.classList.remove('modal-wide');
|
|
6
6
|
document.getElementById('modal').classList.remove('open');
|
|
7
|
-
// Clear the WI-detail auto-refresh tracker so renderWorkItems stops
|
|
8
|
-
// rewriting #modal-body each tick. Safe to do unconditionally — if the
|
|
9
|
-
// closed modal wasn't a WI detail, these vars were already null.
|
|
10
|
-
if (typeof _wiModalOpenId !== 'undefined') { _wiModalOpenId = null; }
|
|
11
|
-
if (typeof _wiModalHydratedFields !== 'undefined') { _wiModalHydratedFields = null; }
|
|
12
7
|
clearModalBackStack();
|
|
13
8
|
// Hide Q&A section (only shown for document modals)
|
|
14
9
|
document.getElementById('modal-qa').style.display = 'none';
|
|
@@ -69,18 +69,24 @@ function renderAgents(agents) {
|
|
|
69
69
|
`).join('');
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
async function openAgentDetail(id) {
|
|
73
|
+
const agent = agentData.find(a => a.id === id);
|
|
74
|
+
if (!agent) return;
|
|
75
|
+
currentAgentId = id;
|
|
76
|
+
currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
|
|
77
|
+
|
|
78
|
+
// SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
|
|
79
|
+
// Emoji, name and role are all user-controlled fields; routing them through textContent
|
|
80
|
+
// guarantees no HTML interpretation even if the escape function were ever bypassed.
|
|
79
81
|
const nameEl = document.getElementById('detail-agent-name');
|
|
80
|
-
if (!nameEl) return;
|
|
81
82
|
const emojiSpan = document.createElement('span');
|
|
82
83
|
emojiSpan.style.fontSize = '22px';
|
|
83
84
|
emojiSpan.textContent = agent.emoji || '';
|
|
85
|
+
// Runtime tag \u2014 uses the inline-SVG logo from the same RUNTIME_TAGS map the
|
|
86
|
+
// card uses, so the visual is consistent. The container's user-controlled
|
|
87
|
+
// text fields stay on the textContent path; the SVG is a hardcoded literal
|
|
88
|
+
// from RUNTIME_TAGS keyed by the runtime string (server-controlled, finite
|
|
89
|
+
// set), so injecting via innerHTML on the icon-only span is safe.
|
|
84
90
|
const runtimeMeta = RUNTIME_TAGS[agent.runtime];
|
|
85
91
|
const runtimeSpan = document.createElement('span');
|
|
86
92
|
runtimeSpan.title = 'Runtime: ' + (runtimeMeta?.label || agent.runtime || 'unknown');
|
|
@@ -94,6 +100,9 @@ function _renderAgentDetailHeader(agent) {
|
|
|
94
100
|
runtimeSpan.style.cssText += ';font-size:10px;font-weight:600;letter-spacing:0.4px;text-transform:uppercase;padding:2px 6px;border:1px solid var(--muted);border-radius:3px;color:var(--muted)';
|
|
95
101
|
runtimeSpan.textContent = agent.runtime || 'unknown';
|
|
96
102
|
}
|
|
103
|
+
// W-mpmwxk4y00053271 — mirror the model chip the card shows so the detail
|
|
104
|
+
// header stays in sync. textContent path keeps the model string from being
|
|
105
|
+
// interpreted as HTML.
|
|
97
106
|
const modelSpan = (agent.model && typeof agent.model === 'string')
|
|
98
107
|
? (() => {
|
|
99
108
|
const s = document.createElement('span');
|
|
@@ -112,27 +121,12 @@ function _renderAgentDetailHeader(agent) {
|
|
|
112
121
|
nameEl.replaceChildren(...children);
|
|
113
122
|
|
|
114
123
|
const badgeClass = agent.status;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
(agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">⏳ Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') — silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
|
|
122
|
-
(agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function openAgentDetail(id) {
|
|
127
|
-
const agent = agentData.find(a => a.id === id);
|
|
128
|
-
if (!agent) return;
|
|
129
|
-
currentAgentId = id;
|
|
130
|
-
currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
|
|
131
|
-
|
|
132
|
-
// SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
|
|
133
|
-
// Emoji, name and role are all user-controlled fields; routing them through textContent
|
|
134
|
-
// guarantees no HTML interpretation even if the escape function were ever bypassed.
|
|
135
|
-
_renderAgentDetailHeader(agent);
|
|
124
|
+
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; status is an internal bounded enum and all user data is wrapped in escapeHtml()/renderMd() (fields: lastAction, blocking tool, resultSummary)
|
|
125
|
+
document.getElementById('detail-status-line').innerHTML =
|
|
126
|
+
'<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
|
|
127
|
+
'<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
|
|
128
|
+
(agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">⏳ Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') — silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
|
|
129
|
+
(agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
|
|
136
130
|
|
|
137
131
|
// Show panel immediately with loading state — don't wait for API
|
|
138
132
|
document.getElementById('detail-content').innerHTML = '<div style="padding:24px;text-align:center;color:var(--muted)">Loading...</div>';
|
|
@@ -167,12 +161,7 @@ function _tickAgentRuntimes() {
|
|
|
167
161
|
el.textContent = 'Running: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
|
|
168
162
|
});
|
|
169
163
|
}
|
|
170
|
-
// Start ticker after each render if working agents exist
|
|
171
|
-
// open agent-detail panel header so status / lastAction / blocking-tool /
|
|
172
|
-
// resultSummary track the latest /api/status slice without re-fetching the
|
|
173
|
-
// expensive /api/agent/<id> charter/history/output payload. The body tabs
|
|
174
|
-
// (thought-process, live, output, etc.) are loaded once on open via
|
|
175
|
-
// openAgentDetail's safeFetch — they stay as-is until the user reopens.
|
|
164
|
+
// Start ticker after each render if working agents exist
|
|
176
165
|
var _origRenderAgents = renderAgents;
|
|
177
166
|
renderAgents = function(agents) {
|
|
178
167
|
_origRenderAgents(agents);
|
|
@@ -181,10 +170,6 @@ renderAgents = function(agents) {
|
|
|
181
170
|
_tickAgentRuntimes();
|
|
182
171
|
_agentRuntimeTimer = setInterval(_tickAgentRuntimes, 1000);
|
|
183
172
|
}
|
|
184
|
-
if (currentAgentId) {
|
|
185
|
-
var open = agents.find(function(a) { return a.id === currentAgentId; });
|
|
186
|
-
if (open) _renderAgentDetailHeader(open);
|
|
187
|
-
}
|
|
188
173
|
};
|
|
189
174
|
|
|
190
175
|
window.MinionsAgents = { renderAgents, openAgentDetail };
|
|
@@ -4,18 +4,6 @@ let allWorkItems = [];
|
|
|
4
4
|
let wiPage = 0;
|
|
5
5
|
const WI_PER_PAGE = 20;
|
|
6
6
|
|
|
7
|
-
// Track open WI detail modal so renderWorkItems can re-render its body
|
|
8
|
-
// every poll tick. Without this the modal stays frozen on the snapshot
|
|
9
|
-
// from the moment it was opened — status flips / agent assignments /
|
|
10
|
-
// PR link arrival are invisible until the user closes + reopens or
|
|
11
|
-
// hard-refreshes. Mirrors the same fix that drove the section-render
|
|
12
|
-
// gate sweep (8ad48509 / W-mpn7keq9000302c9). `_wiModalHydratedFields`
|
|
13
|
-
// caches the heavy free-text fields (description, acceptanceCriteria,
|
|
14
|
-
// references) loaded once by openWorkItemDetail's GET /api/work-items/<id>
|
|
15
|
-
// hydration call so per-tick re-renders don't lose them.
|
|
16
|
-
let _wiModalOpenId = null;
|
|
17
|
-
let _wiModalHydratedFields = null;
|
|
18
|
-
|
|
19
7
|
// Track retry state per work item so loading/success/error survives re-renders
|
|
20
8
|
const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
|
|
21
9
|
function setWiRetryState(id, state) { _wiRetryState[id] = state; }
|
|
@@ -167,27 +155,6 @@ function renderWorkItems(items) {
|
|
|
167
155
|
const newWrap = el.querySelector('.pr-table-wrap');
|
|
168
156
|
if (newWrap) newWrap.scrollLeft = savedScroll;
|
|
169
157
|
}
|
|
170
|
-
// Refresh the open WI detail modal in-place so its status badge,
|
|
171
|
-
// agent assignment, PR link, etc. reflect the latest /api/status slice.
|
|
172
|
-
// The heavy free-text fields (description, AC, references) live in
|
|
173
|
-
// `_wiModalHydratedFields` from the one-time GET /api/work-items/<id>
|
|
174
|
-
// hydration and survive across these re-renders.
|
|
175
|
-
if (_wiModalOpenId) {
|
|
176
|
-
const slim = items.find(i => i.id === _wiModalOpenId);
|
|
177
|
-
if (slim) {
|
|
178
|
-
const merged = Object.assign({}, _wiModalHydratedFields || {}, slim);
|
|
179
|
-
const body = document.getElementById('modal-body');
|
|
180
|
-
if (body) {
|
|
181
|
-
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail()
|
|
182
|
-
body.innerHTML = _wiRenderDetail(merged);
|
|
183
|
-
}
|
|
184
|
-
const title = document.getElementById('modal-title');
|
|
185
|
-
if (title) title.textContent = slim.title || slim.id;
|
|
186
|
-
}
|
|
187
|
-
// If the WI dropped out of the slim slice (deleted/archived), leave
|
|
188
|
-
// the modal as-is — the user will close it normally; we don't want
|
|
189
|
-
// to auto-dismiss in case they're still reading.
|
|
190
|
-
}
|
|
191
158
|
}
|
|
192
159
|
|
|
193
160
|
async function editWorkItem(id, source) {
|
|
@@ -682,15 +649,6 @@ function openWorkItemDetail(id) {
|
|
|
682
649
|
(cached.referencesCount > 0 && !Array.isArray(cached.references));
|
|
683
650
|
|
|
684
651
|
const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
|
|
685
|
-
// Track which WI's modal is open so renderWorkItems can re-render the
|
|
686
|
-
// modal body each poll tick. Reset the hydrated cache; openWorkItemDetail
|
|
687
|
-
// is the only authoritative source of the heavy free-text fields.
|
|
688
|
-
_wiModalOpenId = id;
|
|
689
|
-
_wiModalHydratedFields = needsHydration ? null : {
|
|
690
|
-
description: cached.description,
|
|
691
|
-
acceptanceCriteria: Array.isArray(cached.acceptanceCriteria) ? cached.acceptanceCriteria : undefined,
|
|
692
|
-
references: Array.isArray(cached.references) ? cached.references : undefined,
|
|
693
|
-
};
|
|
694
652
|
document.getElementById('modal-title').textContent = initial.title || initial.id;
|
|
695
653
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
|
|
696
654
|
document.getElementById('modal-body').innerHTML = _wiRenderDetail(initial);
|
|
@@ -704,22 +662,18 @@ function openWorkItemDetail(id) {
|
|
|
704
662
|
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
705
663
|
.then(function(data) {
|
|
706
664
|
// Guard against modal navigation away from this WI during the fetch.
|
|
707
|
-
|
|
665
|
+
var title = document.getElementById('modal-title');
|
|
666
|
+
if (!title || title.textContent !== (initial.title || initial.id)) return;
|
|
708
667
|
var full = data && data.item;
|
|
709
668
|
if (!full) return;
|
|
710
|
-
// Cache the heavy free-text fields so per-tick re-renders preserve
|
|
711
|
-
// them. We keep them separate from the slim slice (which renderWorkItems
|
|
712
|
-
// refreshes on every tick).
|
|
713
|
-
_wiModalHydratedFields = {
|
|
714
|
-
description: full.description || cached.description || '',
|
|
715
|
-
acceptanceCriteria: Array.isArray(full.acceptanceCriteria) ? full.acceptanceCriteria : undefined,
|
|
716
|
-
references: Array.isArray(full.references) ? full.references : undefined,
|
|
717
|
-
};
|
|
718
669
|
// Merge: cached cross-slice fields (_pr, _artifacts, etc.) WIN over
|
|
719
670
|
// the on-disk record so we don't lose engine enrichment that lives
|
|
720
671
|
// only on the in-memory pass. The full record contributes description,
|
|
721
672
|
// acceptanceCriteria, and references back to the rendered shape.
|
|
722
|
-
var merged = Object.assign({}, full, cached
|
|
673
|
+
var merged = Object.assign({}, full, cached);
|
|
674
|
+
merged.description = full.description || cached.description || '';
|
|
675
|
+
if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
|
|
676
|
+
if (Array.isArray(full.references)) merged.references = full.references;
|
|
723
677
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
|
|
724
678
|
document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
|
|
725
679
|
})
|
package/dashboard.js
CHANGED
|
@@ -1540,18 +1540,26 @@ function parsePinnedEntries(content) {
|
|
|
1540
1540
|
return entries;
|
|
1541
1541
|
}
|
|
1542
1542
|
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1543
|
+
// /api/status cache — single comprehensive layer.
|
|
1544
|
+
//
|
|
1545
|
+
// Pre-2026-05-27 the cache was split into fast (10s TTL) and slow (60s TTL)
|
|
1546
|
+
// tiers with separate mtime registries. Each layer was added for a real
|
|
1547
|
+
// reason (perf, staleness, race) but the result was 6 caches disagreeing
|
|
1548
|
+
// about freshness — every "I had to hard refresh" bug we shipped fixes for
|
|
1549
|
+
// (agents, workItems, prdProgress, …) came from a layer not knowing about
|
|
1550
|
+
// a change that another layer had already noticed.
|
|
1551
|
+
//
|
|
1552
|
+
// New model: ONE cache, ONE comprehensive mtime registry (union of the
|
|
1553
|
+
// former fast+slow registries via queries.getStatus{Fast,Slow}StateMtimePaths).
|
|
1554
|
+
// Steady state = single mtime check. Any file change → cache invalidated →
|
|
1555
|
+
// full rebuild on the next call → fresh response on the next /api/status
|
|
1556
|
+
// poll, always. No TTL fallback — the mtime registry is the source of
|
|
1557
|
+
// truth; missing a file there is a bug to fix, not a cost to amortize.
|
|
1558
|
+
//
|
|
1559
|
+
// _buildStatusFastState + _buildStatusSlowState stay as organizational
|
|
1560
|
+
// helpers below so the build code stays grouped + the async path can yield
|
|
1561
|
+
// the event loop between them (CC SSE heartbeats keep flowing during the
|
|
1562
|
+
// slower slice).
|
|
1555
1563
|
const ENGINE_HEARTBEAT_STALE_MS = 120000;
|
|
1556
1564
|
let _statusCache = null;
|
|
1557
1565
|
let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
|
|
@@ -1601,27 +1609,33 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
|
|
|
1601
1609
|
return false;
|
|
1602
1610
|
}
|
|
1603
1611
|
|
|
1604
|
-
// mtime-based cache invalidation
|
|
1612
|
+
// mtime-based cache invalidation.
|
|
1605
1613
|
//
|
|
1606
1614
|
// Engine and dashboard are independent processes; `invalidateStatusCache()`
|
|
1607
|
-
// lives in dashboard.js memory and is unreachable from engine code.
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
// is rebuilt and `_statusCacheVersion` bumps (which busts the ETag, so the
|
|
1613
|
-
// next /api/status poll sees a 200 + fresh body instead of a 304).
|
|
1615
|
+
// lives in dashboard.js memory and is unreachable from engine code. We
|
|
1616
|
+
// detect engine-side state flips (WI pending→done, PR status changes,
|
|
1617
|
+
// dispatch.json mutations, PRD progress derived from work-items, …) by
|
|
1618
|
+
// statSync'ing a registry of input files on every status request. Any
|
|
1619
|
+
// mtime advance → cache busted → fresh rebuild on the same poll.
|
|
1614
1620
|
//
|
|
1615
|
-
// The
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1621
|
+
// The two source-of-truth lists live in `engine/queries.js`:
|
|
1622
|
+
// - getStatusFastStateMtimePaths — engine-driven hot writes (dispatch,
|
|
1623
|
+
// work-items, PR files, watches, qa runs, inbox, notes, meetings, …).
|
|
1624
|
+
// - getStatusSlowStateMtimePaths — slower-cadence writes (PRD JSON,
|
|
1625
|
+
// schedule/pipeline runs, pinned, skill discovery roots, MCP configs,
|
|
1626
|
+
// git refs, …).
|
|
1627
|
+
// We union them here. Adding a new tracked file means adding ONE line to
|
|
1628
|
+
// the appropriate list in queries.js — the dashboard side stays a thin
|
|
1629
|
+
// delegate that doesn't need to know about per-source semantics anymore.
|
|
1630
|
+
function _mtimeTrackedFiles() {
|
|
1631
|
+
const fast = queries.getStatusFastStateMtimePaths(CONFIG);
|
|
1632
|
+
const slow = queries.getStatusSlowStateMtimePaths(CONFIG);
|
|
1633
|
+
// Dedup with a Set — many paths (per-project work-items, git refs) appear
|
|
1634
|
+
// in both registries since both fast (workItems slice) and slow (PRD
|
|
1635
|
+
// progress derived from same data) read them.
|
|
1636
|
+
return Array.from(new Set([...fast, ...slow]));
|
|
1637
|
+
}
|
|
1638
|
+
let _lastMtimes = {}; // { filePath: mtimeMs } — baseline since last build
|
|
1625
1639
|
|
|
1626
1640
|
// Stat a tracked path with transient-error tolerance. ENOENT (file/dir doesn't
|
|
1627
1641
|
// exist) is normal — fresh installs, deleted projects, empty PRD dirs all hit
|
|
@@ -1648,23 +1662,12 @@ function _getMtimes() {
|
|
|
1648
1662
|
return result;
|
|
1649
1663
|
}
|
|
1650
1664
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
// Reset the per-source caches that outlive the slow-state TTL when a tracked
|
|
1660
|
-
// source file changes (W-mphfdgwv000bf549). The slow-state mtime tracker now
|
|
1661
|
-
// covers skill discovery dirs and MCP config files, but
|
|
1662
|
-
// `queries._skillsCache` (30 s) and the local `_mcpServersCache` (5 min)
|
|
1663
|
-
// would still serve stale data into `_buildStatusSlowState()` — defeating
|
|
1664
|
-
// the <4 s freshness goal. Only call this when an mtime delta is detected;
|
|
1665
|
-
// TTL-driven rebuilds keep using the inner caches so we don't pay disk-scan
|
|
1666
|
-
// cost on every 60 s slow-state rollover.
|
|
1667
|
-
function _invalidateSlowInnerCachesForMtimeChange() {
|
|
1665
|
+
// Reset the per-source caches whose TTL would otherwise serve stale data
|
|
1666
|
+
// across rebuilds. Skill files (`queries._skillsCache` 30s) and MCP servers
|
|
1667
|
+
// (`_mcpServersCache` 5min) are the two with TTLs longer than our poll
|
|
1668
|
+
// cadence, and both back slices the user sees on screen. Called on every
|
|
1669
|
+
// mtime-detected rebuild so the rebuild always reads fresh disk state.
|
|
1670
|
+
function _invalidateInnerCachesForRebuild() {
|
|
1668
1671
|
try { queries.invalidateSkillsCache(); } catch { /* optional */ }
|
|
1669
1672
|
_mcpServersCache = null;
|
|
1670
1673
|
_mcpServersCacheTs = 0;
|
|
@@ -1681,18 +1684,15 @@ function _mtimesChanged(prev, curr) {
|
|
|
1681
1684
|
return false;
|
|
1682
1685
|
}
|
|
1683
1686
|
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
if (opts && opts.includeSlow) {
|
|
1690
|
-
_slowState = null;
|
|
1691
|
-
_slowStateTs = 0;
|
|
1692
|
-
}
|
|
1687
|
+
// Flush the single status cache. `opts` is accepted (and ignored) for
|
|
1688
|
+
// backward-compat — callers used to pass `{ includeSlow: true }` when they
|
|
1689
|
+
// wanted to bust the slow tier; with a unified cache the option is moot.
|
|
1690
|
+
// Renamed to `_opts` so eslint stops warning about the unused param.
|
|
1691
|
+
function invalidateStatusCache(_opts) {
|
|
1693
1692
|
_statusCache = null;
|
|
1694
1693
|
_statusCacheJson = null;
|
|
1695
1694
|
_statusCacheGzip = null;
|
|
1695
|
+
_lastMtimes = {};
|
|
1696
1696
|
// Tell any in-flight refreshStatusAsync() that its result is stale and must
|
|
1697
1697
|
// not be published. Bumping the generation also forces the next ETag to
|
|
1698
1698
|
// differ from anything a client already has cached.
|
|
@@ -2098,64 +2098,26 @@ function _markStatusCacheBuilt() {
|
|
|
2098
2098
|
}
|
|
2099
2099
|
|
|
2100
2100
|
function getStatus() {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
//
|
|
2104
|
-
|
|
2105
|
-
if (!fastStale) {
|
|
2106
|
-
// Within TTL — check mtimes for early return (skip rebuild if no tracked files changed)
|
|
2101
|
+
// Steady-state fast path: cache present + no tracked file changed → return
|
|
2102
|
+
// cached snapshot. Single mtime check covers both fast + slow tracker
|
|
2103
|
+
// contributions (see _mtimeTrackedFiles).
|
|
2104
|
+
if (_statusCache) {
|
|
2107
2105
|
const currMtimes = _getMtimes();
|
|
2108
|
-
if (_mtimesChanged(_lastMtimes, currMtimes))
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
//
|
|
2112
|
-
|
|
2113
|
-
//
|
|
2114
|
-
//
|
|
2115
|
-
//
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
// If nothing stale, return cached merged result
|
|
2127
|
-
if (!fastStale && !slowStale && _statusCache) return _statusCache;
|
|
2128
|
-
|
|
2129
|
-
// Rebuild fast state (frequently-changing data: ~12-15 reads)
|
|
2130
|
-
if (fastStale) {
|
|
2131
|
-
// Reload config on fast-state miss — picks up external changes (minions init, minions add)
|
|
2132
|
-
reloadConfig();
|
|
2133
|
-
// Snapshot mtimes BEFORE the rebuild reads disk. If an engine write lands
|
|
2134
|
-
// mid-rebuild (dispatch.json, work-items.json, pull-requests.json), the
|
|
2135
|
-
// snapshot reflects pre-write state, but capturing _lastMtimes AFTER would
|
|
2136
|
-
// record the post-write mtime — silently masking the change. Subsequent
|
|
2137
|
-
// polls would then see no mtime delta and return 304 with stale data until
|
|
2138
|
-
// FAST_STATE_TTL expires. Capturing pre-build at worst forces one extra
|
|
2139
|
-
// (no-op) rebuild on the next poll; capturing post-build loses the update.
|
|
2140
|
-
const preBuildMtimes = _getMtimes();
|
|
2141
|
-
_fastState = _buildStatusFastState();
|
|
2142
|
-
_fastStateTs = now;
|
|
2143
|
-
_lastMtimes = preBuildMtimes;
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
// Rebuild slow state (rarely-changing data: ~8-15 reads, 60s TTL).
|
|
2147
|
-
// Same pre-build snapshot pattern as fast-state — capture mtimes BEFORE
|
|
2148
|
-
// disk reads so any write landing mid-build busts the next poll.
|
|
2149
|
-
if (slowStale) {
|
|
2150
|
-
if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
|
|
2151
|
-
const preBuildSlowMtimes = _getSlowMtimes();
|
|
2152
|
-
_slowState = _buildStatusSlowState();
|
|
2153
|
-
_slowStateTs = now;
|
|
2154
|
-
_lastSlowMtimes = preBuildSlowMtimes;
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
// Merge both tiers — no API contract change
|
|
2158
|
-
_statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
|
|
2106
|
+
if (!_mtimesChanged(_lastMtimes, currMtimes)) return _statusCache;
|
|
2107
|
+
}
|
|
2108
|
+
// Stale or first-call: rebuild everything. Reload config first so newly-
|
|
2109
|
+
// added projects / agents land before the slice builders read them.
|
|
2110
|
+
reloadConfig();
|
|
2111
|
+
// Snapshot mtimes BEFORE the rebuild reads disk. If an engine write lands
|
|
2112
|
+
// mid-rebuild, the post-build _getMtimes() would record the post-write
|
|
2113
|
+
// mtime — silently masking the change. Pre-build snapshot at worst forces
|
|
2114
|
+
// one extra (no-op) rebuild on the next poll; post-build loses the update.
|
|
2115
|
+
const preBuildMtimes = _getMtimes();
|
|
2116
|
+
_invalidateInnerCachesForRebuild();
|
|
2117
|
+
const fast = _buildStatusFastState();
|
|
2118
|
+
const slow = _buildStatusSlowState();
|
|
2119
|
+
_statusCache = { ...fast, ...slow, timestamp: new Date().toISOString() };
|
|
2120
|
+
_lastMtimes = preBuildMtimes;
|
|
2159
2121
|
_markStatusCacheBuilt();
|
|
2160
2122
|
return _statusCache;
|
|
2161
2123
|
}
|
|
@@ -2221,74 +2183,50 @@ function refreshStatusAsync() {
|
|
|
2221
2183
|
let fastBuildMs = 0;
|
|
2222
2184
|
let slowBuildMs = 0;
|
|
2223
2185
|
try {
|
|
2224
|
-
const now = Date.now();
|
|
2225
2186
|
const startGeneration = _statusInvalidationGeneration;
|
|
2226
2187
|
|
|
2227
|
-
|
|
2228
|
-
if (
|
|
2188
|
+
// Steady-state fast path — same single mtime check as the sync getStatus.
|
|
2189
|
+
if (_statusCache) {
|
|
2229
2190
|
const currMtimes = _getMtimes();
|
|
2230
|
-
if (_mtimesChanged(_lastMtimes, currMtimes))
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2191
|
+
if (!_mtimesChanged(_lastMtimes, currMtimes)) {
|
|
2192
|
+
if (profile) {
|
|
2193
|
+
_emitStatusTiming({
|
|
2194
|
+
phase: 'cache-hit',
|
|
2195
|
+
total: Number(process.hrtime.bigint() - tOverall) / 1e6,
|
|
2196
|
+
cacheV: _statusCacheVersion,
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
return _statusCache;
|
|
2239
2200
|
}
|
|
2240
2201
|
}
|
|
2241
2202
|
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
// `await _yieldEventLoop()` below — any write that lands between disk
|
|
2257
|
-
// reads and the post-build `_getMtimes()` capture would be lost.
|
|
2258
|
-
let preBuildMtimes = null;
|
|
2259
|
-
if (fastStale) {
|
|
2260
|
-
reloadConfig();
|
|
2261
|
-
preBuildMtimes = _getMtimes();
|
|
2262
|
-
const tFast = profile ? process.hrtime.bigint() : null;
|
|
2263
|
-
fast = _buildStatusFastState();
|
|
2264
|
-
if (profile) fastBuildMs = Number(process.hrtime.bigint() - tFast) / 1e6;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
// Unconditional cooperative yield between phases — guarantees the event
|
|
2268
|
-
// loop is available to SSE heartbeats / other I/O at least once mid-rebuild
|
|
2269
|
-
// regardless of whether the test hook is installed. Combined with the
|
|
2270
|
-
// optional async hook below, also lets stress tests inject artificial
|
|
2271
|
-
// delay to simulate a slow filesystem without freezing the loop.
|
|
2203
|
+
// Stale or first-call → rebuild. Reload config first so newly-added
|
|
2204
|
+
// projects/agents land before the slice builders see them. Pre-build
|
|
2205
|
+
// mtime snapshot (rationale: a write that lands mid-rebuild would be
|
|
2206
|
+
// silently masked if we snapshotted post-build).
|
|
2207
|
+
reloadConfig();
|
|
2208
|
+
const preBuildMtimes = _getMtimes();
|
|
2209
|
+
_invalidateInnerCachesForRebuild();
|
|
2210
|
+
const tFast = profile ? process.hrtime.bigint() : null;
|
|
2211
|
+
const fast = _buildStatusFastState();
|
|
2212
|
+
if (profile) fastBuildMs = Number(process.hrtime.bigint() - tFast) / 1e6;
|
|
2213
|
+
|
|
2214
|
+
// Cooperative yield between the fast + slow halves — guarantees the
|
|
2215
|
+
// event loop is available to SSE heartbeats / other I/O at least once
|
|
2216
|
+
// mid-rebuild regardless of whether the test hook is installed.
|
|
2272
2217
|
await _yieldEventLoop();
|
|
2273
2218
|
if (typeof _statusRefreshHook === 'function') {
|
|
2274
2219
|
try { await _statusRefreshHook(); } catch { /* hook errors must not break rebuild */ }
|
|
2275
2220
|
}
|
|
2276
2221
|
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
if (
|
|
2280
|
-
if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
|
|
2281
|
-
preBuildSlowMtimes = _getSlowMtimes();
|
|
2282
|
-
const tSlow = profile ? process.hrtime.bigint() : null;
|
|
2283
|
-
slow = _buildStatusSlowState();
|
|
2284
|
-
if (profile) slowBuildMs = Number(process.hrtime.bigint() - tSlow) / 1e6;
|
|
2285
|
-
}
|
|
2222
|
+
const tSlow = profile ? process.hrtime.bigint() : null;
|
|
2223
|
+
const slow = _buildStatusSlowState();
|
|
2224
|
+
if (profile) slowBuildMs = Number(process.hrtime.bigint() - tSlow) / 1e6;
|
|
2286
2225
|
|
|
2287
2226
|
// Invalidation-race guard: if an invalidation fired during the await
|
|
2288
2227
|
// window, this rebuild was based on potentially stale signals — drop
|
|
2289
2228
|
// the result silently. _statusCache stays as invalidated (null) so the
|
|
2290
|
-
// next sync getStatus() OR async refreshStatusAsync() rebuilds fresh
|
|
2291
|
-
// against the post-invalidate generation.
|
|
2229
|
+
// next sync getStatus() OR async refreshStatusAsync() rebuilds fresh.
|
|
2292
2230
|
if (_statusInvalidationGeneration !== startGeneration) {
|
|
2293
2231
|
if (profile) {
|
|
2294
2232
|
_emitStatusTiming({
|
|
@@ -2301,24 +2239,15 @@ function refreshStatusAsync() {
|
|
|
2301
2239
|
return _statusCache;
|
|
2302
2240
|
}
|
|
2303
2241
|
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
_fastStateTs = now;
|
|
2307
|
-
_lastMtimes = preBuildMtimes;
|
|
2308
|
-
}
|
|
2309
|
-
if (slowStale) {
|
|
2310
|
-
_slowState = slow;
|
|
2311
|
-
_slowStateTs = now;
|
|
2312
|
-
_lastSlowMtimes = preBuildSlowMtimes;
|
|
2313
|
-
}
|
|
2314
|
-
_statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
|
|
2242
|
+
_statusCache = { ...fast, ...slow, timestamp: new Date().toISOString() };
|
|
2243
|
+
_lastMtimes = preBuildMtimes;
|
|
2315
2244
|
_markStatusCacheBuilt();
|
|
2316
2245
|
if (profile) {
|
|
2317
2246
|
_emitStatusTiming({
|
|
2318
2247
|
phase: 'rebuilt',
|
|
2319
2248
|
fastBuild: fastBuildMs, slowBuild: slowBuildMs,
|
|
2320
2249
|
total: Number(process.hrtime.bigint() - tOverall) / 1e6,
|
|
2321
|
-
|
|
2250
|
+
cacheV: _statusCacheVersion,
|
|
2322
2251
|
});
|
|
2323
2252
|
}
|
|
2324
2253
|
return _statusCache;
|
|
@@ -2340,10 +2269,6 @@ function _getStatusCacheVersion() {
|
|
|
2340
2269
|
return _statusCacheVersion;
|
|
2341
2270
|
}
|
|
2342
2271
|
function _resetStatusCacheForTesting() {
|
|
2343
|
-
_fastState = null;
|
|
2344
|
-
_fastStateTs = 0;
|
|
2345
|
-
_slowState = null;
|
|
2346
|
-
_slowStateTs = 0;
|
|
2347
2272
|
_statusCache = null;
|
|
2348
2273
|
_statusCacheJson = null;
|
|
2349
2274
|
_statusCacheGzip = null;
|
|
@@ -2352,7 +2277,6 @@ function _resetStatusCacheForTesting() {
|
|
|
2352
2277
|
_statusInvalidationGeneration = 0;
|
|
2353
2278
|
_statusRefreshHook = null;
|
|
2354
2279
|
_lastMtimes = {};
|
|
2355
|
-
_lastSlowMtimes = {};
|
|
2356
2280
|
}
|
|
2357
2281
|
|
|
2358
2282
|
/** Return cached JSON string of status — single stringify, reused by SSE and /api/status */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2064",
|
|
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"
|