@worca/ui 0.31.0 → 0.33.0

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.
@@ -6,7 +6,11 @@
6
6
 
7
7
  import { existsSync, statSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
- import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
9
+ import {
10
+ countIssuesByRunLabel,
11
+ enrichIssuesWithDeps,
12
+ listIssuesShallow,
13
+ } from './beads-reader.js';
10
14
 
11
15
  const BEADS_DEBOUNCE_MS = 500;
12
16
  const BEADS_POLL_MS = 2000;
@@ -34,19 +38,73 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
34
38
  let lastPayloadJson = null;
35
39
  let lastSelfReadWalStat = null;
36
40
  let latestCounts = {};
41
+ // In-flight guard + trailing coalesce. The refresh body spawns several `bd`
42
+ // subprocesses (listIssuesShallow, then enrichIssuesWithDeps +
43
+ // countIssuesByRunLabel when the fingerprint changes) that, on a large beads
44
+ // db, take seconds. The debounce only collapses scheduling — once the async body
45
+ // is awaiting, a fresh db/WAL event would otherwise start an overlapping
46
+ // refresh and pile up bd processes unbounded. Allow at most one refresh in
47
+ // flight; events arriving mid-refresh collapse into a single trailing pass.
48
+ let refreshing = false;
49
+ let refreshPending = false;
50
+ let lastListFingerprint = null;
51
+
52
+ function computeFingerprint(issues) {
53
+ const sorted = [...issues].sort((a, b) => (a.id < b.id ? -1 : 1));
54
+ return JSON.stringify(
55
+ sorted.map((i) => ({
56
+ id: i.id,
57
+ status: i.status,
58
+ priority: i.priority,
59
+ title: i.title,
60
+ updated_at: i.updated_at,
61
+ // dependency_count/dependent_count come free in `bd list` (no `bd show`).
62
+ // They catch dependency-edge changes (`bd dep add/remove`) that don't
63
+ // bump an issue's own updated_at — without them the fingerprint would
64
+ // bail and leave depends_on/blocked_by (and blocked badges) stale.
65
+ dependency_count: i.dependency_count,
66
+ dependent_count: i.dependent_count,
67
+ })),
68
+ );
69
+ }
70
+
71
+ function recordWalStat() {
72
+ try {
73
+ const s = statSync(beadsWalPath);
74
+ lastSelfReadWalStat = { mtimeMs: s.mtimeMs, size: s.size };
75
+ } catch {
76
+ lastSelfReadWalStat = null;
77
+ }
78
+ }
37
79
 
38
80
  function scheduleBeadsRefresh() {
39
81
  if (BEADS_REFRESH_TIMER) clearTimeout(BEADS_REFRESH_TIMER);
40
- BEADS_REFRESH_TIMER = setTimeout(async () => {
41
- BEADS_REFRESH_TIMER = null;
42
- try {
43
- const [issues, counts] = await Promise.all([
44
- listIssues(beadsDbPath),
45
- countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
46
- ]);
47
- latestCounts = counts;
48
- const payloadJson = JSON.stringify({ issues, counts });
49
- if (payloadJson === lastPayloadJson) return;
82
+ BEADS_REFRESH_TIMER = setTimeout(runBeadsRefresh, BEADS_DEBOUNCE_MS);
83
+ }
84
+
85
+ async function runBeadsRefresh() {
86
+ BEADS_REFRESH_TIMER = null;
87
+ if (refreshing) {
88
+ refreshPending = true;
89
+ return;
90
+ }
91
+ refreshing = true;
92
+ try {
93
+ const shallowIssues = await listIssuesShallow(beadsDbPath);
94
+ const fingerprint = computeFingerprint(shallowIssues);
95
+ if (fingerprint === lastListFingerprint) {
96
+ recordWalStat();
97
+ return;
98
+ }
99
+ lastListFingerprint = fingerprint;
100
+
101
+ const [issues, counts] = await Promise.all([
102
+ enrichIssuesWithDeps(shallowIssues, beadsDbPath),
103
+ countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
104
+ ]);
105
+ latestCounts = counts;
106
+ const payloadJson = JSON.stringify({ issues, counts });
107
+ if (payloadJson !== lastPayloadJson) {
50
108
  lastPayloadJson = payloadJson;
51
109
  broadcaster.broadcast(
52
110
  'beads-update',
@@ -58,16 +116,17 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
58
116
  },
59
117
  projectId,
60
118
  );
61
- try {
62
- const s = statSync(beadsWalPath);
63
- lastSelfReadWalStat = { mtimeMs: s.mtimeMs, size: s.size };
64
- } catch {
65
- lastSelfReadWalStat = null;
66
- }
67
- } catch {
68
- /* ignore */
69
119
  }
70
- }, BEADS_DEBOUNCE_MS);
120
+ recordWalStat();
121
+ } catch {
122
+ /* ignore */
123
+ } finally {
124
+ refreshing = false;
125
+ if (refreshPending) {
126
+ refreshPending = false;
127
+ scheduleBeadsRefresh();
128
+ }
129
+ }
71
130
  }
72
131
 
73
132
  if (existsSync(beadsDir)) {
@@ -116,7 +175,11 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
116
175
  return { getBeadsDbPath, getLatestCounts, destroy };
117
176
  }
118
177
 
119
- const FALLBACK_TTL_MS = 5000;
178
+ // Throttle window for the cold-path bead-count read (used by REST /runs and chat
179
+ // when no live watcher is warm). The read costs seconds on a large db, so a short
180
+ // TTL lets repeated /runs across many projects re-spawn `bd` too often. Counts are
181
+ // advisory and the live watcher keeps a viewed project fresh, so 30s is ample.
182
+ const FALLBACK_TTL_MS = 30000;
120
183
  /** @type {Map<string, { ts: number, counts: object }>} */
121
184
  const fallbackCache = new Map();
122
185
  /** @type {Map<string, Promise<object>>} */
@@ -163,3 +226,50 @@ export async function resolveBeadsCounts(wset) {
163
226
  fallbackInflight.set(key, promise);
164
227
  return promise;
165
228
  }
229
+
230
+ /**
231
+ * Non-blocking variant of {@link resolveBeadsCounts}: returns whatever counts
232
+ * are already available (the live watcher cache, or a fresh TTL-cached fallback)
233
+ * WITHOUT ever awaiting the cold `bd` query. When nothing is cached it kicks off
234
+ * a background refresh (reusing the same TTL cache + in-flight dedup) so a later
235
+ * caller gets real data, and returns {} immediately.
236
+ *
237
+ * Used by the REST `/runs` endpoint: a cold `bd show` on a large beads db
238
+ * (worca-cc has hundreds of issues — the cold read takes ~10s) would otherwise
239
+ * block the run list and, fanned out across every project, stall the whole UI.
240
+ * Bead counts on run cards are advisory and arrive via the `beads-update`
241
+ * broadcast once warm; the web run-detail does not use them at all.
242
+ *
243
+ * @param {{ projectId?: string, worcaDir?: string, beadsWatcher?: { getLatestCounts: () => object } | null }} [wset]
244
+ * @returns {Record<string, { total: number, done: number }>}
245
+ */
246
+ export function peekBeadsCounts(wset) {
247
+ if (!wset || !wset.worcaDir) return {};
248
+
249
+ const live = wset.beadsWatcher?.getLatestCounts();
250
+ if (live && Object.keys(live).length > 0) return live;
251
+
252
+ const key = wset.projectId ?? wset.worcaDir;
253
+ const cached = fallbackCache.get(key);
254
+ if (cached && Date.now() - cached.ts < FALLBACK_TTL_MS) return cached.counts;
255
+
256
+ // Cold cache: warm it in the background (dedup'd), but never block the caller.
257
+ if (!fallbackInflight.has(key)) {
258
+ const dbPath = beadsDbPathFor(wset.worcaDir);
259
+ if (existsSync(dbPath)) {
260
+ const promise = (async () => {
261
+ try {
262
+ const counts = await countIssuesByRunLabel(dbPath);
263
+ fallbackCache.set(key, { ts: Date.now(), counts });
264
+ return counts;
265
+ } catch {
266
+ return {};
267
+ } finally {
268
+ fallbackInflight.delete(key);
269
+ }
270
+ })();
271
+ fallbackInflight.set(key, promise);
272
+ }
273
+ }
274
+ return {};
275
+ }
@@ -13,7 +13,7 @@ import { fleetRunsDir, workspaceRunsDir } from './paths.js';
13
13
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
14
14
  import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
15
15
  import { readProjectWorcaVersion } from './worca-setup.js';
16
- import { resolveBeadsCounts } from './ws-beads-watcher.js';
16
+ import { peekBeadsCounts } from './ws-beads-watcher.js';
17
17
  import { createBroadcaster } from './ws-broadcaster.js';
18
18
  import { createClientManager } from './ws-client-manager.js';
19
19
  import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
@@ -331,8 +331,10 @@ export function attachWsServer(httpServer, config) {
331
331
  return null;
332
332
  }
333
333
 
334
+ // Non-blocking: returns cached/live counts and warms cold caches in the
335
+ // background. The REST /runs endpoint must never block on a slow `bd` read.
334
336
  function getBeadsCounts(projectId) {
335
- return resolveBeadsCounts(watcherSets.get(projectId));
337
+ return peekBeadsCounts(watcherSets.get(projectId));
336
338
  }
337
339
 
338
340
  return {