@worca/ui 0.1.0-rc.1

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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Modular WebSocket server — facade wiring 7 extracted modules.
3
+ * Drop-in replacement for ws-legacy.js with identical behavior.
4
+ *
5
+ * Supports multi-project mode via WatcherSet map when projects.d/ exists.
6
+ * Supports dynamic project add/remove via fs.watch on projects.d/.
7
+ */
8
+
9
+ import { existsSync, watch } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { WebSocketServer } from 'ws';
12
+ import { readProjects, synthesizeDefaultProject } from './project-registry.js';
13
+ import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
14
+ import { createBroadcaster } from './ws-broadcaster.js';
15
+ import { createClientManager } from './ws-client-manager.js';
16
+ import { createMessageRouter } from './ws-message-router.js';
17
+ import { resolveActiveRunDir } from './ws-status-watcher.js';
18
+
19
+ export { resolveActiveRunDir };
20
+
21
+ /**
22
+ * Attach a WebSocket server to an existing HTTP server.
23
+ *
24
+ * @param {import('node:http').Server} httpServer
25
+ * @param {{ worcaDir: string, settingsPath: string, prefsPath: string, prefsDir?: string }} config
26
+ */
27
+ export function attachWsServer(httpServer, config) {
28
+ const {
29
+ worcaDir,
30
+ settingsPath,
31
+ prefsPath,
32
+ webhookInbox,
33
+ projectRoot,
34
+ prefsDir,
35
+ } = config;
36
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
37
+
38
+ // 1. Client manager — owns subs WeakMap and heartbeat
39
+ const clientManager = createClientManager({ wss });
40
+
41
+ // 2. Broadcaster — stateless, uses wss.clients + subs
42
+ const broadcaster = createBroadcaster({
43
+ wss,
44
+ getSubs: clientManager.getSubs,
45
+ });
46
+
47
+ // 3. Create WatcherSet(s) — one per project
48
+ /** @type {Map<string, WatcherSet>} */
49
+ const watcherSets = new Map();
50
+
51
+ const projects = prefsDir ? readProjects(prefsDir) : [];
52
+ if (projects.length > 0) {
53
+ // Multi-project mode — start in Polling tier (promoted on client subscribe)
54
+ for (const proj of projects) {
55
+ const ws = new WatcherSet(
56
+ proj.name,
57
+ proj.worcaDir || join(proj.path, '.worca'),
58
+ {
59
+ broadcaster,
60
+ getSubs: clientManager.getSubs,
61
+ wss,
62
+ settingsPath:
63
+ proj.settingsPath || join(proj.path, '.claude', 'settings.json'),
64
+ projectRoot: proj.path,
65
+ webhookInbox,
66
+ },
67
+ );
68
+ ws.create();
69
+ watcherSets.set(proj.name, ws);
70
+ }
71
+ } else {
72
+ // Single-project mode — start in Full tier (backward compatible)
73
+ const effectiveRoot =
74
+ projectRoot || (worcaDir ? join(worcaDir, '..') : process.cwd());
75
+ const synth = synthesizeDefaultProject(effectiveRoot);
76
+ const effectiveWorcaDir = worcaDir || synth.worcaDir;
77
+ const ws = new WatcherSet(synth.name, effectiveWorcaDir, {
78
+ broadcaster,
79
+ getSubs: clientManager.getSubs,
80
+ wss,
81
+ settingsPath,
82
+ projectRoot,
83
+ webhookInbox,
84
+ });
85
+ ws.create();
86
+ ws.setTier(TIER_FULL);
87
+ watcherSets.set(synth.name, ws);
88
+ }
89
+
90
+ // Default WatcherSet — used by message router (Phase 1a: UI is single-project)
91
+ let defaultWs = watcherSets.values().next().value;
92
+
93
+ // 4. Dynamic project watching — watch projects.d/ for add/remove
94
+ let dirWatcher = null;
95
+ let debounceTimer = null;
96
+
97
+ if (prefsDir) {
98
+ const projectsDir = join(prefsDir, 'projects.d');
99
+ try {
100
+ if (existsSync(projectsDir)) {
101
+ dirWatcher = watch(projectsDir, { persistent: false }, () => {
102
+ if (debounceTimer) clearTimeout(debounceTimer);
103
+ debounceTimer = setTimeout(() => {
104
+ debounceTimer = null;
105
+ _syncProjects();
106
+ }, 500);
107
+ });
108
+ }
109
+ } catch {
110
+ // fs.watch not supported or dir doesn't exist yet — skip
111
+ }
112
+ }
113
+
114
+ function _syncProjects() {
115
+ if (!prefsDir) return;
116
+ const freshProjects = readProjects(prefsDir);
117
+ const freshNames = new Set(freshProjects.map((p) => p.name));
118
+ const currentNames = new Set(watcherSets.keys());
119
+
120
+ // Add new projects
121
+ for (const proj of freshProjects) {
122
+ if (!currentNames.has(proj.name)) {
123
+ const ws = new WatcherSet(
124
+ proj.name,
125
+ proj.worcaDir || join(proj.path, '.worca'),
126
+ {
127
+ broadcaster,
128
+ getSubs: clientManager.getSubs,
129
+ wss,
130
+ settingsPath:
131
+ proj.settingsPath || join(proj.path, '.claude', 'settings.json'),
132
+ projectRoot: proj.path,
133
+ webhookInbox,
134
+ },
135
+ );
136
+ ws.create();
137
+ watcherSets.set(proj.name, ws);
138
+ }
139
+ }
140
+
141
+ // Remove deleted projects
142
+ for (const name of currentNames) {
143
+ if (!freshNames.has(name)) {
144
+ const wset = watcherSets.get(name);
145
+ if (wset) {
146
+ wset.destroy();
147
+ watcherSets.delete(name);
148
+ }
149
+ }
150
+ }
151
+
152
+ // Update default — set to null when all projects removed (fix #5)
153
+ if (watcherSets.size > 0) {
154
+ defaultWs = watcherSets.values().next().value;
155
+ } else {
156
+ defaultWs = null;
157
+ }
158
+
159
+ // Broadcast projects-updated to all clients
160
+ const projectList = freshProjects.map((p) => ({
161
+ name: p.name,
162
+ path: p.path,
163
+ }));
164
+ broadcaster.broadcast('projects-updated', { projects: projectList });
165
+ }
166
+
167
+ // 5. Tier management — promote/demote based on client subscriptions
168
+ clientManager.onClientCountChange((projectId, count) => {
169
+ const wset = watcherSets.get(projectId);
170
+ if (!wset) return;
171
+ if (count > 0 && wset.getTier() === TIER_POLLING) {
172
+ wset.setTier(TIER_FULL);
173
+ } else if (count === 0 && wset.getTier() === TIER_FULL) {
174
+ // Demote after a grace period to avoid flip-flop on page refresh
175
+ setTimeout(() => {
176
+ if (
177
+ clientManager.getProjectClientCount(projectId) === 0 &&
178
+ wset.getTier() === TIER_FULL
179
+ ) {
180
+ wset.setTier(TIER_POLLING);
181
+ }
182
+ }, 5000);
183
+ }
184
+ });
185
+
186
+ // 6. Message router — resolves project per-request via watcherSets
187
+ // Pass defaultWs via getter so the router always sees the current value (fix #6)
188
+ const messageRouter = createMessageRouter({
189
+ watcherSets,
190
+ getDefaultWs: () => defaultWs,
191
+ prefsPath,
192
+ webhookInbox,
193
+ clientManager,
194
+ broadcaster,
195
+ });
196
+
197
+ /**
198
+ * Scoped scheduleRefresh: with projectName refreshes one, without refreshes all.
199
+ */
200
+ function scheduleRefresh(projectName) {
201
+ if (projectName) {
202
+ const ws = watcherSets.get(projectName);
203
+ if (ws) {
204
+ ws.scheduleRefresh();
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ for (const ws of watcherSets.values()) ws.scheduleRefresh();
210
+ return true;
211
+ }
212
+
213
+ // Connection lifecycle
214
+ wss.on('connection', (ws) => {
215
+ ws.isAlive = true;
216
+ clientManager.ensureSubs(ws);
217
+
218
+ // Send hello handshake to all clients (both single- and multi-project mode).
219
+ // Protocol 2 clients reply with hello-ack; protocol 1 clients ignore it.
220
+ ws.send(
221
+ JSON.stringify({
222
+ id: `evt-${Date.now()}`,
223
+ ok: true,
224
+ type: 'hello',
225
+ payload: {
226
+ protocol: 2,
227
+ capabilities: prefsDir ? ['multi-project'] : [],
228
+ },
229
+ }),
230
+ );
231
+
232
+ // Timeout: if no hello-ack in 2s, client stays at protocol 1 (legacy)
233
+ const helloTimeout = setTimeout(() => {
234
+ // No-op: client stays at protocol 1 by default
235
+ }, 2000);
236
+ ws._helloTimeout = helloTimeout;
237
+
238
+ ws.on('pong', () => {
239
+ ws.isAlive = true;
240
+ });
241
+
242
+ ws.on('message', (data) => {
243
+ messageRouter.handleMessage(ws, data);
244
+ });
245
+
246
+ ws.on('close', () => {
247
+ // Clear hello timeout if still pending (fix #17)
248
+ if (ws._helloTimeout) {
249
+ clearTimeout(ws._helloTimeout);
250
+ ws._helloTimeout = null;
251
+ }
252
+ const s = clientManager.getSubs(ws);
253
+ const eventsRunId = s?.eventsRunId;
254
+ // Resolve the correct project's WatcherSet for cleanup (fix #4)
255
+ const projectId = s?.projectId || null;
256
+ clientManager.deleteSubs(ws);
257
+ if (eventsRunId) {
258
+ const wset = (projectId && watcherSets.get(projectId)) || defaultWs;
259
+ if (wset?.eventWatcher) {
260
+ wset.eventWatcher.maybeCloseEventWatcher(eventsRunId);
261
+ }
262
+ }
263
+ });
264
+ });
265
+
266
+ wss.on('close', () => {
267
+ clientManager.destroy();
268
+ if (dirWatcher) {
269
+ try {
270
+ dirWatcher.close();
271
+ } catch {
272
+ /* ignore */
273
+ }
274
+ dirWatcher = null;
275
+ }
276
+ if (debounceTimer) {
277
+ clearTimeout(debounceTimer);
278
+ debounceTimer = null;
279
+ }
280
+ for (const ws of watcherSets.values()) {
281
+ ws.destroy();
282
+ }
283
+ watcherSets.clear();
284
+ });
285
+
286
+ /**
287
+ * Resolve which project a run belongs to by checking watcherSets.
288
+ * @param {string} runId
289
+ * @returns {string|null} projectId or null
290
+ */
291
+ function resolveRunProject(runId) {
292
+ if (!runId) return null;
293
+ for (const [projectId, wset] of watcherSets) {
294
+ const runsPath = join(wset.worcaDir, 'runs', runId);
295
+ const resultsPath = join(wset.worcaDir, 'results', runId);
296
+ if (existsSync(runsPath) || existsSync(resultsPath)) {
297
+ return projectId;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ return {
304
+ wss,
305
+ broadcast: broadcaster.broadcast,
306
+ scheduleRefresh,
307
+ resolveRunProject,
308
+ };
309
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Status file watcher — monitors status.json and active_run for changes.
3
+ * Owns refresh scheduling, lastPipelineStatus tracking, and the status/activeRun FSWatchers.
4
+ */
5
+
6
+ import { existsSync, readFileSync, watch } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { readSettings } from './settings-reader.js';
9
+ import { discoverRunsAsync } from './watcher.js';
10
+
11
+ const REFRESH_DEBOUNCE_MS = 75;
12
+
13
+ /**
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.
17
+ *
18
+ * @param {string} worcaDir
19
+ * @returns {string}
20
+ */
21
+ export function resolveActiveRunDir(worcaDir) {
22
+ const activeRunPath = join(worcaDir, 'active_run');
23
+ if (existsSync(activeRunPath)) {
24
+ try {
25
+ const runId = readFileSync(activeRunPath, 'utf8').trim();
26
+ if (runId) return join(worcaDir, 'runs', runId);
27
+ } catch {
28
+ /* ignore */
29
+ }
30
+ }
31
+ return worcaDir; // legacy fallback
32
+ }
33
+
34
+ /**
35
+ * @param {{
36
+ * worcaDir: string,
37
+ * settingsPath: string,
38
+ * broadcaster: { broadcast: Function, broadcastToSubscribers: Function },
39
+ * getSubs: Function,
40
+ * wss: import('ws').WebSocketServer,
41
+ * onActiveRunChange?: () => void,
42
+ * projectId?: string
43
+ * }} deps
44
+ */
45
+ export function createStatusWatcher({
46
+ worcaDir,
47
+ settingsPath,
48
+ broadcaster,
49
+ getSubs,
50
+ wss,
51
+ onActiveRunChange,
52
+ projectId,
53
+ }) {
54
+ let REFRESH_TIMER = null;
55
+ const lastPipelineStatus = new Map();
56
+ let statusWatcher = null;
57
+ let watchedRunDir = null;
58
+ let activeRunWatcher = null;
59
+ let runsDirWatcher = null;
60
+
61
+ function currentActiveRunId() {
62
+ if (!watchedRunDir) return null;
63
+ return watchedRunDir.split('/').pop() || null;
64
+ }
65
+
66
+ function _resolveActiveRunDir() {
67
+ return resolveActiveRunDir(worcaDir);
68
+ }
69
+
70
+ function scheduleRefresh() {
71
+ if (REFRESH_TIMER) clearTimeout(REFRESH_TIMER);
72
+ REFRESH_TIMER = setTimeout(async () => {
73
+ REFRESH_TIMER = null;
74
+ let settings = {};
75
+ try {
76
+ settings = readSettings(settingsPath);
77
+ } catch {
78
+ /* ignore */
79
+ }
80
+ try {
81
+ const runs = await discoverRunsAsync(worcaDir);
82
+ const subscribedIds = new Set();
83
+ for (const ws of wss.clients) {
84
+ const s = getSubs(ws);
85
+ if (s?.runId) subscribedIds.add(s.runId);
86
+ }
87
+ // Evict stale entries from lastPipelineStatus (fix #18)
88
+ const activeRunIds = new Set(runs.map((r) => r.id));
89
+ for (const id of lastPipelineStatus.keys()) {
90
+ if (!activeRunIds.has(id)) lastPipelineStatus.delete(id);
91
+ }
92
+
93
+ for (const run of runs) {
94
+ if (subscribedIds.has(run.id)) {
95
+ broadcaster.broadcastToSubscribers(run.id, 'run-snapshot', run);
96
+ }
97
+ const currStatus = run.pipeline_status;
98
+ if (currStatus !== undefined) {
99
+ const prevStatus = lastPipelineStatus.get(run.id);
100
+ if (prevStatus !== undefined && prevStatus !== currStatus) {
101
+ if (currStatus === 'paused') {
102
+ broadcaster.broadcastToSubscribers(run.id, 'pipeline-paused', {
103
+ runId: run.id,
104
+ pipeline_status: currStatus,
105
+ });
106
+ } else if (
107
+ currStatus === 'running' &&
108
+ (prevStatus === 'paused' || prevStatus === 'resuming')
109
+ ) {
110
+ broadcaster.broadcastToSubscribers(run.id, 'pipeline-resumed', {
111
+ runId: run.id,
112
+ pipeline_status: currStatus,
113
+ });
114
+ }
115
+ }
116
+ lastPipelineStatus.set(run.id, currStatus);
117
+ }
118
+ }
119
+ broadcaster.broadcast('runs-list', { runs, settings }, projectId);
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ }, REFRESH_DEBOUNCE_MS);
124
+ }
125
+
126
+ function setupStatusWatcher() {
127
+ if (statusWatcher) {
128
+ statusWatcher.close();
129
+ statusWatcher = null;
130
+ }
131
+ const runDir = _resolveActiveRunDir();
132
+ if (watchedRunDir !== null && runDir !== watchedRunDir) {
133
+ if (onActiveRunChange) onActiveRunChange();
134
+ }
135
+ watchedRunDir = runDir;
136
+
137
+ function tryWatch() {
138
+ if (statusWatcher) return;
139
+ try {
140
+ const statusFile = join(runDir, 'status.json');
141
+ if (existsSync(statusFile)) {
142
+ // Watch the file directly — on macOS, kqueue directory watchers
143
+ // don't fire for in-place content modifications of existing files.
144
+ // Watching the file itself ensures we detect status.json writes.
145
+ //
146
+ // IMPORTANT: On macOS kqueue, atomic writes (write-to-temp +
147
+ // rename-over) replace the inode. After one 'rename' event the
148
+ // watcher goes dead because it tracked the old inode. We
149
+ // re-establish the watcher on the new file after a short delay.
150
+ statusWatcher = watch(statusFile, (eventType) => {
151
+ scheduleRefresh();
152
+ if (eventType === 'rename') {
153
+ // File replaced (atomic write) — re-watch the new inode
154
+ try {
155
+ statusWatcher.close();
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ statusWatcher = null;
160
+ setTimeout(() => tryWatch(), 50);
161
+ }
162
+ });
163
+ } else if (existsSync(runDir)) {
164
+ // status.json doesn't exist yet — watch the directory for its creation,
165
+ // then switch to watching the file once it appears.
166
+ statusWatcher = watch(
167
+ runDir,
168
+ { recursive: false },
169
+ (_eventType, filename) => {
170
+ if (!filename || filename === 'status.json') {
171
+ const statusPath = join(runDir, 'status.json');
172
+ if (existsSync(statusPath)) {
173
+ // status.json appeared — switch to file-level watch
174
+ statusWatcher.close();
175
+ statusWatcher = null;
176
+ tryWatch();
177
+ }
178
+ scheduleRefresh();
179
+ }
180
+ },
181
+ );
182
+ } else {
183
+ setTimeout(() => {
184
+ if (_resolveActiveRunDir() === runDir) tryWatch();
185
+ }, 500);
186
+ }
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ }
191
+
192
+ tryWatch();
193
+ }
194
+
195
+ // Initialize status watcher
196
+ setupStatusWatcher();
197
+
198
+ // Watch worcaDir for active_run pointer changes
199
+ try {
200
+ if (existsSync(worcaDir)) {
201
+ activeRunWatcher = watch(
202
+ worcaDir,
203
+ { recursive: false },
204
+ (_eventType, filename) => {
205
+ if (
206
+ !filename ||
207
+ filename === 'active_run' ||
208
+ filename === 'status.json'
209
+ ) {
210
+ const newRunDir = _resolveActiveRunDir();
211
+ if (newRunDir !== watchedRunDir) {
212
+ setupStatusWatcher();
213
+ }
214
+ scheduleRefresh();
215
+ }
216
+ },
217
+ );
218
+ }
219
+ } catch {
220
+ /* ignore */
221
+ }
222
+
223
+ // Watch .worca/runs/ for status changes in ANY run (concurrent pipelines)
224
+ const runsDir = join(worcaDir, 'runs');
225
+ try {
226
+ if (existsSync(runsDir)) {
227
+ runsDirWatcher = watch(
228
+ runsDir,
229
+ { recursive: true },
230
+ (_eventType, filename) => {
231
+ if (!filename || filename.endsWith('status.json')) {
232
+ scheduleRefresh();
233
+ }
234
+ },
235
+ );
236
+ }
237
+ } catch {
238
+ /* ignore */
239
+ }
240
+
241
+ function getWatchedRunDir() {
242
+ return watchedRunDir;
243
+ }
244
+
245
+ function destroy() {
246
+ if (statusWatcher) statusWatcher.close();
247
+ if (activeRunWatcher) activeRunWatcher.close();
248
+ if (runsDirWatcher) runsDirWatcher.close();
249
+ }
250
+
251
+ return {
252
+ scheduleRefresh,
253
+ currentActiveRunId,
254
+ resolveActiveRunDir: _resolveActiveRunDir,
255
+ getWatchedRunDir,
256
+ lastPipelineStatus,
257
+ destroy,
258
+ };
259
+ }
package/server/ws.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * WebSocket server entry point.
3
+ */
4
+
5
+ export { attachWsServer, resolveActiveRunDir } from './ws-modular.js';