@worca/ui 0.23.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
  }
@@ -4,12 +4,11 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync, watch } from 'node:fs';
7
- import { homedir } from 'node:os';
8
7
  import { join } from 'node:path';
9
8
  import { effectiveFleetStatus } from './fleet-routes.js';
9
+ import { fleetRunsDir as resolveFleetRunsDir } from './paths.js';
10
10
 
11
11
  const FLEET_DEBOUNCE_MS = 200;
12
- const DEFAULT_FLEET_RUNS_DIR = join(homedir(), '.worca', 'fleet-runs');
13
12
 
14
13
  const FAILURE_STATES = new Set(['failed', 'setup_failed', 'unrecoverable']);
15
14
 
@@ -40,8 +39,10 @@ function resolveChildStatus(child) {
40
39
  */
41
40
  export function createFleetManifestWatcher({
42
41
  broadcaster,
43
- fleetRunsDir = DEFAULT_FLEET_RUNS_DIR,
42
+ fleetRunsDir: fleetRunsDirArg,
44
43
  }) {
44
+ // Lazy resolution honors $WORCA_HOME (issue #162).
45
+ const fleetRunsDir = resolveFleetRunsDir(fleetRunsDirArg);
45
46
  let fsWatcher = null;
46
47
  /** @type {Map<string, ReturnType<typeof setTimeout>>} */
47
48
  const debounceTimers = new Map();
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  import { existsSync, watch } from 'node:fs';
10
- import { homedir } from 'node:os';
11
10
  import { join } from 'node:path';
12
11
  import { WebSocketServer } from 'ws';
12
+ 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';
@@ -18,6 +18,7 @@ import { createClientManager } from './ws-client-manager.js';
18
18
  import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
19
19
  import { createMessageRouter } from './ws-message-router.js';
20
20
  import { resolveLatestRunDir } from './ws-status-watcher.js';
21
+ import { createWorkspaceManifestWatcher } from './ws-workspace-manifest-watcher.js';
21
22
 
22
23
  export { resolveLatestRunDir };
23
24
 
@@ -50,7 +51,13 @@ export function attachWsServer(httpServer, config) {
50
51
  // 3a. Fleet manifest watcher — global, not per-project (§13.5)
51
52
  const fleetManifestWatcher = createFleetManifestWatcher({
52
53
  broadcaster,
53
- fleetRunsDir: join(homedir(), '.worca', 'fleet-runs'),
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(),
54
61
  });
55
62
 
56
63
  // 3b. Create WatcherSet(s) — one per project
@@ -278,6 +285,7 @@ export function attachWsServer(httpServer, config) {
278
285
  wss.on('close', () => {
279
286
  clientManager.destroy();
280
287
  fleetManifestWatcher.destroy();
288
+ workspaceManifestWatcher.destroy();
281
289
  if (dirWatcher) {
282
290
  try {
283
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
+ }