@worca/ui 0.30.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.
- package/app/main.bundle.js +732 -731
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +11 -3
- package/package.json +1 -1
- package/server/beads-reader.js +16 -9
- 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 +86 -3
- package/server/ws-message-router.js +5 -12
- package/server/ws-modular.js +6 -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;
|
|
@@ -4779,9 +4786,9 @@ sl-details.learnings-panel::part(content) {
|
|
|
4779
4786
|
|
|
4780
4787
|
.bead-tooltip-header {
|
|
4781
4788
|
display: flex;
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
gap:
|
|
4789
|
+
flex-direction: column;
|
|
4790
|
+
align-items: flex-start;
|
|
4791
|
+
gap: 4px;
|
|
4785
4792
|
}
|
|
4786
4793
|
|
|
4787
4794
|
sl-tooltip.bead-tooltip::part(body) {
|
|
@@ -4790,6 +4797,7 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4790
4797
|
|
|
4791
4798
|
.bead-tooltip-badges {
|
|
4792
4799
|
display: flex;
|
|
4800
|
+
flex-wrap: wrap;
|
|
4793
4801
|
align-items: center;
|
|
4794
4802
|
gap: 4px;
|
|
4795
4803
|
}
|
package/package.json
CHANGED
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,17 +36,18 @@ 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
43
|
async function enrichWithDeps(issues, dbPath) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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) {
|
|
@@ -89,10 +97,9 @@ export async function listUnlinkedIssues(beadsDb) {
|
|
|
89
97
|
const labels = d?.labels || [];
|
|
90
98
|
return !labels.some((l) => l.startsWith('run:'));
|
|
91
99
|
});
|
|
92
|
-
// detailed already has dependencies, use them directly
|
|
93
100
|
return unlinked.map((i) => {
|
|
94
101
|
const d = detailMap.get(i.id);
|
|
95
|
-
return transformIssue(i, d?.dependencies || []);
|
|
102
|
+
return transformIssue(d || i, d?.dependencies || []);
|
|
96
103
|
});
|
|
97
104
|
} catch {
|
|
98
105
|
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,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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 { 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
|
}
|