@worca/ui 0.29.0 → 0.31.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.
@@ -4,22 +4,36 @@
4
4
  * because fs.watch on macOS misses SQLite WAL writes done via mmap.
5
5
  */
6
6
 
7
- import { existsSync, unwatchFile, watch, watchFile } from 'node:fs';
7
+ import { existsSync, statSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
10
10
 
11
11
  const BEADS_DEBOUNCE_MS = 500;
12
12
  const BEADS_POLL_MS = 2000;
13
13
 
14
+ /**
15
+ * Resolve the beads.db path for a project's worcaDir, independent of any
16
+ * watcher instance. Used by tier-independent count lookups (chat-only mode,
17
+ * where no beadsWatcher exists because the WatcherSet is in TIER_POLLING).
18
+ * @param {string} worcaDir
19
+ * @returns {string}
20
+ */
21
+ export function beadsDbPathFor(worcaDir) {
22
+ return resolve(join(worcaDir, '..', '.beads', 'beads.db'));
23
+ }
24
+
14
25
  /**
15
26
  * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps
16
27
  */
17
28
  export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
18
- const beadsDbPath = resolve(join(worcaDir, '..', '.beads', 'beads.db'));
29
+ const beadsDbPath = beadsDbPathFor(worcaDir);
19
30
  const beadsDir = resolve(join(worcaDir, '..', '.beads'));
20
31
  const beadsWalPath = `${beadsDbPath}-wal`;
21
32
  let fsWatcher = null;
22
33
  let BEADS_REFRESH_TIMER = null;
34
+ let lastPayloadJson = null;
35
+ let lastSelfReadWalStat = null;
36
+ let latestCounts = {};
23
37
 
24
38
  function scheduleBeadsRefresh() {
25
39
  if (BEADS_REFRESH_TIMER) clearTimeout(BEADS_REFRESH_TIMER);
@@ -30,6 +44,10 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
30
44
  listIssues(beadsDbPath),
31
45
  countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
32
46
  ]);
47
+ latestCounts = counts;
48
+ const payloadJson = JSON.stringify({ issues, counts });
49
+ if (payloadJson === lastPayloadJson) return;
50
+ lastPayloadJson = payloadJson;
33
51
  broadcaster.broadcast(
34
52
  'beads-update',
35
53
  {
@@ -40,6 +58,12 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
40
58
  },
41
59
  projectId,
42
60
  );
61
+ try {
62
+ const s = statSync(beadsWalPath);
63
+ lastSelfReadWalStat = { mtimeMs: s.mtimeMs, size: s.size };
64
+ } catch {
65
+ lastSelfReadWalStat = null;
66
+ }
43
67
  } catch {
44
68
  /* ignore */
45
69
  }
@@ -60,6 +84,13 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
60
84
  // on macOS. watchFile tolerates a missing file; it starts firing once created.
61
85
  watchFile(beadsWalPath, { interval: BEADS_POLL_MS }, (curr, prev) => {
62
86
  if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) {
87
+ if (
88
+ lastSelfReadWalStat &&
89
+ curr.mtimeMs === lastSelfReadWalStat.mtimeMs &&
90
+ curr.size === lastSelfReadWalStat.size
91
+ ) {
92
+ return;
93
+ }
63
94
  scheduleBeadsRefresh();
64
95
  }
65
96
  });
@@ -78,5 +109,57 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
78
109
  }
79
110
  }
80
111
 
