@worca/ui 0.11.0 → 0.13.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.
@@ -1,32 +1,64 @@
1
1
  /**
2
- * Status file watcher — monitors status.json and active_run for changes.
3
- * Owns refresh scheduling, lastPipelineStatus tracking, and the status/activeRun FSWatchers.
2
+ * Status file watcher — monitors status.json and runs/ directory for changes.
3
+ * Owns refresh scheduling, lastPipelineStatus tracking, and the status/runsDirWatcher FSWatchers.
4
4
  */
5
5
 
6
- import { existsSync, readFileSync, watch } from 'node:fs';
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ watch,
12
+ } from 'node:fs';
7
13
  import { join } from 'node:path';
8
14
  import { readSettings } from './settings-reader.js';
9
15
  import { discoverRunsAsync } from './watcher.js';
10
16
 
11
17
  const REFRESH_DEBOUNCE_MS = 75;
18
+ const WORKTREE_WATCHER_THRESHOLD = 50;
19
+ const WORKTREE_POLL_MS = 30_000;
20
+ // Display-layer: broadest set — any status that means "stop watching this run".
21
+ // Differs from runner/resume (which exclude 'failed' to keep it resumable) and
22
+ // cleanup ({completed, failed}). Here we add 'error' so the UI also stops polling
23
+ // pipelines that crashed before reaching a clean terminal state.
24
+ const TERMINAL_STATUSES = new Set([
25
+ 'completed',
26
+ 'failed',
27
+ 'error',
28
+ 'interrupted',
29
+ ]);
12
30
 
13
31
  /**
14
- * Resolve the active run directory for a given worca base dir.
15
- * Returns `<worcaDir>/runs/<runId>` as long as runId is non-empty,
16
- * without gating on the existence of status.json.
32
+ * Resolve the latest active run directory for a given worca base dir.
33
+ * Scans runs/<runId>/pipeline.pid for live processes via process.kill(pid, 0).
34
+ * Returns the run dir of the latest live run (by run ID), or worcaDir as fallback.
17
35
  *
18
36
  * @param {string} worcaDir
19
37
  * @returns {string}
20
38
  */
