@yemi33/minions 0.1.2449 → 0.1.2451
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/js/render-inbox.js +50 -13
- package/dashboard/js/render-work-items.js +8 -4
- package/dashboard.js +31 -7
- package/engine/core/queries.js +83 -34
- package/package.json +1 -1
|
@@ -19,13 +19,44 @@ function _timeSinceMs(mtimeMs) {
|
|
|
19
19
|
return days + 'd ago';
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Per-file content cache keyed by name → { mtimeMs, content }. The inbox can
|
|
23
|
+
// hold thousands of notes; without a cache every 4 s refresh re-fetched EVERY
|
|
24
|
+
// file's body. Firing that many fetches at once exhausted the browser socket
|
|
25
|
+
// pool and flooded the console with `net::ERR_INSUFFICIENT_RESOURCES` on every
|
|
26
|
+
// page (the refresh loop runs regardless of the active page). Caching by
|
|
27
|
+
// (name, mtimeMs) means steady-state refreshes re-fetch nothing — only new or
|
|
28
|
+
// edited notes are pulled — and the pool below bounds the initial load so the
|
|
29
|
+
// socket pool is never saturated. (W-mscjlch1005qdc11)
|
|
30
|
+
const _inboxContentCache = new Map();
|
|
31
|
+
// Max concurrent /state/notes/inbox/<file> fetches. Chrome caps same-host HTTP/1
|
|
32
|
+
// connections at 6; 8 keeps the pipe full without tripping ERR_INSUFFICIENT_RESOURCES.
|
|
33
|
+
const INBOX_FETCH_CONCURRENCY = 8;
|
|
34
|
+
|
|
35
|
+
// Run async `worker(item)` over `items` with at most `limit` in flight at once.
|
|
36
|
+
// Preserves input order in the returned results array. Used to bound the inbox
|
|
37
|
+
// per-file content fetches so a large inbox can't exhaust the browser's socket pool.
|
|
38
|
+
async function _mapWithConcurrency(items, limit, worker) {
|
|
39
|
+
const results = new Array(items.length);
|
|
40
|
+
let next = 0;
|
|
41
|
+
async function run() {
|
|
42
|
+
while (next < items.length) {
|
|
43
|
+
const i = next++;
|
|
44
|
+
results[i] = await worker(items[i], i);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const pool = [];
|
|
48
|
+
for (let i = 0; i < Math.min(limit, items.length); i++) pool.push(run());
|
|
49
|
+
await Promise.all(pool);
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
// Fetch the inbox via the /state/notes/inbox directory listing + per-file
|
|
23
54
|
// content fetch. The listing call returns {entries:[{name, mtimeMs, size,
|
|
24
|
-
// isDir}]} and is cheap (one statSync per entry).
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// content edits within one 4 s poll.
|
|
55
|
+
// isDir}]} and is cheap (one statSync per entry). Per-file bodies are fetched
|
|
56
|
+
// through a bounded-concurrency pool and cached by (name, mtimeMs), so unchanged
|
|
57
|
+
// notes are served from memory and only new/edited notes hit the network. Issue
|
|
58
|
+
// #2949 — the staleness fix applies here too: the inbox directory mtime advances
|
|
59
|
+
// on every new note, and the mtime cache key surfaces content edits within one 4 s poll.
|
|
29
60
|
async function fetchInboxFromDisk() {
|
|
30
61
|
try {
|
|
31
62
|
const listResp = await fetch('/state/notes/inbox');
|
|
@@ -35,21 +66,27 @@ async function fetchInboxFromDisk() {
|
|
|
35
66
|
const mdFiles = entries
|
|
36
67
|
.filter((e) => e && !e.isDir && typeof e.name === 'string' && e.name.endsWith('.md'))
|
|
37
68
|
.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
|
38
|
-
|
|
69
|
+
// Drop cache entries for notes that no longer exist so the cache can't grow
|
|
70
|
+
// unbounded across the lifetime of the page.
|
|
71
|
+
const liveNames = new Set(mdFiles.map((e) => e.name));
|
|
72
|
+
for (const key of _inboxContentCache.keys()) {
|
|
73
|
+
if (!liveNames.has(key)) _inboxContentCache.delete(key);
|
|
74
|
+
}
|
|
75
|
+
const items = await _mapWithConcurrency(mdFiles, INBOX_FETCH_CONCURRENCY, async (e) => {
|
|
76
|
+
const cached = _inboxContentCache.get(e.name);
|
|
77
|
+
if (cached && cached.mtimeMs === e.mtimeMs) {
|
|
78
|
+
return { name: e.name, mtime: e.mtimeMs, age: _timeSinceMs(e.mtimeMs), content: cached.content };
|
|
79
|
+
}
|
|
39
80
|
try {
|
|
40
81
|
const r = await fetch('/state/notes/inbox/' + encodeURIComponent(e.name));
|
|
41
82
|
if (!r.ok) return null;
|
|
42
83
|
const content = await r.text();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
mtime: e.mtimeMs,
|
|
46
|
-
age: _timeSinceMs(e.mtimeMs),
|
|
47
|
-
content,
|
|
48
|
-
};
|
|
84
|
+
_inboxContentCache.set(e.name, { mtimeMs: e.mtimeMs, content });
|
|
85
|
+
return { name: e.name, mtime: e.mtimeMs, age: _timeSinceMs(e.mtimeMs), content };
|
|
49
86
|
} catch {
|
|
50
87
|
return null;
|
|
51
88
|
}
|
|
52
|
-
})
|
|
89
|
+
});
|
|
53
90
|
return items.filter(Boolean);
|
|
54
91
|
} catch {
|
|
55
92
|
return null;
|
|
@@ -1238,10 +1238,14 @@ function openWorkItemDetail(id, source) {
|
|
|
1238
1238
|
// — see W-mphejzmj000718bf / W-mq5xg5e9000nec0e. We then hydrate the
|
|
1239
1239
|
// missing/truncated fields from GET /api/work-items/<id> and re-render in
|
|
1240
1240
|
// place.
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1241
|
+
// The bulk /api/work-items list is now a LEAN payload: it carries badges,
|
|
1242
|
+
// status, and the PR link, but NOT the detail-only enrichment fields
|
|
1243
|
+
// (_artifacts / _notes / _model) — those are computed on demand by
|
|
1244
|
+
// GET /api/work-items/<id> to keep the polled list event-loop-safe
|
|
1245
|
+
// (W-mscir7fv000u6321). The detail modal therefore ALWAYS hydrates from the
|
|
1246
|
+
// full record; we still render the cached slim record first so the click
|
|
1247
|
+
// feels instant, then re-render in place once the full record arrives.
|
|
1248
|
+
const needsHydration = true;
|
|
1245
1249
|
|
|
1246
1250
|
const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
|
|
1247
1251
|
document.getElementById('modal-title').textContent = initial.title || initial.id;
|
package/dashboard.js
CHANGED
|
@@ -3690,6 +3690,19 @@ function handleStateRead(req, res) {
|
|
|
3690
3690
|
let lstat;
|
|
3691
3691
|
try { lstat = fs.lstatSync(resolved); }
|
|
3692
3692
|
catch {
|
|
3693
|
+
// A not-yet-created but allowlisted top-level state directory (e.g. the
|
|
3694
|
+
// meetings dir before the first meeting is recorded) is an EMPTY directory,
|
|
3695
|
+
// not a missing resource. Returning 404 here made the dashboard's directory-
|
|
3696
|
+
// listing clients (render-meetings.js) log a console error on every refresh.
|
|
3697
|
+
// List it as empty instead so "no items yet" is a clean 200. Missing FILES
|
|
3698
|
+
// and missing nested paths still 404. (W-mscjlch1005qdc11)
|
|
3699
|
+
if (isSingleSegment && STATE_READ_ALLOWED_DIRS.has(top)) {
|
|
3700
|
+
res.statusCode = 200;
|
|
3701
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3702
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
3703
|
+
res.end(JSON.stringify({ path: rel.replace(/\\/g, '/'), entries: [] }));
|
|
3704
|
+
return;
|
|
3705
|
+
}
|
|
3693
3706
|
res.statusCode = 404;
|
|
3694
3707
|
res.setHeader('Content-Type', 'application/json');
|
|
3695
3708
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
@@ -15245,18 +15258,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
15245
15258
|
builder: () => getAgents(),
|
|
15246
15259
|
});
|
|
15247
15260
|
}},
|
|
15248
|
-
{ method: 'GET', path: '/api/work-items', desc: '
|
|
15261
|
+
{ method: 'GET', path: '/api/work-items', desc: 'Lean SQL-backed work items joined with dispatch state + PR links (_pr/_prUrl) — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e). The expensive detail-only enrichment (_artifacts/_notes/_model) is NOT computed for the list — the detail modal lazy-loads the full record via GET /api/work-items/<id> (W-mscir7fv000u6321). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is returned. Pagination is applied to the lean list BEFORE PR link-up so a large history cannot monopolize the event loop.', handler: (req, res) => {
|
|
15249
15262
|
const page = _parsePageParams(req);
|
|
15250
15263
|
return serveFreshJson(req, res, {
|
|
15251
15264
|
tag: 'work-items',
|
|
15252
15265
|
inputs: [CONFIG_PATH],
|
|
15253
15266
|
variant: page ? ('p' + page.offset + '.' + page.limit) : '',
|
|
15254
|
-
// W-
|
|
15255
|
-
//
|
|
15256
|
-
//
|
|
15257
|
-
//
|
|
15258
|
-
|
|
15259
|
-
|
|
15267
|
+
// W-mscir7fv000u6321 — event-loop-safe list build. getWorkItems(enrich:
|
|
15268
|
+
// false) returns the deterministically-ordered lean list (rows + dispatch
|
|
15269
|
+
// cross-ref + sort) with NO O(items × files) _artifacts/_notes scan.
|
|
15270
|
+
// Paginate FIRST, then slim + attach _pr/_prUrl to just the returned
|
|
15271
|
+
// page (or full list when unpaged) via the cheap map-based helper.
|
|
15272
|
+
// W-mq5xg5e9000nec0e — slimWorkItemForList shallow-copies so the cached
|
|
15273
|
+
// lean list from queries.getWorkItems() is never mutated; attach the PR
|
|
15274
|
+
// links to those copies, never the shared cache.
|
|
15275
|
+
builder: () => {
|
|
15276
|
+
const lean = getWorkItems(null, { enrich: false });
|
|
15277
|
+
if (page) {
|
|
15278
|
+
const pageResult = _paginateList(lean, page);
|
|
15279
|
+
pageResult.items = queries.attachWorkItemPrLinks(pageResult.items.map(slimWorkItemForList));
|
|
15280
|
+
return pageResult;
|
|
15281
|
+
}
|
|
15282
|
+
return queries.attachWorkItemPrLinks(lean.map(slimWorkItemForList));
|
|
15283
|
+
},
|
|
15260
15284
|
});
|
|
15261
15285
|
}},
|
|
15262
15286
|
{ method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps. Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
|
package/engine/core/queries.js
CHANGED
|
@@ -2106,6 +2106,82 @@ function invalidateWorkItemsCache() {
|
|
|
2106
2106
|
_workItemsLeanCacheAt = 0;
|
|
2107
2107
|
}
|
|
2108
2108
|
|
|
2109
|
+
// attachWorkItemPrLinks(items, config) — backfill the _pr / _prUrl fields the
|
|
2110
|
+
// /api/work-items LIST rows render (dashboard/js/render-work-items.js `wiRow`).
|
|
2111
|
+
// This is the ONLY cross-reference the list rows need; the far more expensive
|
|
2112
|
+
// _artifacts/_notes/_model enrichment stays detail-only (GET /api/work-items/<id>).
|
|
2113
|
+
//
|
|
2114
|
+
// Root-cause perf note (W-mscir7fv000u6321): the list endpoint used to run the
|
|
2115
|
+
// FULL enrich over the entire work-item history on every 4 s poll, whose
|
|
2116
|
+
// _artifacts/_notes step is an O(items × files) synchronous KB/inbox/archive/
|
|
2117
|
+
// agent-dir scan that hard-froze the single dashboard event loop (~26-30 s
|
|
2118
|
+
// requests at ~2,281 completed items), starving /api/health and Command Center
|
|
2119
|
+
// SSE. Extracting the cheap PR link-up lets the endpoint paginate FIRST and then
|
|
2120
|
+
// attach links to just the page slice. This helper is deliberately map-based —
|
|
2121
|
+
// O(items + prs), never O(items × prs) — and performs NO filesystem access, so
|
|
2122
|
+
// it is safe to call on the full list or a single page. Mutates and returns
|
|
2123
|
+
// `items` (same semantics the enrich block relied on).
|
|
2124
|
+
function attachWorkItemPrLinks(items, config) {
|
|
2125
|
+
if (!Array.isArray(items) || items.length === 0) return items || [];
|
|
2126
|
+
config = config || getConfig();
|
|
2127
|
+
const projects = getProjects(config);
|
|
2128
|
+
const allPrs = getPullRequests(config);
|
|
2129
|
+
// Prebuilt lookups so each item is O(1) instead of scanning allPrs.
|
|
2130
|
+
const prById = new Map();
|
|
2131
|
+
const prsByDisplayId = new Map();
|
|
2132
|
+
const prByProjectPrdItem = new Map();
|
|
2133
|
+
const prByProjectBranch = new Map();
|
|
2134
|
+
for (const p of allPrs) {
|
|
2135
|
+
if (p.id != null && !prById.has(p.id)) prById.set(p.id, p);
|
|
2136
|
+
const disp = shared.getPrDisplayId(p);
|
|
2137
|
+
if (disp != null) {
|
|
2138
|
+
const bucket = prsByDisplayId.get(disp);
|
|
2139
|
+
if (bucket) bucket.push(p); else prsByDisplayId.set(disp, [p]);
|
|
2140
|
+
}
|
|
2141
|
+
if (p._project) {
|
|
2142
|
+
for (const wid of (p.prdItems || [])) {
|
|
2143
|
+
const key = p._project + '\0' + wid;
|
|
2144
|
+
if (!prByProjectPrdItem.has(key)) prByProjectPrdItem.set(key, p);
|
|
2145
|
+
}
|
|
2146
|
+
if (p.branch) {
|
|
2147
|
+
const bkey = p._project + '\0' + p.branch;
|
|
2148
|
+
if (!prByProjectBranch.has(bkey)) prByProjectBranch.set(bkey, p);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
for (const item of items) {
|
|
2153
|
+
if (item._pr && !item._prUrl) {
|
|
2154
|
+
const project = shared.resolveProjectSource(item.project || item._source, projects, { allowCentral: false }).project || null;
|
|
2155
|
+
const canonicalPrId = shared.getCanonicalPrId(project, item._pr);
|
|
2156
|
+
const displayPrId = shared.getPrDisplayId(item._pr);
|
|
2157
|
+
const exactPr = prById.get(canonicalPrId);
|
|
2158
|
+
const displayMatches = exactPr ? [] : (prsByDisplayId.get(displayPrId) || []);
|
|
2159
|
+
const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
|
|
2160
|
+
if (pr) {
|
|
2161
|
+
item._pr = pr.id;
|
|
2162
|
+
item._prUrl = pr.url;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
if (!item._pr) {
|
|
2166
|
+
// Derive from PR.prdItems (single source of truth), then fall back to
|
|
2167
|
+
// branch identity. The branch fallback heals decomposed-child WIs whose
|
|
2168
|
+
// PR-opening agent only stamped the parent id into prdItems — branch
|
|
2169
|
+
// names (`work/<wi-id>`) are unique per WI within a project, so a
|
|
2170
|
+
// branch hit is as authoritative as a prdItems hit. Scoped to the same
|
|
2171
|
+
// project so cross-repo branch collisions don't false-link.
|
|
2172
|
+
// (W-mpn0b76200044eed)
|
|
2173
|
+
const itemProject = item.project || item._source;
|
|
2174
|
+
const linkedPr = prByProjectPrdItem.get(itemProject + '\0' + item.id)
|
|
2175
|
+
|| (item.branch ? prByProjectBranch.get(itemProject + '\0' + item.branch) : null);
|
|
2176
|
+
if (linkedPr) {
|
|
2177
|
+
item._pr = linkedPr.id;
|
|
2178
|
+
item._prUrl = linkedPr.url;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
return items;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2109
2185
|
// getWorkItems(config, { enrich, onlyId })
|
|
2110
2186
|
// enrich (default true): also cross-reference PRs and populate the
|
|
2111
2187
|
// detail-modal _artifacts/_notes fields. The /api/work-items list payload
|
|
@@ -2203,39 +2279,12 @@ function getWorkItems(config, opts) {
|
|
|
2203
2279
|
// Detail-modal enrichment (PR cross-ref + _artifacts/_notes). Skipped for
|
|
2204
2280
|
// enrich:false callers — see the getWorkItems header.
|
|
2205
2281
|
if (enrich && !skipEnrichment) {
|
|
2206
|
-
|
|
2207
|
-
//
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
const canonicalPrId = shared.getCanonicalPrId(project, item._pr);
|
|
2213
|
-
const displayPrId = shared.getPrDisplayId(item._pr);
|
|
2214
|
-
const exactPr = allPrs.find(p => p.id === canonicalPrId);
|
|
2215
|
-
const displayMatches = exactPr ? [] : allPrs.filter(p => shared.getPrDisplayId(p) === displayPrId);
|
|
2216
|
-
const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
|
|
2217
|
-
if (pr) {
|
|
2218
|
-
item._pr = pr.id;
|
|
2219
|
-
item._prUrl = pr.url;
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
if (!item._pr) {
|
|
2223
|
-
// Derive from PR.prdItems (single source of truth), then fall back to
|
|
2224
|
-
// branch identity. The branch fallback heals decomposed-child WIs whose
|
|
2225
|
-
// PR-opening agent only stamped the parent id into prdItems — branch
|
|
2226
|
-
// names (`work/<wi-id>`) are unique per WI within a project, so a
|
|
2227
|
-
// branch hit is as authoritative as a prdItems hit. Scoped to the same
|
|
2228
|
-
// project so cross-repo branch collisions don't false-link.
|
|
2229
|
-
// (W-mpn0b76200044eed)
|
|
2230
|
-
const itemProject = item.project || item._source;
|
|
2231
|
-
const linkedPr = allPrs.find(p => p._project === itemProject && (p.prdItems || []).includes(item.id))
|
|
2232
|
-
|| (item.branch && allPrs.find(p => p.branch === item.branch && p._project === itemProject));
|
|
2233
|
-
if (linkedPr) {
|
|
2234
|
-
item._pr = linkedPr.id;
|
|
2235
|
-
item._prUrl = linkedPr.url;
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2282
|
+
// Cross-reference with PRs (the _pr/_prUrl LIST-row fields). Extracted to a
|
|
2283
|
+
// shared, map-based helper so the /api/work-items list endpoint can attach
|
|
2284
|
+
// the same links to a PAGINATED slice without the expensive _artifacts/_notes
|
|
2285
|
+
// filesystem scan below (W-mscir7fv000u6321). Narrowed to enrichTargets so an
|
|
2286
|
+
// onlyId detail lookup only links its own row (W-mscmqk6b03ha5673).
|
|
2287
|
+
attachWorkItemPrLinks(enrichTargets, config);
|
|
2239
2288
|
|
|
2240
2289
|
// Populate _artifacts for the work item detail modal
|
|
2241
2290
|
// Build dispatch ID → work item ID lookup from completed dispatches
|
|
@@ -3609,7 +3658,7 @@ module.exports = {
|
|
|
3609
3658
|
getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
|
|
3610
3659
|
|
|
3611
3660
|
// Work items & PRD
|
|
3612
|
-
getWorkItems, invalidateWorkItemsCache, getPrdInfo,
|
|
3661
|
+
getWorkItems, attachWorkItemPrLinks, invalidateWorkItemsCache, getPrdInfo,
|
|
3613
3662
|
|
|
3614
3663
|
// W-mq5uzmc6001d708f — test hooks for the defensive PR enrichment cache.
|
|
3615
3664
|
_resetPrEnrichmentCacheForTest,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2451",
|
|
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"
|