@worca/ui 0.41.0 → 0.43.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/server/watcher.js CHANGED
@@ -2,25 +2,35 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import {
6
- assignEventsToIterations,
7
- readDispatchEventsFromJsonl,
8
- } from './dispatch-events-aggregator.js';
5
+ import { assignEventsToIterations } from './dispatch-events-aggregator.js';
6
+ import { readEventsForEnrichment } from './events-jsonl-reader.js';
7
+ import { assignGraphQueryCountsToIterations } from './graph-query-aggregator.js';
9
8
  import { readPipelineOverlay } from './run-dir-resolver.js';
10
9
  import { safeWatch } from './safe-watch.js';
11
10
 
12
11
  /**
13
- * Enrich a status object with dispatch events read from events.jsonl in the
14
- * same run directory. Mutates `status.stages` by adding `dispatch_events` to
15
- * matching iterations. No-op when events.jsonl is missing (e.g. a run that
16
- * started before the emit was wired, or a run with no dispatches).
12
+ * Enrich a status object from events.jsonl in the same run directory:
13
+ * - dispatch events `dispatch_events` per iteration (skills/subagents badges)
14
+ * - graph-query events live graphify_invocations / crg_invocations /
15
+ * crg_tool_counts for the still-running iteration (graphify/CRG badges),
16
+ * without clobbering the runner's authoritative completion-time counts.
17
+ * No-op when events.jsonl is missing (e.g. a run started before the emit was
18
+ * wired, or one with no dispatches / graph queries).
17
19
  */
18
20
  function enrichWithDispatchEvents(status, runDir) {
19
21
  if (!status?.stages) return status;
20
22
  const eventsPath = join(runDir, 'events.jsonl');
21
- const events = readDispatchEventsFromJsonl(eventsPath);
22
- if (events.length === 0) return status;
23
- status.stages = assignEventsToIterations(events, status.stages);
23
+ // Single read for both event kinds (issue #296) — was two full passes.
24
+ const { dispatchEvents, graphEvents } = readEventsForEnrichment(eventsPath);
25
+ if (dispatchEvents.length > 0) {
26
+ status.stages = assignEventsToIterations(dispatchEvents, status.stages);
27
+ }
28
+ if (graphEvents.length > 0) {
29
+ status.stages = assignGraphQueryCountsToIterations(
30
+ graphEvents,
31
+ status.stages,
32
+ );
33
+ }
24
34
  return status;
25
35
  }
26
36
 
@@ -47,7 +57,7 @@ function isTerminal(status) {
47
57
  );
48
58
  }
49
59
 
50
- const _discoverRunsCache = new Map(); // worcaDir → { ts, runs }
60
+ const _discoverRunsCache = new Map(); // `${worcaDir}|${enrich}` → { ts, runs }
51
61
  // TTL defaults to 0 under vitest (NODE_ENV=test) so the cache is a no-op in
52
62
  // tests — they build fixture dirs from Date.now() and a shared path could
53
63
  // otherwise serve a stale cached scan across tests. Production uses 1500ms;
