@worca/ui 0.30.0 → 0.32.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 +1452 -1449
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +66 -8
- package/package.json +2 -1
- package/server/beads-reader.js +28 -13
- package/server/index.js +5 -6
- package/server/integrations/commands/global.js +5 -0
- package/server/integrations/commands/project.js +3 -0
- package/server/integrations/renderers.js +15 -0
- package/server/project-routes.js +20 -1
- package/server/ws-beads-watcher.js +207 -14
- package/server/ws-message-router.js +5 -12
- package/server/ws-modular.js +8 -0
package/app/styles.css
CHANGED
|
@@ -766,6 +766,13 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
766
766
|
to { transform: rotate(360deg); }
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
.effort-zap-icon {
|
|
770
|
+
width: 12px;
|
|
771
|
+
height: 12px;
|
|
772
|
+
vertical-align: -1px;
|
|
773
|
+
margin-right: 2px;
|
|
774
|
+
}
|
|
775
|
+
|
|
769
776
|
/* --- 13. Stage Connector --- */
|
|
770
777
|
.stage-connector {
|
|
771
778
|
width: 36px;
|
|
@@ -2418,6 +2425,13 @@ sl-details.agent-prompt-section::part(content) {
|
|
|
2418
2425
|
margin-bottom: 12px;
|
|
2419
2426
|
}
|
|
2420
2427
|
|
|
2428
|
+
/* Divider + gap before the user message header */
|
|
2429
|
+
.agent-prompt-block + .agent-prompt-block {
|
|
2430
|
+
margin-top: 18px;
|
|
2431
|
+
padding-top: 16px;
|
|
2432
|
+
border-top: 1px solid var(--border-subtle);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2421
2435
|
.agent-prompt-label-row {
|
|
2422
2436
|
display: flex;
|
|
2423
2437
|
align-items: center;
|
|
@@ -2448,12 +2462,14 @@ sl-details.agent-prompt-section::part(content) {
|
|
|
2448
2462
|
flex-shrink: 0;
|
|
2449
2463
|
}
|
|
2450
2464
|
|
|
2465
|
+
/* Accent the section headers (not the message bodies) so each block reads as
|
|
2466
|
+
a labelled section; the bodies stay neutral. */
|
|
2451
2467
|
.agent-prompt-label {
|
|
2452
|
-
font-size:
|
|
2453
|
-
font-weight:
|
|
2454
|
-
color: var(--
|
|
2468
|
+
font-size: 12px;
|
|
2469
|
+
font-weight: 700;
|
|
2470
|
+
color: var(--accent);
|
|
2455
2471
|
text-transform: uppercase;
|
|
2456
|
-
letter-spacing: 0.
|
|
2472
|
+
letter-spacing: 0.6px;
|
|
2457
2473
|
margin-bottom: 4px;
|
|
2458
2474
|
}
|
|
2459
2475
|
|
|
@@ -4779,9 +4795,9 @@ sl-details.learnings-panel::part(content) {
|
|
|
4779
4795
|
|
|
4780
4796
|
.bead-tooltip-header {
|
|
4781
4797
|
display: flex;
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
gap:
|
|
4798
|
+
flex-direction: column;
|
|
4799
|
+
align-items: flex-start;
|
|
4800
|
+
gap: 4px;
|
|
4785
4801
|
}
|
|
4786
4802
|
|
|
4787
4803
|
sl-tooltip.bead-tooltip::part(body) {
|
|
@@ -4790,6 +4806,7 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4790
4806
|
|
|
4791
4807
|
.bead-tooltip-badges {
|
|
4792
4808
|
display: flex;
|
|
4809
|
+
flex-wrap: wrap;
|
|
4793
4810
|
align-items: center;
|
|
4794
4811
|
gap: 4px;
|
|
4795
4812
|
}
|
|
@@ -4825,7 +4842,7 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4825
4842
|
|
|
4826
4843
|
.bead-tooltip-excerpt {
|
|
4827
4844
|
font-size: 12px;
|
|
4828
|
-
white-space:
|
|
4845
|
+
white-space: normal;
|
|
4829
4846
|
margin-bottom: 2px;
|
|
4830
4847
|
}
|
|
4831
4848
|
|
|
@@ -6240,6 +6257,47 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
6240
6257
|
text-align: left;
|
|
6241
6258
|
}
|
|
6242
6259
|
|
|
6260
|
+
/* --- Markdown context overrides --- */
|
|
6261
|
+
|
|
6262
|
+
.markdown-inline.markdown-body {
|
|
6263
|
+
display: inline;
|
|
6264
|
+
font-size: inherit;
|
|
6265
|
+
line-height: inherit;
|
|
6266
|
+
}
|
|
6267
|
+
.markdown-inline.markdown-body p {
|
|
6268
|
+
display: inline;
|
|
6269
|
+
margin: 0;
|
|
6270
|
+
}
|
|
6271
|
+
.markdown-inline.markdown-body code {
|
|
6272
|
+
font-size: 0.85em;
|
|
6273
|
+
}
|
|
6274
|
+
|
|
6275
|
+
.agent-prompt-content .markdown-body {
|
|
6276
|
+
white-space: normal;
|
|
6277
|
+
font-size: 12px;
|
|
6278
|
+
line-height: 1.5;
|
|
6279
|
+
}
|
|
6280
|
+
.agent-prompt-content .markdown-body h1,
|
|
6281
|
+
.agent-prompt-content .markdown-body h2,
|
|
6282
|
+
.agent-prompt-content .markdown-body h3 {
|
|
6283
|
+
margin: 0.8em 0 0.3em;
|
|
6284
|
+
}
|
|
6285
|
+
|
|
6286
|
+
.bead-tooltip-excerpt.markdown-body {
|
|
6287
|
+
white-space: normal;
|
|
6288
|
+
font-size: 12px;
|
|
6289
|
+
max-height: 200px;
|
|
6290
|
+
overflow-y: auto;
|
|
6291
|
+
}
|
|
6292
|
+
.bead-tooltip-excerpt.markdown-body p {
|
|
6293
|
+
margin: 0.3em 0;
|
|
6294
|
+
}
|
|
6295
|
+
.bead-tooltip-excerpt.markdown-body pre {
|
|
6296
|
+
font-size: 11px;
|
|
6297
|
+
max-height: 120px;
|
|
6298
|
+
overflow: auto;
|
|
6299
|
+
}
|
|
6300
|
+
|
|
6243
6301
|
.workspace-run-card .workspace-card-root,
|
|
6244
6302
|
.workspace-run-card .workspace-card-name {
|
|
6245
6303
|
font-family: var(--font-mono);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Pipeline monitoring UI for worca-cc",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@xterm/addon-fit": "^0.11.0",
|
|
66
66
|
"@xterm/addon-search": "^0.16.0",
|
|
67
67
|
"@xterm/xterm": "^6.0.0",
|
|
68
|
+
"dompurify": "^3.4.5",
|
|
68
69
|
"express": "^5.2.1",
|
|
69
70
|
"lit-html": "^3.3.1",
|
|
70
71
|
"lucide": "^0.577.0",
|
package/server/beads-reader.js
CHANGED
|
@@ -14,6 +14,13 @@ async function runBd(args, dbPath) {
|
|
|
14
14
|
return JSON.parse(stdout);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function extractEffortFromLabels(labels) {
|
|
18
|
+
if (!labels || labels.length === 0) return null;
|
|
19
|
+
const match = labels.find((l) => l.startsWith('worca-effort:'));
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
return match.slice('worca-effort:'.length);
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
function transformIssue(issue, deps) {
|
|
18
25
|
const depends_on = (deps || []).map((d) => d.id);
|
|
19
26
|
const blocked_by = (deps || [])
|
|
@@ -29,30 +36,39 @@ function transformIssue(issue, deps) {
|
|
|
29
36
|
external_ref: issue.external_ref || null,
|
|
30
37
|
depends_on,
|
|
31
38
|
blocked_by,
|
|
39
|
+
effort: extractEffortFromLabels(issue.labels),
|
|
32
40
|
};
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
async function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
export async function enrichIssuesWithDeps(issues, dbPath) {
|
|
44
|
+
if (issues.length === 0) return [];
|
|
45
|
+
const detailed = await runBd(['show', ...issues.map((i) => i.id)], dbPath);
|
|
46
|
+
const detailMap = new Map(detailed.map((d) => [d.id, d]));
|
|
47
|
+
return issues.map((i) => {
|
|
48
|
+
const d = detailMap.get(i.id);
|
|
49
|
+
return transformIssue(d || i, d?.dependencies || []);
|
|
50
|
+
});
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
export function dbExists(beadsDb) {
|
|
46
54
|
return existsSync(beadsDb);
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
export async function listIssuesShallow(beadsDb) {
|
|
58
|
+
try {
|
|
59
|
+
return await runBd(['list', '--limit', '0'], beadsDb);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
export async function listIssues(beadsDb) {
|
|
50
66
|
try {
|
|
51
67
|
const issues = await runBd(['list', '--limit', '0'], beadsDb);
|
|
52
|
-
// Must await here — without it, an
|
|
68
|
+
// Must await here — without it, an enrichIssuesWithDeps rejection (e.g. bd show
|
|
53
69
|
// SIGTERM under daemon contention) escapes the try/catch and propagates
|
|
54
70
|
// to the WS handler as an unhandled rejection, crashing Node.
|
|
55
|
-
return await
|
|
71
|
+
return await enrichIssuesWithDeps(issues, beadsDb);
|
|
56
72
|
} catch {
|
|
57
73
|
return [];
|
|
58
74
|
}
|
|
@@ -64,7 +80,7 @@ export async function listIssuesByLabel(beadsDb, label) {
|
|
|
64
80
|
['list', '--label-any', label, '--all', '--limit', '0'],
|
|
65
81
|
beadsDb,
|
|
66
82
|
);
|
|
67
|
-
return await
|
|
83
|
+
return await enrichIssuesWithDeps(issues, beadsDb);
|
|
68
84
|
};
|
|
69
85
|
try {
|
|
70
86
|
return await attempt();
|
|
@@ -89,10 +105,9 @@ export async function listUnlinkedIssues(beadsDb) {
|
|
|
89
105
|
const labels = d?.labels || [];
|
|
90
106
|
return !labels.some((l) => l.startsWith('run:'));
|
|
91
107
|
});
|
|
92
|
-
// detailed already has dependencies, use them directly
|
|
93
108
|
return unlinked.map((i) => {
|
|
94
109
|
const d = detailMap.get(i.id);
|
|
95
|
-
return transformIssue(i, d?.dependencies || []);
|
|
110
|
+
return transformIssue(d || i, d?.dependencies || []);
|
|
96
111
|
});
|
|
97
112
|
} catch {
|
|
98
113
|
return [];
|
package/server/index.js
CHANGED
|
@@ -90,22 +90,21 @@ server.on('error', (err) => {
|
|
|
90
90
|
process.exit(1);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
const { broadcast, scheduleRefresh, resolveRunProject } =
|
|
94
|
-
server,
|
|
95
|
-
{
|
|
93
|
+
const { broadcast, scheduleRefresh, resolveRunProject, getBeadsCounts } =
|
|
94
|
+
attachWsServer(server, {
|
|
96
95
|
worcaDir,
|
|
97
96
|
settingsPath,
|
|
98
97
|
prefsPath: preferencesPath(),
|
|
99
98
|
prefsDir,
|
|
100
99
|
webhookInbox,
|
|
101
100
|
projectRoot,
|
|
102
|
-
}
|
|
103
|
-
);
|
|
101
|
+
});
|
|
104
102
|
|
|
105
|
-
// Expose broadcast, scheduleRefresh, and
|
|
103
|
+
// Expose broadcast, scheduleRefresh, resolveRunProject, and getBeadsCounts to REST route handlers
|
|
106
104
|
app.locals.broadcast = broadcast;
|
|
107
105
|
app.locals.scheduleRefresh = scheduleRefresh;
|
|
108
106
|
app.locals.resolveRunProject = resolveRunProject;
|
|
107
|
+
app.locals.getBeadsCounts = getBeadsCounts;
|
|
109
108
|
|
|
110
109
|
// Boot chat integrations only in global mode — project-scoped instances skip
|
|
111
110
|
// integrations to avoid duplicate Telegram long-poll connections on the same bot.
|
|
@@ -211,6 +211,11 @@ export function createGlobalHandlers({ chatContext, prefsDir, restClient }) {
|
|
|
211
211
|
} else if (elapsed) {
|
|
212
212
|
parts.push(` **Duration:** ${elapsed}`);
|
|
213
213
|
}
|
|
214
|
+
if (run.beads_total > 0) {
|
|
215
|
+
parts.push(
|
|
216
|
+
` **Beads:** ${run.beads_done ?? 0}/${run.beads_total}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
214
219
|
lines.push(parts.join('\n'));
|
|
215
220
|
}
|
|
216
221
|
}
|
|
@@ -181,6 +181,9 @@ function fmtStatusBlock(run) {
|
|
|
181
181
|
const iterPart = iteration ? ` (iteration ${iteration})` : '';
|
|
182
182
|
parts.push(` **Stage:** ${stage}${iterPart}`);
|
|
183
183
|
}
|
|
184
|
+
if (run.beads_total > 0) {
|
|
185
|
+
parts.push(` **Beads:** ${run.beads_done ?? 0}/${run.beads_total}`);
|
|
186
|
+
}
|
|
184
187
|
if (elapsed) parts.push(` **Duration:** ${elapsed}`);
|
|
185
188
|
if (cost) parts.push(` **Cost:** ${cost}`);
|
|
186
189
|
if (ps === 'completed' && run.pr_url)
|
|
@@ -114,6 +114,9 @@ function renderStageCompleted(envelope) {
|
|
|
114
114
|
parts.push(` **Stage:** ${p.stage ?? 'unknown'} completed`);
|
|
115
115
|
const dur = fmtMs(p.duration_ms);
|
|
116
116
|
if (dur) parts.push(` **Duration:** ${dur}`);
|
|
117
|
+
if (p.beads_total > 0) {
|
|
118
|
+
parts.push(` **Beads:** ${p.beads_done ?? 0}/${p.beads_total}`);
|
|
119
|
+
}
|
|
117
120
|
return mdMsg(parts.join('\n'), 'success');
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -504,6 +507,17 @@ function renderWorkspaceGuideConflict(envelope) {
|
|
|
504
507
|
return mdMsg(parts.join('\n'), 'warning');
|
|
505
508
|
}
|
|
506
509
|
|
|
510
|
+
function renderBeadNext(envelope) {
|
|
511
|
+
const p = envelope.payload;
|
|
512
|
+
const parts = [`⚙ **Run:** \`${runId(envelope)}\``];
|
|
513
|
+
const label =
|
|
514
|
+
p.max_beads != null
|
|
515
|
+
? `${p.bead_iteration}/${p.max_beads}`
|
|
516
|
+
: `${p.bead_iteration}`;
|
|
517
|
+
parts.push(` **Bead:** ${label}`);
|
|
518
|
+
return mdMsg(parts.join('\n'), 'info');
|
|
519
|
+
}
|
|
520
|
+
|
|
507
521
|
// ---------------------------------------------------------------------------
|
|
508
522
|
// Registry
|
|
509
523
|
// ---------------------------------------------------------------------------
|
|
@@ -550,6 +564,7 @@ const EVENT_RENDERERS = {
|
|
|
550
564
|
// than Tier-1 defaults. Callers that want them can pull from this export and
|
|
551
565
|
// register them in their own pipeline.
|
|
552
566
|
export const OPT_IN_RENDERERS = {
|
|
567
|
+
'pipeline.bead.next': renderBeadNext,
|
|
553
568
|
'fleet.launched': renderFleetLaunched,
|
|
554
569
|
'workspace.launched': renderWorkspaceLaunched,
|
|
555
570
|
'workspace.plan.started': renderWorkspacePlanStarted,
|
package/server/project-routes.js
CHANGED
|
@@ -360,10 +360,29 @@ export function createProjectScopedRoutes({
|
|
|
360
360
|
});
|
|
361
361
|
|
|
362
362
|
// GET /api/projects/:projectId/runs — list runs for this project
|
|
363
|
-
router.get('/runs', requireWorcaDir, (req, res) => {
|
|
363
|
+
router.get('/runs', requireWorcaDir, async (req, res) => {
|
|
364
364
|
try {
|
|
365
365
|
const runs = discoverRuns(req.project.worcaDir);
|
|
366
366
|
const default_branch = getDefaultBranch(req.project.projectRoot);
|
|
367
|
+
|
|
368
|
+
const { getBeadsCounts } = req.app.locals;
|
|
369
|
+
if (getBeadsCounts) {
|
|
370
|
+
try {
|
|
371
|
+
const counts = await getBeadsCounts(req.project.name);
|
|
372
|
+
if (counts) {
|
|
373
|
+
for (const run of runs) {
|
|
374
|
+
const c = counts[run.id];
|
|
375
|
+
if (c) {
|
|
376
|
+
run.beads_done = c.done;
|
|
377
|
+
run.beads_total = c.total;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
/* non-fatal — runs returned without bead counts */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
367
386
|
const response = { ok: true, runs, default_branch };
|
|
368
387
|
// Include settings so multi-project clients can use loop limits, etc.
|
|
369
388
|
const { settingsPath } = req.project;
|
|
@@ -4,32 +4,108 @@
|
|
|
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
|
-
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;
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the beads.db path for a project's worcaDir, independent of any
|
|
20
|
+
* watcher instance. Used by tier-independent count lookups (chat-only mode,
|
|
21
|
+
* where no beadsWatcher exists because the WatcherSet is in TIER_POLLING).
|
|
22
|
+
* @param {string} worcaDir
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function beadsDbPathFor(worcaDir) {
|
|
26
|
+
return resolve(join(worcaDir, '..', '.beads', 'beads.db'));
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
/**
|
|
15
30
|
* @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps
|
|
16
31
|
*/
|
|
17
32
|
export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
|
|
18
|
-
const beadsDbPath =
|
|
33
|
+
const beadsDbPath = beadsDbPathFor(worcaDir);
|
|
19
34
|
const beadsDir = resolve(join(worcaDir, '..', '.beads'));
|
|
20
35
|
const beadsWalPath = `${beadsDbPath}-wal`;
|
|
21
36
|
let fsWatcher = null;
|
|
22
37
|
let BEADS_REFRESH_TIMER = null;
|
|
38
|
+
let lastPayloadJson = null;
|
|
39
|
+
let lastSelfReadWalStat = null;
|
|
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
|
+
}
|
|
23
79
|
|
|
24
80
|
function scheduleBeadsRefresh() {
|
|
25
81
|
if (BEADS_REFRESH_TIMER) clearTimeout(BEADS_REFRESH_TIMER);
|
|
26
|
-
BEADS_REFRESH_TIMER = setTimeout(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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) {
|
|
108
|
+
lastPayloadJson = payloadJson;
|
|
33
109
|
broadcaster.broadcast(
|
|
34
110
|
'beads-update',
|
|
35
111
|
{
|
|
@@ -40,10 +116,17 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
|
|
|
40
116
|
},
|
|
41
117
|
projectId,
|
|
42
118
|
);
|
|
43
|
-
} catch {
|
|
44
|
-
/* ignore */
|
|
45
119
|
}
|
|
46
|
-
|
|
120
|
+
recordWalStat();
|
|
121
|
+
} catch {
|
|
122
|
+
/* ignore */
|
|
123
|
+
} finally {
|
|
124
|
+
refreshing = false;
|
|
125
|
+
if (refreshPending) {
|
|
126
|
+
refreshPending = false;
|
|
127
|
+
scheduleBeadsRefresh();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
47
130
|
}
|
|
48
131
|
|
|
49
132
|
if (existsSync(beadsDir)) {
|
|
@@ -60,6 +143,13 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
|
|
|
60
143
|
// on macOS. watchFile tolerates a missing file; it starts firing once created.
|
|
61
144
|
watchFile(beadsWalPath, { interval: BEADS_POLL_MS }, (curr, prev) => {
|
|
62
145
|
if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) {
|
|
146
|
+
if (
|
|
147
|
+
lastSelfReadWalStat &&
|
|
148
|
+
curr.mtimeMs === lastSelfReadWalStat.mtimeMs &&
|
|
149
|
+
curr.size === lastSelfReadWalStat.size
|
|
150
|
+
) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
63
153
|
scheduleBeadsRefresh();
|
|
64
154
|
}
|
|
65
155
|
});
|
|
@@ -78,5 +168,108 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
|
|
|
78
168
|
}
|
|
79
169
|
}
|
|
80
170
|
|
|
81
|
-
|
|
171
|
+
function getLatestCounts() {
|
|
172
|
+
return latestCounts;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { getBeadsDbPath, getLatestCounts, destroy };
|
|
176
|
+
}
|
|
177
|
+
|
|
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;
|
|
183
|
+
/** @type {Map<string, { ts: number, counts: object }>} */
|
|
184
|
+
const fallbackCache = new Map();
|
|
185
|
+
/** @type {Map<string, Promise<object>>} */
|
|
186
|
+
const fallbackInflight = new Map();
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve per-run bead counts for a WatcherSet, independent of its tier.
|
|
190
|
+
*
|
|
191
|
+
* Fast path: the live watcher cache, populated only while a UI client is
|
|
192
|
+
* subscribed (TIER_FULL). Fallback: an on-demand DB read for callers that
|
|
193
|
+
* have no active watcher — e.g. chat integrations hitting REST with no
|
|
194
|
+
* browser open, where the WatcherSet sits in TIER_POLLING and `beadsWatcher`
|
|
195
|
+
* is null. The fallback is TTL-cached and in-flight-deduplicated so repeated
|
|
196
|
+
* chat polling does not spawn a `bd` subprocess per request.
|
|
197
|
+
*
|
|
198
|
+
* @param {{ projectId?: string, worcaDir?: string, beadsWatcher?: { getLatestCounts: () => object } | null }} [wset]
|
|
199
|
+
* @returns {Promise<Record<string, { total: number, done: number }>>}
|
|
200
|
+
*/
|
|
201
|
+
export async function resolveBeadsCounts(wset) {
|
|
202
|
+
if (!wset || !wset.worcaDir) return {};
|
|
203
|
+
|
|
204
|
+
const live = wset.beadsWatcher?.getLatestCounts();
|
|
205
|
+
if (live && Object.keys(live).length > 0) return live;
|
|
206
|
+
|
|
207
|
+
const key = wset.projectId ?? wset.worcaDir;
|
|
208
|
+
const cached = fallbackCache.get(key);
|
|
209
|
+
if (cached && Date.now() - cached.ts < FALLBACK_TTL_MS) return cached.counts;
|
|
210
|
+
if (fallbackInflight.has(key)) return fallbackInflight.get(key);
|
|
211
|
+
|
|
212
|
+
const dbPath = beadsDbPathFor(wset.worcaDir);
|
|
213
|
+
if (!existsSync(dbPath)) return {};
|
|
214
|
+
|
|
215
|
+
const promise = (async () => {
|
|
216
|
+
try {
|
|
217
|
+
const counts = await countIssuesByRunLabel(dbPath);
|
|
218
|
+
fallbackCache.set(key, { ts: Date.now(), counts });
|
|
219
|
+
return counts;
|
|
220
|
+
} catch {
|
|
221
|
+
return {};
|
|
222
|
+
} finally {
|
|
223
|
+
fallbackInflight.delete(key);
|
|
224
|
+
}
|
|
225
|
+
})();
|
|
226
|
+
fallbackInflight.set(key, promise);
|
|
227
|
+
return promise;
|
|
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 {};
|
|
82
275
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
package/server/ws-modular.js
CHANGED
|
@@ -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 { peekBeadsCounts } 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,17 @@ export function attachWsServer(httpServer, config) {
|
|
|
330
331
|
return null;
|
|
331
332
|
}
|
|
332
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.
|
|
336
|
+
function getBeadsCounts(projectId) {
|
|
337
|
+
return peekBeadsCounts(watcherSets.get(projectId));
|
|
338
|
+
}
|
|
339
|
+
|
|
333
340
|
return {
|
|
334
341
|
wss,
|
|
335
342
|
broadcast: broadcaster.broadcast,
|
|
336
343
|
scheduleRefresh,
|
|
337
344
|
resolveRunProject,
|
|
345
|
+
getBeadsCounts,
|
|
338
346
|
};
|
|
339
347
|
}
|