21
- export function resolveActiveRunDir(worcaDir) {
22
- const activeRunPath = join(worcaDir, 'active_run');
23
- if (existsSync(activeRunPath)) {
39
+ export function resolveLatestRunDir(worcaDir) {
40
+ const runsDir = join(worcaDir, 'runs');
41
+ if (existsSync(runsDir)) {
42
+ let latest = null;
24
43
  try {
25
- const runId = readFileSync(activeRunPath, 'utf8').trim();
26
- if (runId) return join(worcaDir, 'runs', runId);
44
+ for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
45
+ if (!entry.isDirectory()) continue;
46
+ const pidPath = join(runsDir, entry.name, 'pipeline.pid');
47
+ if (!existsSync(pidPath)) continue;
48
+ try {
49
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
50
+ if (!Number.isNaN(pid) && pid > 0) {
51
+ process.kill(pid, 0); // throws if dead
52
+ if (!latest || entry.name > latest) latest = entry.name;
53
+ }
54
+ } catch {
55
+ /* dead process or invalid PID */
56
+ }
57
+ }
27
58
  } catch {
28
59
  /* ignore */
29
60
  }
61
+ if (latest) return join(runsDir, latest);
30
62
  }
31
63
  return worcaDir; // legacy fallback
32
64
  }
@@ -57,14 +89,118 @@ export function createStatusWatcher({
57
89
  let watchedRunDir = null;
58
90
  let activeRunWatcher = null;
59
91
  let runsDirWatcher = null;
92
+ let pipelinesDirWatcher = null;
93
+ const worktreeRunWatchers = new Map(); // Map<run_id, FSWatcher>
94
+ let worktreePollingInterval = null;
60
95
 
61
96
  function currentActiveRunId() {
62
97
  if (!watchedRunDir) return null;
63
98
  return watchedRunDir.split('/').pop() || null;
64
99
  }
65
100
 
66
- function _resolveActiveRunDir() {
67
- return resolveActiveRunDir(worcaDir);
101
+ function _resolveLatestRunDir() {
102
+ return resolveLatestRunDir(worcaDir);
103
+ }
104
+
105
+ function reconcileWorktreeWatchers() {
106
+ const pipelinesDirPath = join(worcaDir, 'multi', 'pipelines.d');
107
+ if (!existsSync(pipelinesDirPath)) {
108
+ for (const w of worktreeRunWatchers.values()) {
109
+ try {
110
+ w.close();
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+ worktreeRunWatchers.clear();
116
+ if (worktreePollingInterval) {
117
+ clearInterval(worktreePollingInterval);
118
+ worktreePollingInterval = null;
119
+ }
120
+ return;
121
+ }
122
+
123
+ // Read all non-terminal entries from pipelines.d/
124
+ const activeEntries = new Map(); // run_id -> reg
125
+ try {
126
+ for (const entry of readdirSync(pipelinesDirPath)) {
127
+ if (!entry.endsWith('.json')) continue;
128
+ try {
129
+ const reg = JSON.parse(
130
+ readFileSync(join(pipelinesDirPath, entry), 'utf8'),
131
+ );
132
+ if (
133
+ reg.run_id &&
134
+ reg.worktree_path &&
135
+ !TERMINAL_STATUSES.has(reg.status)
136
+ ) {
137
+ activeEntries.set(reg.run_id, reg);
138
+ }
139
+ } catch {
140
+ /* ignore malformed */
141
+ }
142
+ }
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+
147
+ // >50 concurrent worktrees: fall back to periodic polling
148
+ if (activeEntries.size > WORKTREE_WATCHER_THRESHOLD) {
149
+ for (const w of worktreeRunWatchers.values()) {
150
+ try {
151
+ w.close();
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ }
156
+ worktreeRunWatchers.clear();
157
+ if (!worktreePollingInterval) {
158
+ worktreePollingInterval = setInterval(
159
+ () => scheduleRefresh(),
160
+ WORKTREE_POLL_MS,
161
+ );
162
+ }
163
+ return;
164
+ }
165
+
166
+ // Below threshold: stop polling if it was running
167
+ if (worktreePollingInterval) {
168
+ clearInterval(worktreePollingInterval);
169
+ worktreePollingInterval = null;
170
+ }
171
+
172
+ // Remove watchers for entries no longer active
173
+ for (const [runId, w] of worktreeRunWatchers) {
174
+ if (!activeEntries.has(runId)) {
175
+ try {
176
+ w.close();
177
+ } catch {
178
+ /* ignore */
179
+ }
180
+ worktreeRunWatchers.delete(runId);
181
+ }
182
+ }
183
+
184
+ // Add watchers for new active entries
185
+ for (const [runId, reg] of activeEntries) {
186
+ if (worktreeRunWatchers.has(runId)) continue;
187
+ const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
188
+ if (!existsSync(wtRunsDir)) continue;
189
+ try {
190
+ const w = watch(
191
+ wtRunsDir,
192
+ { recursive: true },
193
+ (_eventType, filename) => {
194
+ if (!filename || filename.endsWith('status.json')) {
195
+ scheduleRefresh();
196
+ }
197
+ },
198
+ );
199
+ worktreeRunWatchers.set(runId, w);
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ }
68
204
  }
69
205
 
70
206
  function scheduleRefresh() {
@@ -79,6 +215,7 @@ export function createStatusWatcher({
79
215
  }
80
216
  try {
81
217
  const runs = await discoverRunsAsync(worcaDir);
218
+ reconcileWorktreeWatchers();
82
219
  const subscribedIds = new Set();
83
220
  for (const ws of wss.clients) {
84
221
  const s = getSubs(ws);
@@ -128,7 +265,7 @@ export function createStatusWatcher({
128
265
  statusWatcher.close();
129
266
  statusWatcher = null;
130
267
  }
131
- const runDir = _resolveActiveRunDir();
268
+ const runDir = _resolveLatestRunDir();
132
269
  if (watchedRunDir !== null && runDir !== watchedRunDir) {
133
270
  if (onActiveRunChange) onActiveRunChange();
134
271
  }
@@ -181,7 +318,7 @@ export function createStatusWatcher({
181
318
  );
182
319
  } else {
183
320
  setTimeout(() => {
184
- if (_resolveActiveRunDir() === runDir) tryWatch();
321
+ if (_resolveLatestRunDir() === runDir) tryWatch();
185
322
  }, 500);
186
323
  }
187
324
  } catch {
@@ -195,19 +332,15 @@ export function createStatusWatcher({
195
332
  // Initialize status watcher
196
333
  setupStatusWatcher();
197
334
 
198
- // Watch worcaDir for active_run pointer changes
335
+ // Watch worcaDir for legacy status.json changes
199
336
  try {
200
337
  if (existsSync(worcaDir)) {
201
338
  activeRunWatcher = watch(
202
339
  worcaDir,
203
340
  { recursive: false },
204
341
  (_eventType, filename) => {
205
- if (
206
- !filename ||
207
- filename === 'active_run' ||
208
- filename === 'status.json'
209
- ) {
210
- const newRunDir = _resolveActiveRunDir();
342
+ if (!filename || filename === 'status.json') {
343
+ const newRunDir = _resolveLatestRunDir();
211
344
  if (newRunDir !== watchedRunDir) {
212
345
  setupStatusWatcher();
213
346
  }
@@ -238,6 +371,24 @@ export function createStatusWatcher({
238
371
  /* ignore */
239
372
  }
240
373
 
374
+ // Watch .worca/multi/pipelines.d/ for pipeline additions/removals.
375
+ // Create the directory eagerly so the watcher fires even on first worktree run.
376
+ const pipelinesDirPath = join(worcaDir, 'multi', 'pipelines.d');
377
+ try {
378
+ mkdirSync(pipelinesDirPath, { recursive: true });
379
+ pipelinesDirWatcher = watch(
380
+ pipelinesDirPath,
381
+ { recursive: false },
382
+ (_eventType, filename) => {
383
+ if (!filename || filename.endsWith('.json')) {
384
+ scheduleRefresh();
385
+ }
386
+ },
387
+ );
388
+ } catch {
389
+ /* ignore */
390
+ }
391
+
241
392
  function getWatchedRunDir() {
242
393
  return watchedRunDir;
243
394
  }
@@ -246,12 +397,25 @@ export function createStatusWatcher({
246
397
  if (statusWatcher) statusWatcher.close();
247
398
  if (activeRunWatcher) activeRunWatcher.close();
248
399
  if (runsDirWatcher) runsDirWatcher.close();
400
+ if (pipelinesDirWatcher) pipelinesDirWatcher.close();
401
+ for (const w of worktreeRunWatchers.values()) {
402
+ try {
403
+ w.close();
404
+ } catch {
405
+ /* ignore */
406
+ }
407
+ }
408
+ worktreeRunWatchers.clear();
409
+ if (worktreePollingInterval) {
410
+ clearInterval(worktreePollingInterval);
411
+ worktreePollingInterval = null;
412
+ }
249
413
  }
250
414
 
251
415
  return {
252
416
  scheduleRefresh,
253
417
  currentActiveRunId,
254
- resolveActiveRunDir: _resolveActiveRunDir,
418
+ resolveLatestRunDir: _resolveLatestRunDir,
255
419
  getWatchedRunDir,
256
420
  lastPipelineStatus,
257
421
  destroy,
package/server/ws.js CHANGED
@@ -2,4 +2,4 @@
2
2
  * WebSocket server entry point.
3
3
  */
4
4
 
5
- export { attachWsServer, resolveActiveRunDir } from './ws-modular.js';
5
+ export { attachWsServer, resolveLatestRunDir } from './ws-modular.js';
@@ -1,237 +0,0 @@
1
- /**
2
- * MultiWatcher — watches .worca/multi/pipelines.d/ for a project,
3
- * tracking parallel pipeline instances and their status changes.
4
- *
5
- * Each pipeline in pipelines.d/{run_id}.json is monitored. On status
6
- * changes, broadcasts 'pipeline-status-changed' events. Optionally
7
- * creates per-worktree WatcherSets for log/status streaming.
8
- */
9
-
10
- import { existsSync, watch } from 'node:fs';
11
- import { readdir, readFile } from 'node:fs/promises';
12
- import { join } from 'node:path';
13
- import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
14
-
15
- export class MultiWatcher {
16
- /**
17
- * @param {string} projectId — parent project name
18
- * @param {string} worcaDir — parent project's .worca/ directory
19
- * @param {{ broadcaster, getSubs, wss, settingsPath, projectRoot, webhookInbox }} deps
20
- */
21
- constructor(projectId, worcaDir, deps) {
22
- this.projectId = projectId;
23
- this.worcaDir = worcaDir;
24
- this._deps = deps;
25
- this._dirWatcher = null;
26
- this._debounceTimer = null;
27
- this._closed = false;
28
-
29
- /** @type {Map<string, { entry: object, watcherSet: WatcherSet|null }>} */
30
- this.pipelines = new Map();
31
- }
32
-
33
- /** Start watching pipelines.d/ directory. */
34
- start() {
35
- this._syncPipelines(); // Initial scan
36
-
37
- const pipelinesDir = join(this.worcaDir, 'multi', 'pipelines.d');
38
- if (existsSync(pipelinesDir)) {
39
- try {
40
- this._dirWatcher = watch(pipelinesDir, { persistent: false }, () => {
41
- if (this._closed) return;
42
- if (this._debounceTimer) clearTimeout(this._debounceTimer);
43
- this._debounceTimer = setTimeout(() => {
44
- this._debounceTimer = null;
45
- if (!this._closed) this._syncPipelines();
46
- }, 300);
47
- });
48
- } catch {
49
- // fs.watch not supported or dir doesn't exist — skip
50
- }
51
- }
52
- }
53
-
54
- /** Scan pipelines.d/, diff against current map, broadcast changes. */
55
- async _syncPipelines() {
56
- const pipelinesDir = join(this.worcaDir, 'multi', 'pipelines.d');
57
- const freshEntries = new Map();
58
-
59
- try {
60
- const files = await readdir(pipelinesDir);
61
- const readPromises = files
62
- .filter((f) => f.endsWith('.json'))
63
- .map(async (fname) => {
64
- try {
65
- const entry = JSON.parse(
66
- await readFile(join(pipelinesDir, fname), 'utf8'),
67
- );
68
- return entry.run_id ? [entry.run_id, entry] : null;
69
- } catch {
70
- return null;
71
- }
72
- });
73
- for (const result of await Promise.all(readPromises)) {
74
- if (result) freshEntries.set(result[0], result[1]);
75
- }
76
- } catch {
77
- // directory doesn't exist or unreadable — freshEntries stays empty
78
- }
79
-
80
- // Add new pipelines or update changed ones
81
- for (const [runId, entry] of freshEntries) {
82
- const existing = this.pipelines.get(runId);
83
- if (!existing) {
84
- this._addPipeline(runId, entry);
85
- } else if (
86
- existing.entry.status !== entry.status ||
87
- existing.entry.stage !== entry.stage
88
- ) {
89
- // Destroy WatcherSet when pipeline transitions out of running
90
- if (
91
- existing.entry.status === 'running' &&
92
- entry.status !== 'running' &&
93
- existing.watcherSet
94
- ) {
95
- try {
96
- existing.watcherSet.destroy();
97
- } catch {
98
- /* ignore */
99
- }
100
- existing.watcherSet = null;
101
- }
102
- existing.entry = entry;
103
- this._broadcastPipelineStatus(runId, entry);
104
- }
105
- }
106
-
107
- // Remove deleted pipelines
108
- for (const runId of [...this.pipelines.keys()]) {
109
- if (!freshEntries.has(runId)) {
110
- this._removePipeline(runId);
111
- }
112
- }
113
- }
114
-
115
- /** Register a new pipeline and broadcast its status. */
116
- _addPipeline(runId, entry) {
117
- let watcherSet = null;
118
-
119
- // Create a WatcherSet for running worktree pipelines
120
- if (entry.worktree_path && entry.status === 'running') {
121
- const worktreeWorcaDir = join(entry.worktree_path, '.worca');
122
- if (existsSync(worktreeWorcaDir)) {
123
- try {
124
- const pipelineProjectId = `${this.projectId}::${runId}`;
125
- watcherSet = new WatcherSet(
126
- pipelineProjectId,
127
- worktreeWorcaDir,
128
- {
129
- ...this._deps,
130
- settingsPath: join(
131
- entry.worktree_path,
132
- '.claude',
133
- 'settings.json',
134
- ),
135
- projectRoot: entry.worktree_path,
136
- },
137
- // Skip creating a nested MultiWatcher in pipeline WatcherSets
138
- { _skipMultiWatcher: true },
139
- );
140
- watcherSet.create();
141
- // Start in POLLING tier — promoted when user subscribes
142
- } catch (err) {
143
- console.error(
144
- `[MultiWatcher:${this.projectId}] Failed to create WatcherSet for pipeline ${runId}:`,
145
- err.message,
146
- );
147
- watcherSet = null;
148
- }
149
- }
150
- }
151
-
152
- this.pipelines.set(runId, { entry, watcherSet });
153
- this._broadcastPipelineStatus(runId, entry);
154
- }
155
-
156
- /** Destroy a pipeline's WatcherSet and broadcast removal. */
157
- _removePipeline(runId) {
158
- const pipeline = this.pipelines.get(runId);
159
- if (pipeline?.watcherSet) {
160
- try {
161
- pipeline.watcherSet.destroy();
162
- } catch {
163
- // ignore cleanup errors
164
- }
165
- }
166
- this.pipelines.delete(runId);
167
- this._deps.broadcaster.broadcast('pipeline-status-changed', {
168
- project: this.projectId,
169
- runId,
170
- status: 'removed',
171
- });
172
- }
173
-
174
- /** Broadcast a pipeline status change event. */
175
- _broadcastPipelineStatus(runId, entry) {
176
- this._deps.broadcaster.broadcast('pipeline-status-changed', {
177
- project: this.projectId,
178
- runId,
179
- status: entry.status,
180
- stage: entry.stage || null,
181
- title: entry.title || null,
182
- worktree_path: entry.worktree_path || null,
183
- started_at: entry.started_at || null,
184
- pid: entry.pid || null,
185
- });
186
- }
187
-
188
- /** List current pipeline entries (for list-pipelines WS request). */
189
- listPipelines() {
190
- return Array.from(this.pipelines.values()).map((p) => p.entry);
191
- }
192
-
193
- /** Get WatcherSet for a specific pipeline (for log/status streaming). */
194
- getPipelineWatcherSet(runId) {
195
- return this.pipelines.get(runId)?.watcherSet || null;
196
- }
197
-
198
- /** Promote a pipeline's watcher to FULL tier (on user subscribe). */
199
- promotePipeline(runId) {
200
- const ws = this.pipelines.get(runId)?.watcherSet;
201
- if (ws && ws.getTier() === TIER_POLLING) ws.setTier(TIER_FULL);
202
- }
203
-
204
- /** Demote a pipeline's watcher back to POLLING tier (on user unsubscribe). */
205
- demotePipeline(runId) {
206
- const ws = this.pipelines.get(runId)?.watcherSet;
207
- if (ws && ws.getTier() === TIER_FULL) ws.setTier(TIER_POLLING);
208
- }
209
-
210
- /** Destroy all pipeline watchers and close directory watcher. Idempotent. */
211
- destroy() {
212
- if (this._closed) return;
213
- this._closed = true;
214
- if (this._dirWatcher) {
215
- try {
216
- this._dirWatcher.close();
217
- } catch {
218
- // ignore
219
- }
220
- this._dirWatcher = null;
221
- }
222
- if (this._debounceTimer) {
223
- clearTimeout(this._debounceTimer);
224
- this._debounceTimer = null;
225
- }
226
- for (const { watcherSet } of this.pipelines.values()) {
227
- if (watcherSet) {
228
- try {
229
- watcherSet.destroy();
230
- } catch {
231
- // ignore cleanup errors
232
- }
233
- }
234
- }
235
- this.pipelines.clear();
236
- }
237
- }