@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.
- package/app/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
|
@@ -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
|
+
}
|