@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.
@@ -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). The per-file fetches run
25
- // in parallel with their own mtime+size ETag so unchanged files 304 with
26
- // no body. Issue #2949 the staleness fix applies here too: the inbox
27
- // directory mtime advances on every new note, and per-file ETags surface
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
- const items = await Promise.all(mdFiles.map(async (e) => {
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
- return {
44
- name: e.name,
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
- const needsHydration = !cached.description ||
1242
- cached._descriptionTruncated === true ||
1243
- (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
1244
- (cached.referencesCount > 0 && !Array.isArray(cached.references));
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: 'Fully-enriched SQL-backed work items joined with dispatch/PR state — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>. 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) => {
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-mq5xg5e9000nec0eSlim every item before stringify so the polled
15255
- // refresh-loop payload stays < ~300 KB even when description fields
15256
- // include 100+ KB transcripts. slimWorkItemForList shallow-copies so
15257
- // queries.getWorkItems()'s in-memory cache is never mutated.
15258
- builder: () => getWorkItems().map(slimWorkItemForList),
15259
- transform: page ? (list) => _paginateList(list, page) : null,
15267
+ // W-mscir7fv000u6321event-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) => {
@@ -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
- const projects = getProjects(config);
2207
- // Cross-reference with PRs
2208
- const allPrs = getPullRequests(config);
2209
- for (const item of enrichTargets) {
2210
- if (item._pr && !item._prUrl) {
2211
- const project = shared.resolveProjectSource(item.project || item._source, projects, { allowCentral: false }).project || null;
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.2449",
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"