@worca/ui 0.22.0 → 0.24.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.
@@ -59,7 +59,18 @@ export async function removeWorktree(
59
59
  /* ignore */
60
60
  }
61
61
  if (isRealDir) {
62
- await rm(worktreePath, { recursive: true, force: true });
62
+ // maxRetries handles transient ENOTEMPTY/EBUSY/EPERM on macOS when a
63
+ // background process (Spotlight, language servers, npm install
64
+ // finishing) touches a deep node_modules subtree between the
65
+ // recursive walk's readdir and the final rmdir. Without retries,
66
+ // a single race surfaces "ENOTEMPTY: directory not empty, rmdir
67
+ // .../node_modules/lucide/dist" to the user.
68
+ await rm(worktreePath, {
69
+ recursive: true,
70
+ force: true,
71
+ maxRetries: 5,
72
+ retryDelay: 200,
73
+ });
63
74
  }
64
75
  }
65
76
  }
@@ -262,6 +262,17 @@ function _patchRegistry(worcaDir, runId, patch) {
262
262
  }
263
263
  }
264
264
 
265
+ function _isPidAlive(pid) {
266
+ if (!pid || typeof pid !== 'number') return false;
267
+ try {
268
+ process.kill(pid, 0);
269
+ return true;
270
+ } catch (err) {
271
+ if (err.code === 'EPERM') return true;
272
+ return false;
273
+ }
274
+ }
275
+
265
276
  async function _listWorktrees(worcaDir) {
266
277
  const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
267
278
  if (!existsSync(pipelinesDir)) return [];
@@ -290,6 +301,29 @@ async function _listWorktrees(worcaDir) {
290
301
  if (actual) status = actual;
291
302
  }
292
303
 
304
+ // Stale-registry reconciliation: a child can die before ever writing
305
+ // status.json (e.g. fleet halt right after dispatch, preflight crash,
306
+ // SIGKILL). In that case the worktree exists but .worca/runs/ doesn't,
307
+ // _readWorktreeStatus returns null, and we'd fall back to reg.status
308
+ // which may still say "running" with a dead pid. Treat that as
309
+ // "interrupted" and patch the registry so this only happens once.
310
+ //
311
+ // Only reconcile when reg.pid is present — a missing pid means the
312
+ // entry is either from a non-standard registration path (e.g. test
313
+ // fixtures) or pre-dates the pid-on-registration contract, so we
314
+ // can't make liveness claims about it.
315
+ if (
316
+ status === 'running' &&
317
+ typeof reg.pid === 'number' &&
318
+ !_isPidAlive(reg.pid)
319
+ ) {
320
+ status = 'interrupted';
321
+ _patchRegistry(worcaDir, reg.run_id, {
322
+ status: 'interrupted',
323
+ interrupted_reason: 'stale_pid',
324
+ });
325
+ }
326
+
293
327
  let ageSeconds = 0;
294
328
  if (reg.started_at) {
295
329
  const started = new Date(reg.started_at).getTime();
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Fleet manifest watcher — monitors ~/.worca/fleet-runs/<fleet_id>.json for changes.
3
+ * Emits fleet-update WS events when a fleet manifest is written (§13.5).
4
+ */
5
+
6
+ import { existsSync, readFileSync, watch } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { effectiveFleetStatus } from './fleet-routes.js';
9
+ import { fleetRunsDir as resolveFleetRunsDir } from './paths.js';
10
+
11
+ const FLEET_DEBOUNCE_MS = 200;
12
+
13
+ const FAILURE_STATES = new Set(['failed', 'setup_failed', 'unrecoverable']);
14
+
15
+ function readJson(path) {
16
+ try {
17
+ return JSON.parse(readFileSync(path, 'utf8'));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function resolveChildStatus(child) {
24
+ const { project_path, run_id } = child;
25
+ if (!project_path || !run_id) return 'running';
26
+ const registryPath = join(
27
+ project_path,
28
+ '.worca',
29
+ 'multi',
30
+ 'pipelines.d',
31
+ `${run_id}.json`,
32
+ );
33
+ const entry = readJson(registryPath);
34
+ return entry?.status ?? 'running';
35
+ }
36
+
37
+ /**
38
+ * @param {{ broadcaster: { broadcast: Function }, fleetRunsDir?: string }} deps
39
+ */
40
+ export function createFleetManifestWatcher({
41
+ broadcaster,
42
+ fleetRunsDir: fleetRunsDirArg,
43
+ }) {
44
+ // Lazy resolution honors $WORCA_HOME (issue #162).
45
+ const fleetRunsDir = resolveFleetRunsDir(fleetRunsDirArg);
46
+ let fsWatcher = null;
47
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
48
+ const debounceTimers = new Map();
49
+
50
+ function broadcastFleetUpdate(fleetId, manifestPath) {
51
+ const manifest = readJson(manifestPath);
52
+ if (!manifest) return;
53
+
54
+ const rawChildren = Array.isArray(manifest.children)
55
+ ? manifest.children
56
+ : [];
57
+ const children = rawChildren.map((child) => ({
58
+ run_id: child.run_id,
59
+ project_path: child.project_path,
60
+ status: resolveChildStatus(child),
61
+ }));
62
+
63
+ const completed_children = children.filter(
64
+ (c) => c.status === 'completed',
65
+ ).length;
66
+ const failed_children = children.filter((c) =>
67
+ FAILURE_STATES.has(c.status),
68
+ ).length;
69
+
70
+ // Derive the effective status (same rules as REST) instead of broadcasting
71
+ // raw manifest.status — otherwise cards stay "running" forever, because
72
+ // run_fleet.py never writes a terminal status after it exits. Pure
73
+ // function: persists nothing, so we don't trigger a watch→write→watch loop.
74
+ const { status, halt_reason } = effectiveFleetStatus(
75
+ manifest,
76
+ children.map((c) => c.status),
77
+ );
78
+
79
+ broadcaster.broadcast('fleet-update', {
80
+ fleet_id: fleetId,
81
+ status,
82
+ halt_reason,
83
+ completed_children,
84
+ failed_children,
85
+ children,
86
+ });
87
+ }
88
+
89
+ function scheduleUpdate(fleetId, manifestPath) {
90
+ const existing = debounceTimers.get(fleetId);
91
+ if (existing) clearTimeout(existing);
92
+ debounceTimers.set(
93
+ fleetId,
94
+ setTimeout(() => {
95
+ debounceTimers.delete(fleetId);
96
+ broadcastFleetUpdate(fleetId, manifestPath);
97
+ }, FLEET_DEBOUNCE_MS),
98
+ );
99
+ }
100
+
101
+ try {
102
+ if (existsSync(fleetRunsDir)) {
103
+ fsWatcher = watch(
104
+ fleetRunsDir,
105
+ { persistent: false },
106
+ (_event, filename) => {
107
+ if (!filename?.endsWith('.json')) return;
108
+ const fleetId = filename.slice(0, -5);
109
+ scheduleUpdate(fleetId, join(fleetRunsDir, filename));
110
+ },
111
+ );
112
+ }
113
+ } catch {
114
+ // fs.watch unsupported or dir unavailable — skip silently
115
+ }
116
+
117
+ function destroy() {
118
+ if (fsWatcher) {
119
+ try {
120
+ fsWatcher.close();
121
+ } catch {
122
+ /* ignore */
123
+ }
124
+ fsWatcher = null;
125
+ }
126
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
127
+ debounceTimers.clear();
128
+ }
129
+
130
+ return { destroy };
131
+ }
@@ -80,6 +80,22 @@ export function createMessageRouter({
80
80
  };
81
81
  }
82
82
 
83
+ // When a run-scoped subscribe arrives with a `payload.projectId` that
84
+ // differs from the client's currently-bound subs.projectId, re-bind the
85
+ // WS to that project. Without this, the targeted project's WatcherSet
86
+ // stays in POLLING tier (no logWatcher / no live updates), and the
87
+ // backfill / live stream silently never arrive — symptoms reported by
88
+ // the user as "no logs / no agent prompts" on the run-detail page in
89
+ // global mode. We keep the previous projectId reference so demotion
90
+ // still happens via the normal client-count mechanism.
91
+ function _adoptProjectFromPayload(ws, payload) {
92
+ const requested = payload?.projectId;
93
+ if (!requested || !watcherSets.has(requested)) return;
94
+ const subs = clientManager.getSubs(ws);
95
+ if (subs?.projectId === requested) return;
96
+ clientManager.setProtocol(ws, subs?.protocolVersion ?? 1, requested);
97
+ }
98
+
83
99
  async function handleMessage(ws, data) {
84
100
  let json;
85
101
  try {
@@ -147,6 +163,7 @@ export function createMessageRouter({
147
163
  );
148
164
  return;
149
165
  }
166
+ _adoptProjectFromPayload(ws, req.payload);
150
167
  const proj = resolveProject(ws, req.payload);
151
168
  const runs = discoverRuns(proj.worcaDir);
152
169
  const run = runs.find((r) => r.id === runId);
@@ -294,6 +311,7 @@ export function createMessageRouter({
294
311
  );
295
312
  return;
296
313
  }
314
+ _adoptProjectFromPayload(ws, req.payload);
297
315
  const proj = resolveProject(ws, req.payload);
298
316
  if (!proj) {
299
317
  ws.send(
@@ -336,6 +354,7 @@ export function createMessageRouter({
336
354
  // subscribe-log
337
355
  if (req.type === 'subscribe-log') {
338
356
  const { stage, runId, iteration } = req.payload || {};
357
+ _adoptProjectFromPayload(ws, req.payload);
339
358
  const proj = resolveProject(ws, req.payload);
340
359
  const s = clientManager.ensureSubs(ws);
341
360
  s.logStage = stage || '*';
@@ -652,6 +671,7 @@ export function createMessageRouter({
652
671
  );
653
672
  return;
654
673
  }
674
+ _adoptProjectFromPayload(ws, req.payload);
655
675
  const proj = resolveProject(ws, req.payload);
656
676
  if (!proj.wset.beadsWatcher) {
657
677
  ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
@@ -9,13 +9,16 @@
9
9
  import { existsSync, watch } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { WebSocketServer } from 'ws';
12
+ import { fleetRunsDir, workspaceRunsDir } from './paths.js';
12
13
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
13
14
  import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
14
15
  import { readProjectWorcaVersion } from './worca-setup.js';
15
16
  import { createBroadcaster } from './ws-broadcaster.js';
16
17
  import { createClientManager } from './ws-client-manager.js';
18
+ import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
17
19
  import { createMessageRouter } from './ws-message-router.js';
18
20
  import { resolveLatestRunDir } from './ws-status-watcher.js';
21
+ import { createWorkspaceManifestWatcher } from './ws-workspace-manifest-watcher.js';
19
22
 
20
23
  export { resolveLatestRunDir };
21
24
 
@@ -45,7 +48,19 @@ export function attachWsServer(httpServer, config) {
45
48
  getSubs: clientManager.getSubs,
46
49
  });
47
50
 
48
- // 3. Create WatcherSet(s)one per project
51
+ // 3a. Fleet manifest watcher global, not per-project (§13.5)
52
+ const fleetManifestWatcher = createFleetManifestWatcher({
53
+ broadcaster,
54
+ fleetRunsDir: fleetRunsDir(),
55
+ });
56
+
57
+ // 3a-ws. Workspace manifest watcher — global, separate from fleet (W-047 §13.5)
58
+ const workspaceManifestWatcher = createWorkspaceManifestWatcher({
59
+ broadcaster,
60
+ workspaceRunsDir: workspaceRunsDir(),
61
+ });
62
+
63
+ // 3b. Create WatcherSet(s) — one per project
49
64
  /** @type {Map<string, WatcherSet>} */
50
65
  const watcherSets = new Map();
51
66
 
@@ -269,6 +284,8 @@ export function attachWsServer(httpServer, config) {
269
284
 
270
285
  wss.on('close', () => {
271
286
  clientManager.destroy();
287
+ fleetManifestWatcher.destroy();
288
+ workspaceManifestWatcher.destroy();
272
289
  if (dirWatcher) {
273
290
  try {
274
291
  dirWatcher.close();
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Workspace manifest watcher — monitors ~/.worca/workspace-runs/ for pointer
3
+ * file changes, reads the actual manifest, and broadcasts workspace-update,
4
+ * workspace-tier-update, and guide-conflict WS events.
5
+ *
6
+ * Separate from fleet-update per W-040 §13.5 — never multiplexed.
7
+ */
8
+
9
+ import { existsSync, readFileSync, watch } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { workspaceRunsDir as resolveWorkspaceRunsDir } from './paths.js';
12
+
13
+ const WS_DEBOUNCE_MS = 200;
14
+
15
+ function readJson(path) {
16
+ try {
17
+ return JSON.parse(readFileSync(path, 'utf8'));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function readManifestFromPointer(wsRunsDir, wsId) {
24
+ const pointer = readJson(join(wsRunsDir, `${wsId}.json`));
25
+ if (!pointer?.workspace_root) return null;
26
+ const manifestPath = join(
27
+ pointer.workspace_root,
28
+ '.worca',
29
+ 'workspace-runs',
30
+ wsId,
31
+ 'workspace-manifest.json',
32
+ );
33
+ return readJson(manifestPath);
34
+ }
35
+
36
+ /**
37
+ * @param {{ broadcaster: { broadcast: Function }, workspaceRunsDir?: string }} deps
38
+ */
39
+ export function createWorkspaceManifestWatcher({
40
+ broadcaster,
41
+ workspaceRunsDir: workspaceRunsDirArg,
42
+ }) {
43
+ // Lazy resolution honors $WORCA_HOME (issue #162).
44
+ const workspaceRunsDir = resolveWorkspaceRunsDir(workspaceRunsDirArg);
45
+
46
+ let fsWatcher = null;
47
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
48
+ const debounceTimers = new Map();
49
+
50
+ function broadcastWorkspaceUpdate(wsId) {
51
+ const manifest = readManifestFromPointer(workspaceRunsDir, wsId);
52
+ if (!manifest) return;
53
+
54
+ const children = Array.isArray(manifest.children) ? manifest.children : [];
55
+ const dag = manifest.dag ?? { tiers: [] };
56
+
57
+ broadcaster.broadcast('workspace-update', {
58
+ workspace_id: wsId,
59
+ workspace_name: manifest.workspace_name ?? null,
60
+ status: manifest.status ?? 'running',
61
+ halt_reason: manifest.halt_reason ?? null,
62
+ dag,
63
+ children: children.map((c) => ({
64
+ repo: c.repo,
65
+ run_id: c.run_id,
66
+ status: c.status,
67
+ tier: c.tier,
68
+ })),
69
+ integration_test: manifest.integration_test ?? null,
70
+ });
71
+
72
+ const tiers = dag.tiers ?? [];
73
+ for (const tier of tiers) {
74
+ broadcaster.broadcast('workspace-tier-update', {
75
+ workspace_id: wsId,
76
+ tier: tier.tier,
77
+ repos: tier.repos,
78
+ status: tier.status,
79
+ });
80
+ }
81
+
82
+ const conflicts = manifest.guide_conflicts;
83
+ if (Array.isArray(conflicts) && conflicts.length > 0) {
84
+ broadcaster.broadcast('guide-conflict', {
85
+ workspace_id: wsId,
86
+ conflicts,
87
+ });
88
+ }
89
+ }
90
+
91
+ function scheduleUpdate(wsId) {
92
+ const existing = debounceTimers.get(wsId);
93
+ if (existing) clearTimeout(existing);
94
+ debounceTimers.set(
95
+ wsId,
96
+ setTimeout(() => {
97
+ debounceTimers.delete(wsId);
98
+ broadcastWorkspaceUpdate(wsId);
99
+ }, WS_DEBOUNCE_MS),
100
+ );
101
+ }
102
+
103
+ try {
104
+ if (existsSync(workspaceRunsDir)) {
105
+ fsWatcher = watch(
106
+ workspaceRunsDir,
107
+ { persistent: false },
108
+ (_event, filename) => {
109
+ if (!filename?.endsWith('.json')) return;
110
+ const wsId = filename.slice(0, -5);
111
+ scheduleUpdate(wsId);
112
+ },
113
+ );
114
+ }
115
+ } catch {
116
+ // fs.watch unsupported or dir unavailable — skip silently
117
+ }
118
+
119
+ function destroy() {
120
+ if (fsWatcher) {
121
+ try {
122
+ fsWatcher.close();
123
+ } catch {
124
+ /* ignore */
125
+ }
126
+ fsWatcher = null;
127
+ }
128
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
129
+ debounceTimers.clear();
130
+ }
131
+
132
+ return {
133
+ destroy,
134
+ _broadcastForTest: broadcastWorkspaceUpdate,
135
+ };
136
+ }