81
- return { getBeadsDbPath, destroy };
112
+ function getLatestCounts() {
113
+ return latestCounts;
114
+ }
115
+
116
+ return { getBeadsDbPath, getLatestCounts, destroy };
117
+ }
118
+
119
+ const FALLBACK_TTL_MS = 5000;
120
+ /** @type {Map<string, { ts: number, counts: object }>} */
121
+ const fallbackCache = new Map();
122
+ /** @type {Map<string, Promise<object>>} */
123
+ const fallbackInflight = new Map();
124
+
125
+ /**
126
+ * Resolve per-run bead counts for a WatcherSet, independent of its tier.
127
+ *
128
+ * Fast path: the live watcher cache, populated only while a UI client is
129
+ * subscribed (TIER_FULL). Fallback: an on-demand DB read for callers that
130
+ * have no active watcher — e.g. chat integrations hitting REST with no
131
+ * browser open, where the WatcherSet sits in TIER_POLLING and `beadsWatcher`
132
+ * is null. The fallback is TTL-cached and in-flight-deduplicated so repeated
133
+ * chat polling does not spawn a `bd` subprocess per request.
134
+ *
135
+ * @param {{ projectId?: string, worcaDir?: string, beadsWatcher?: { getLatestCounts: () => object } | null }} [wset]
136
+ * @returns {Promise<Record<string, { total: number, done: number }>>}
137
+ */
138
+ export async function resolveBeadsCounts(wset) {
139
+ if (!wset || !wset.worcaDir) return {};
140
+
141
+ const live = wset.beadsWatcher?.getLatestCounts();
142
+ if (live && Object.keys(live).length > 0) return live;
143
+
144
+ const key = wset.projectId ?? wset.worcaDir;
145
+ const cached = fallbackCache.get(key);
146
+ if (cached && Date.now() - cached.ts < FALLBACK_TTL_MS) return cached.counts;
147
+ if (fallbackInflight.has(key)) return fallbackInflight.get(key);
148
+
149
+ const dbPath = beadsDbPathFor(wset.worcaDir);
150
+ if (!existsSync(dbPath)) return {};
151
+
152
+ const promise = (async () => {
153
+ try {
154
+ const counts = await countIssuesByRunLabel(dbPath);
155
+ fallbackCache.set(key, { ts: Date.now(), counts });
156
+ return counts;
157
+ } catch {
158
+ return {};
159
+ } finally {
160
+ fallbackInflight.delete(key);
161
+ }
162
+ })();
163
+ fallbackInflight.set(key, promise);
164
+ return promise;
82
165
  }
@@ -11,7 +11,6 @@ import { join } from 'node:path';
11
11
  import { isRequest, makeError, makeOk } from '../app/protocol.js';
12
12
  import {
13
13
  dbExists as beadsDbExists,
14
- countIssuesByRunLabel,
15
14
  getIssue,
16
15
  listDistinctRunLabels,
17
16
  listIssues,
@@ -35,6 +34,7 @@ import {
35
34
  import { resolveRunDir } from './run-dir-resolver.js';
36
35
  import { readSettings } from './settings-reader.js';
37
36
  import { discoverRuns } from './watcher.js';
37
+ import { resolveBeadsCounts } from './ws-beads-watcher.js';
38
38
 
39
39
  /**
40
40
  * @param {{
@@ -643,19 +643,12 @@ export function createMessageRouter({
643
643
  return;
644
644
  }
645
645
 
646
- // list-beads-counts
646
+ // list-beads-counts — tier-independent: resolveBeadsCounts falls back to
647
+ // an on-demand DB read when no beadsWatcher exists (TIER_POLLING), so
648
+ // chat/REST callers with no browser open still get counts.
647
649
  if (req.type === 'list-beads-counts') {
648
650
  const proj = resolveProject(ws, req.payload);
649
- if (!proj.wset.beadsWatcher) {
650
- ws.send(JSON.stringify(makeOk(req, { counts: {} })));
651
- return;
652
- }
653
- const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
654
- if (!beadsDbExists(beadsDbPath)) {
655
- ws.send(JSON.stringify(makeOk(req, { counts: {} })));
656
- return;
657
- }
658
- const counts = await countIssuesByRunLabel(beadsDbPath);
651
+ const counts = await resolveBeadsCounts(proj.wset);
659
652
  ws.send(JSON.stringify(makeOk(req, { counts })));
660
653
  return;
661
654
  }
@@ -13,6 +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
17
  import { createBroadcaster } from './ws-broadcaster.js';
17
18
  import { createClientManager } from './ws-client-manager.js';
18
19
  import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
@@ -330,10 +331,15 @@ export function attachWsServer(httpServer, config) {
330
331
  return null;
331
332
  }
332
333
 
334
+ function getBeadsCounts(projectId) {
335
+ return resolveBeadsCounts(watcherSets.get(projectId));
336
+ }
337
+
333
338
  return {
334
339
  wss,
335
340
  broadcast: broadcaster.broadcast,
336
341
  scheduleRefresh,
337
342
  resolveRunProject,
343
+ getBeadsCounts,
338
344
  };
339
345
  }