@worca/ui 0.34.0 → 0.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/server/watcher.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  assignEventsToIterations,
7
7
  readDispatchEventsFromJsonl,
8
8
  } from './dispatch-events-aggregator.js';
9
+ import { readPipelineOverlay } from './run-dir-resolver.js';
9
10
  import { safeWatch } from './safe-watch.js';
10
11
 
11
12
  /**
@@ -46,7 +47,137 @@ function isTerminal(status) {
46
47
  );
47
48
  }
48
49
 
50
+ const _discoverRunsCache = new Map(); // worcaDir → { ts, runs }
51
+ // TTL defaults to 0 under vitest (NODE_ENV=test) so the cache is a no-op in
52
+ // tests — they build fixture dirs from Date.now() and a shared path could
53
+ // otherwise serve a stale cached scan across tests. Production uses 1500ms;
54
+ // _setDiscoverRunsTtlForTest lets the dedicated cache test exercise real TTL.
55
+ let _discoverRunsTtlMs = process.env.NODE_ENV === 'test' ? 0 : 1500;
56
+
57
+ /** Test hook: override the discoverRuns cache TTL in ms. */
58
+ export function _setDiscoverRunsTtlForTest(ms) {
59
+ _discoverRunsTtlMs = ms;
60
+ }
61
+
62
+ /**
63
+ * Cached wrapper around the run-discovery scan. The scan reads + JSON-parses
64
+ * every run's status.json across runs/, results/, and pipelines.d/ worktree
65
+ * overlays — hundreds of ms on a large project. Whole-list callers (list-runs,
66
+ * REST /runs) hit this; a short TTL collapses repeated calls in a burst into a
67
+ * single scan. Per-run handlers use findRun() instead. Live status changes
68
+ * still reach clients via the statusWatcher broadcast, so TTL-window staleness
69
+ * here is invisible in the UI.
70
+ */
49
71
  export function discoverRuns(worcaDir) {
72
+ const cached = _discoverRunsCache.get(worcaDir);
73
+ if (cached && Date.now() - cached.ts < _discoverRunsTtlMs) {
74
+ return cached.runs;
75
+ }
76
+ const runs = _discoverRunsUncached(worcaDir);
77
+ _discoverRunsCache.set(worcaDir, { ts: Date.now(), runs });
78
+ return runs;
79
+ }
80
+
81
+ /** Clear the discoverRuns TTL cache (tests, or explicit invalidation). */
82
+ export function clearDiscoverRunsCache() {
83
+ _discoverRunsCache.clear();
84
+ }
85
+
86
+ /**
87
+ * Resolve a SINGLE run by id without scanning every run on disk — the O(1)
88
+ * counterpart to discoverRuns().find(r => r.id === runId), for hot WS handlers
89
+ * (subscribe-run, get-agent-prompt) that need exactly one run. Mirrors
90
+ * discoverRuns' per-source shaping: dispatch-event enrichment for runs/ and
91
+ * worktree sources (not results/), plus the worktree registry fields. The
92
+ * findRun-vs-discoverRuns parity test keeps the two aligned.
93
+ *
94
+ * Falls back to a (TTL-cached) discoverRuns scan for legacy layouts where the
95
+ * on-disk name doesn't equal the computed id (flat `.worca/status.json`, hashed
96
+ * legacy ids), so it never resolves fewer runs than discoverRuns().find().
97
+ *
98
+ * @returns {object|null} a run record shaped like a discoverRuns entry, or null
99
+ */
100
+ export function findRun(worcaDir, runId) {
101
+ if (!worcaDir || !runId) return null;
102
+
103
+ // 1. Local active: runs/<id>/status.json (enriched)
104
+ const localRunDir = join(worcaDir, 'runs', runId);
105
+ if (existsSync(join(localRunDir, 'status.json'))) {
106
+ return _shapeRunFromFile(join(localRunDir, 'status.json'), {
107
+ enrich: true,
108
+ runDir: localRunDir,
109
+ });
110
+ }
111
+
112
+ // 2. Local archived dir: results/<id>/status.json (not enriched)
113
+ const resultsDirStatus = join(worcaDir, 'results', runId, 'status.json');
114
+ if (existsSync(resultsDirStatus)) {
115
+ return _shapeRunFromFile(resultsDirStatus, { enrich: false });
116
+ }
117
+
118
+ // 2b. Legacy archived file: results/<id>.json (not enriched)
119
+ const legacyFile = join(worcaDir, 'results', `${runId}.json`);
120
+ if (existsSync(legacyFile)) {
121
+ return _shapeRunFromFile(legacyFile, {
122
+ enrich: false,
123
+ requireStartedAt: true,
124
+ });
125
+ }
126
+
127
+ // 3. Worktree overlay: pipelines.d/<id>.json → <worktree>/.worca/runs/<id>
128
+ const reg = readPipelineOverlay(worcaDir, runId);
129
+ if (reg?.worktree_path) {
130
+ const wtRunDir = join(reg.worktree_path, '.worca', 'runs', runId);
131
+ if (existsSync(join(wtRunDir, 'status.json'))) {
132
+ return _shapeRunFromFile(join(wtRunDir, 'status.json'), {
133
+ enrich: true,
134
+ runDir: wtRunDir,
135
+ worktreeReg: reg,
136
+ });
137
+ }
138
+ }
139
+
140
+ // Fallback for legacy layouts where the on-disk name != the computed id
141
+ // (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;
144
+ }
145
+
146
+ function _shapeRunFromFile(
147
+ statusPath,
148
+ {
149
+ enrich = false,
150
+ runDir = null,
151
+ worktreeReg = null,
152
+ requireStartedAt = false,
153
+ } = {},
154
+ ) {
155
+ try {
156
+ let status = JSON.parse(readFileSync(statusPath, 'utf8'));
157
+ if (requireStartedAt && !status.started_at) return null;
158
+ if (enrich && runDir) status = enrichWithDispatchEvents(status, runDir);
159
+ const id = createRunId(status);
160
+ const active = !isTerminal(status) && status.pipeline_status === 'running';
161
+ const base = { id, active, ...status };
162
+ if (worktreeReg) {
163
+ return {
164
+ ...base,
165
+ worktree_worca_dir: join(worktreeReg.worktree_path, '.worca'),
166
+ is_worktree_run: true,
167
+ head_branch: worktreeReg.branch || null,
168
+ fleet_id: worktreeReg.fleet_id || null,
169
+ workspace_id: worktreeReg.workspace_id || null,
170
+ group_type: worktreeReg.group_type || null,
171
+ target_branch: worktreeReg.target_branch || null,
172
+ };
173
+ }
174
+ return base;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function _discoverRunsUncached(worcaDir) {
50
181
  const runs = [];
51
182
  const seenIds = new Set();
52
183
 
@@ -33,7 +33,7 @@ import {
33
33
  } from './process-manager.js';
34
34
  import { resolveRunDir } from './run-dir-resolver.js';
35
35
  import { readSettings } from './settings-reader.js';
36
- import { discoverRuns } from './watcher.js';
36
+ import { discoverRuns, findRun } from './watcher.js';
37
37
  import { resolveBeadsCounts } from './ws-beads-watcher.js';
38
38
 
39
39
  /**
@@ -165,8 +165,7 @@ export function createMessageRouter({
165
165
  }
166
166
  _adoptProjectFromPayload(ws, req.payload);
167
167
  const proj = resolveProject(ws, req.payload);
168
- const runs = discoverRuns(proj.worcaDir);
169
- const run = runs.find((r) => r.id === runId);
168
+ const run = findRun(proj.worcaDir, runId);
170
169
  if (!run) {
171
170
  ws.send(
172
171
  JSON.stringify(makeError(req, 'NOT_FOUND', `Run ${runId} not found`)),
@@ -321,8 +320,7 @@ export function createMessageRouter({
321
320
  }
322
321
  const s = clientManager.ensureSubs(ws);
323
322
  s.runId = runId;
324
- const runs = discoverRuns(proj.worcaDir);
325
- const run = runs.find((r) => r.id === runId);
323
+ const run = findRun(proj.worcaDir, runId);
326
324
  if (run) {
327
325
  if (
328
326
  run.pipeline_status !== undefined &&