@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.
- package/README.md +10 -0
- package/app/main.bundle.js +1812 -1767
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +100 -5
- package/package.json +2 -1
- package/server/app.js +34 -23
- package/server/beads-reader.js +12 -4
- package/server/integrations/renderers.js +28 -0
- package/server/workspace-routes.js +170 -2
- package/server/ws-beads-watcher.js +131 -21
- package/server/ws-modular.js +4 -2
|
@@ -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 {
|
|
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(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/server/ws-modular.js
CHANGED
|
@@ -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 {
|
|
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
|
|
337
|
+
return peekBeadsCounts(watcherSets.get(projectId));
|
|
336
338
|
}
|
|
337
339
|
|
|
338
340
|
return {
|