@@ -67,14 +77,21 @@ export function _setDiscoverRunsTtlForTest(ms) {
67
77
  * single scan. Per-run handlers use findRun() instead. Live status changes
68
78
  * still reach clients via the statusWatcher broadcast, so TTL-window staleness
69
79
  * here is invisible in the UI.
80
+ *
81
+ * `enrich` (default false) controls events.jsonl enrichment. List/sidebar
82
+ * callers never render dispatch_events / graph-query counts (only the detailed
83
+ * run view does, fed by findRun), so they pass enrich:false and skip reading
84
+ * every project's events.jsonl entirely — the hot-path fix for issue #296. The
85
+ * cache is keyed on (worcaDir, enrich) since the two shapes differ.
70
86
  */
71
- export function discoverRuns(worcaDir) {
72
- const cached = _discoverRunsCache.get(worcaDir);
87
+ export function discoverRuns(worcaDir, { enrich = false } = {}) {
88
+ const cacheKey = `${worcaDir}|${enrich ? 1 : 0}`;
89
+ const cached = _discoverRunsCache.get(cacheKey);
73
90
  if (cached && Date.now() - cached.ts < _discoverRunsTtlMs) {
74
91
  return cached.runs;
75
92
  }
76
- const runs = _discoverRunsUncached(worcaDir);
77
- _discoverRunsCache.set(worcaDir, { ts: Date.now(), runs });
93
+ const runs = _discoverRunsUncached(worcaDir, { enrich });
94
+ _discoverRunsCache.set(cacheKey, { ts: Date.now(), runs });
78
95
  return runs;
79
96
  }
80
97
 
@@ -139,8 +156,12 @@ export function findRun(worcaDir, runId) {
139
156
 
140
157
  // Fallback for legacy layouts where the on-disk name != the computed id
141
158
  // (flat .worca/status.json, hashed legacy ids). Rare — pay one (TTL-cached)
142
- // full scan rather than regress correctness vs discoverRuns().find().
143
- return discoverRuns(worcaDir).find((r) => r.id === runId) || null;
159
+ // full scan rather than regress correctness vs discoverRuns().find(). Pass
160
+ // enrich:true so findRun stays fully enriched in every branch (its contract),
161
+ // even though discoverRuns now defaults enrichment off for the list path.
162
+ return (
163
+ discoverRuns(worcaDir, { enrich: true }).find((r) => r.id === runId) || null
164
+ );
144
165
  }
145
166
 
146
167
  function _shapeRunFromFile(
@@ -158,7 +179,13 @@ function _shapeRunFromFile(
158
179
  if (enrich && runDir) status = enrichWithDispatchEvents(status, runDir);
159
180
  const id = createRunId(status);
160
181
  const active = !isTerminal(status) && status.pipeline_status === 'running';
161
- const base = { id, active, ...status };
182
+ const base = {
183
+ id,
184
+ active,
185
+ ...status,
186
+ source_type: status.source_type ?? null,
187
+ source_ref: status.source_ref ?? null,
188
+ };
162
189
  if (worktreeReg) {
163
190
  return {
164
191
  ...base,
@@ -177,7 +204,7 @@ function _shapeRunFromFile(
177
204
  }
178
205
  }
179
206
 
180
- function _discoverRunsUncached(worcaDir) {
207
+ function _discoverRunsUncached(worcaDir, { enrich = false } = {}) {
181
208
  const runs = [];
182
209
  const seenIds = new Set();
183
210
 
@@ -190,13 +217,19 @@ function _discoverRunsUncached(worcaDir) {
190
217
  if (!existsSync(statusPath)) continue;
191
218
  try {
192
219
  let status = JSON.parse(readFileSync(statusPath, 'utf8'));
193
- status = enrichWithDispatchEvents(status, runDir);
220
+ if (enrich) status = enrichWithDispatchEvents(status, runDir);
194
221
  const id = createRunId(status);
195
222
  if (seenIds.has(id)) continue;
196
223
  seenIds.add(id);
197
224
  const active =
198
225
  !isTerminal(status) && status.pipeline_status === 'running';
199
- runs.push({ id, active, ...status });
226
+ runs.push({
227
+ id,
228
+ active,
229
+ ...status,
230
+ source_type: status.source_type ?? null,
231
+ source_ref: status.source_ref ?? null,
232
+ });
200
233
  } catch {
201
234
  /* ignore */
202
235
  }
@@ -212,7 +245,13 @@ function _discoverRunsUncached(worcaDir) {
212
245
  if (!seenIds.has(id)) {
213
246
  const active =
214
247
  !isTerminal(status) && status.pipeline_status === 'running';
215
- runs.push({ id, active, ...status });
248
+ runs.push({
249
+ id,
250
+ active,
251
+ ...status,
252
+ source_type: status.source_type ?? null,
253
+ source_ref: status.source_ref ?? null,
254
+ });
216
255
  seenIds.add(id);
217
256
  }
218
257
  } catch {
@@ -236,7 +275,13 @@ function _discoverRunsUncached(worcaDir) {
236
275
  seenIds.add(id);
237
276
  const active =
238
277
  !isTerminal(data) && data.pipeline_status === 'running';
239
- runs.push({ id, active, ...data });
278
+ runs.push({
279
+ id,
280
+ active,
281
+ ...data,
282
+ source_type: data.source_type ?? null,
283
+ source_ref: data.source_ref ?? null,
284
+ });
240
285
  }
241
286
  }
242
287
  } else if (entry.isDirectory()) {
@@ -249,7 +294,13 @@ function _discoverRunsUncached(worcaDir) {
249
294
  seenIds.add(id);
250
295
  const active =
251
296
  !isTerminal(data) && data.pipeline_status === 'running';
252
- runs.push({ id, active, ...data });
297
+ runs.push({
298
+ id,
299
+ active,
300
+ ...data,
301
+ source_type: data.source_type ?? null,
302
+ source_ref: data.source_ref ?? null,
303
+ });
253
304
  }
254
305
  }
255
306
  }
@@ -274,10 +325,12 @@ function _discoverRunsUncached(worcaDir) {
274
325
  if (!existsSync(sp)) continue;
275
326
  try {
276
327
  let status = JSON.parse(readFileSync(sp, 'utf8'));
277
- status = enrichWithDispatchEvents(
278
- status,
279
- join(wtRunsDir, runEntry),
280
- );
328
+ if (enrich) {
329
+ status = enrichWithDispatchEvents(
330
+ status,
331
+ join(wtRunsDir, runEntry),
332
+ );
333
+ }
281
334
  const id = createRunId(status);
282
335
  if (seenIds.has(id)) continue;
283
336
  seenIds.add(id);
@@ -287,6 +340,8 @@ function _discoverRunsUncached(worcaDir) {
287
340
  id,
288
341
  active,
289
342
  ...status,
343
+ source_type: status.source_type ?? null,
344
+ source_ref: status.source_ref ?? null,
290
345
  worktree_worca_dir: join(reg.worktree_path, '.worca'),
291
346
  is_worktree_run: true,
292
347
  head_branch: reg.branch || null,
@@ -309,10 +364,13 @@ function _discoverRunsUncached(worcaDir) {
309
364
  }
310
365
 
311
366
  /**
312
- * Async version of discoverRuns — avoids blocking the event loop.
313
- * Used by the status watcher's debounced refresh.
367
+ * Async version of discoverRuns — avoids blocking the event loop. Used by the
368
+ * status watcher's debounced refresh (enrich:true, since it broadcasts
369
+ * run-snapshot to the detailed view's live update) and by the list-path REST /
370
+ * WS handlers (enrich:false — issue #296, keeps the all-projects scan off the
371
+ * event loop without reading every events.jsonl).
314
372
  */
315
- export async function discoverRunsAsync(worcaDir) {
373
+ export async function discoverRunsAsync(worcaDir, { enrich = false } = {}) {
316
374
  const runs = [];
317
375
  const seenIds = new Set();
318
376
 
@@ -332,13 +390,21 @@ export async function discoverRunsAsync(worcaDir) {
332
390
  });
333
391
  for (const result of await Promise.all(readPromises)) {
334
392
  if (!result) continue;
335
- const status = enrichWithDispatchEvents(result.status, result.runDir);
393
+ const status = enrich
394
+ ? enrichWithDispatchEvents(result.status, result.runDir)
395
+ : result.status;
336
396
  const id = createRunId(status);
337
397
  if (seenIds.has(id)) continue;
338
398
  seenIds.add(id);
339
399
  const active =
340
400
  !isTerminal(status) && status.pipeline_status === 'running';
341
- runs.push({ id, active, ...status });
401
+ runs.push({
402
+ id,
403
+ active,
404
+ ...status,
405
+ source_type: status.source_type ?? null,
406
+ source_ref: status.source_ref ?? null,
407
+ });
342
408
  }
343
409
  } catch {
344
410
  /* ignore */
@@ -353,7 +419,13 @@ export async function discoverRunsAsync(worcaDir) {
353
419
  if (!seenIds.has(id)) {
354
420
  const active =
355
421
  !isTerminal(status) && status.pipeline_status === 'running';
356
- runs.push({ id, active, ...status });
422
+ runs.push({
423
+ id,
424
+ active,
425
+ ...status,
426
+ source_type: status.source_type ?? null,
427
+ source_ref: status.source_ref ?? null,
428
+ });
357
429
  seenIds.add(id);
358
430
  }
359
431
  } catch {
@@ -386,7 +458,13 @@ export async function discoverRunsAsync(worcaDir) {
386
458
  if (!seenIds.has(id)) {
387
459
  seenIds.add(id);
388
460
  const active = !isTerminal(data) && data.pipeline_status === 'running';
389
- runs.push({ id, active, ...data });
461
+ runs.push({
462
+ id,
463
+ active,
464
+ ...data,
465
+ source_type: data.source_type ?? null,
466
+ source_ref: data.source_ref ?? null,
467
+ });
390
468
  }
391
469
  }
392
470
  } catch {
@@ -417,10 +495,12 @@ export async function discoverRunsAsync(worcaDir) {
417
495
  try {
418
496
  const sp = join(wtRunsDir, runEntry, 'status.json');
419
497
  let status = JSON.parse(await readFile(sp, 'utf8'));
420
- status = enrichWithDispatchEvents(
421
- status,
422
- join(wtRunsDir, runEntry),
423
- );
498
+ if (enrich) {
499
+ status = enrichWithDispatchEvents(
500
+ status,
501
+ join(wtRunsDir, runEntry),
502
+ );
503
+ }
424
504
  results.push({ status, reg });
425
505
  } catch {
426
506
  /* ignore */
@@ -442,6 +522,8 @@ export async function discoverRunsAsync(worcaDir) {
442
522
  id,
443
523
  active,
444
524
  ...status,
525
+ source_type: status.source_type ?? null,
526
+ source_ref: status.source_ref ?? null,
445
527
  worktree_worca_dir: join(reg.worktree_path, '.worca'),
446
528
  is_worktree_run: true,
447
529
  head_branch: reg.branch || null,
@@ -14,10 +14,11 @@ export function createBroadcaster({ wss, getSubs }) {
14
14
  * Build a message envelope. For protocol 2 clients with a projectId,
15
15
  * a `project` field is added to the top-level message.
16
16
  */
17
- function sendToClient(ws, baseMsg) {
17
+ function sendToClient(ws, baseMsg, sourceProjectId) {
18
18
  const s = getSubs(ws);
19
- if (s && s.protocolVersion >= 2 && s.projectId) {
20
- ws.send(JSON.stringify({ ...baseMsg, project: s.projectId }));
19
+ const project = sourceProjectId || s?.projectId || null;
20
+ if (s && s.protocolVersion >= 2 && project) {
21
+ ws.send(JSON.stringify({ ...baseMsg, project }));
21
22
  } else {
22
23
  ws.send(JSON.stringify(baseMsg));
23
24
  }
@@ -44,7 +45,7 @@ export function createBroadcaster({ wss, getSubs }) {
44
45
  )
45
46
  continue;
46
47
  }
47
- sendToClient(ws, base);
48
+ sendToClient(ws, base, projectId);
48
49
  }
49
50
  }
50
51
 
@@ -17,6 +17,7 @@ import {
17
17
  listIssuesByLabel,
18
18
  listUnlinkedIssues,
19
19
  } from './beads-reader.js';
20
+ import { buildFileAccessModel } from './file-access-aggregator.js';
20
21
  import {
21
22
  listIterationFiles,
22
23
  listLogFiles,
@@ -33,7 +34,7 @@ import {
33
34
  } from './process-manager.js';
34
35
  import { resolveRunDir } from './run-dir-resolver.js';
35
36
  import { readSettings } from './settings-reader.js';
36
- import { discoverRuns, findRun } from './watcher.js';
37
+ import { discoverRunsAsync, findRun } from './watcher.js';
37
38
  import { resolveBeadsCounts } from './ws-beads-watcher.js';
38
39
 
39
40
  /**
@@ -142,7 +143,8 @@ export function createMessageRouter({
142
143
  // list-runs
143
144
  if (req.type === 'list-runs') {
144
145
  const proj = resolveProject(ws, req.payload);
145
- const runs = discoverRuns(proj.worcaDir);
146
+ // List/sidebar path: async scan, no events.jsonl enrichment (issue #296).
147
+ const runs = await discoverRunsAsync(proj.worcaDir, { enrich: false });
146
148
  const settings = readSettings(proj.settingsPath);
147
149
  ws.send(JSON.stringify(makeOk(req, { runs, settings })));
148
150
  return;
@@ -807,6 +809,25 @@ export function createMessageRouter({
807
809
  return;
808
810
  }
809
811
 
812
+ // get-file-access
813
+ if (req.type === 'get-file-access') {
814
+ const { runId } = req.payload || {};
815
+ if (typeof runId !== 'string') {
816
+ ws.send(
817
+ JSON.stringify(
818
+ makeError(req, 'bad_request', 'payload.runId required'),
819
+ ),
820
+ );
821
+ return;
822
+ }
823
+ const proj = resolveProject(ws, req.payload);
824
+ const runDir = resolveRunDir(proj.worcaDir, runId);
825
+ const eventsPath = runDir ? join(runDir, 'events.jsonl') : null;
826
+ const model = buildFileAccessModel(eventsPath, runDir);
827
+ ws.send(JSON.stringify(makeOk(req, { runId, ...model })));
828
+ return;
829
+ }
830
+
810
831
  // subscribe-events
811
832
  if (req.type === 'subscribe-events') {
812
833
  const { runId } = req.payload || {};
@@ -247,7 +247,11 @@ export function createStatusWatcher({
247
247
  /* ignore */
248
248
  }
249
249
  try {
250
- const runs = await discoverRunsAsync(worcaDir);
250
+ // enrich:true run-snapshot broadcasts below feed the detailed run
251
+ // view's live update, which renders dispatch_events / graph-query counts
252
+ // for the still-running iteration (issue #296 keeps the list path lean,
253
+ // not this one).
254
+ const runs = await discoverRunsAsync(worcaDir, { enrich: true });
251
255
  reconcileWorktreeWatchers();
252
256
  const subscribedIds = new Set();
253
257
  for (const ws of wss.clients) {