@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,286 @@
1
+ /**
2
+ * WatcherSet — groups all file watchers for a single project.
3
+ * Wraps createStatusWatcher, createLogWatcher, createBeadsWatcher, createEventWatcher
4
+ * into a single lifecycle-managed unit.
5
+ *
6
+ * Supports activity-based tiering:
7
+ * - Full: all 4 watchers active (75ms debounce)
8
+ * - Polling: status watcher only (5s debounce)
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { MultiWatcher } from './multi-watcher.js';
14
+ import { createBeadsWatcher } from './ws-beads-watcher.js';
15
+ import { createEventWatcher } from './ws-event-watcher.js';
16
+ import { createLogWatcher } from './ws-log-watcher.js';
17
+ import { createStatusWatcher } from './ws-status-watcher.js';
18
+
19
+ export const TIER_FULL = 'full';
20
+ export const TIER_POLLING = 'polling';
21
+
22
+ export class WatcherSet {
23
+ /**
24
+ * @param {string} projectId
25
+ * @param {string} worcaDir
26
+ * @param {{ broadcaster, getSubs, wss, settingsPath, projectRoot, webhookInbox }} deps
27
+ * @param {object} [factoryOverrides] - Optional factory overrides for testing
28
+ */
29
+ constructor(projectId, worcaDir, deps, factoryOverrides = {}) {
30
+ this.projectId = projectId;
31
+ this._worcaDir = worcaDir;
32
+ this._deps = deps;
33
+ this._closed = false;
34
+ this._tier = TIER_POLLING;
35
+ this._skipMultiWatcher = !!factoryOverrides._skipMultiWatcher;
36
+ const { _skipMultiWatcher, ...factories } = factoryOverrides;
37
+ this._factories = {
38
+ createStatusWatcher,
39
+ createLogWatcher,
40
+ createBeadsWatcher,
41
+ createEventWatcher,
42
+ ...factories,
43
+ };
44
+
45
+ /** @type {ReturnType<typeof createStatusWatcher> | null} */
46
+ this.statusWatcher = null;
47
+ /** @type {ReturnType<typeof createLogWatcher> | null} */
48
+ this.logWatcher = null;
49
+ /** @type {ReturnType<typeof createBeadsWatcher> | null} */
50
+ this.beadsWatcher = null;
51
+ /** @type {ReturnType<typeof createEventWatcher> | null} */
52
+ this.eventWatcher = null;
53
+ /** @type {MultiWatcher | null} */
54
+ this.multiWatcher = null;
55
+ }
56
+
57
+ get worcaDir() {
58
+ return this._worcaDir;
59
+ }
60
+ get settingsPath() {
61
+ return this._deps.settingsPath;
62
+ }
63
+ get projectRoot() {
64
+ return this._deps.projectRoot;
65
+ }
66
+
67
+ /** Get current tier. */
68
+ getTier() {
69
+ return this._tier;
70
+ }
71
+
72
+ /**
73
+ * Set tier. Promote to Full creates missing watchers, demote to Polling
74
+ * destroys log/beads/event watchers.
75
+ */
76
+ setTier(tier) {
77
+ if (tier === this._tier || this._closed) return;
78
+ const oldTier = this._tier;
79
+ this._tier = tier;
80
+
81
+ if (tier === TIER_FULL && oldTier === TIER_POLLING) {
82
+ // Promote: create log, beads, event watchers
83
+ this._createSecondaryWatchers();
84
+ } else if (tier === TIER_POLLING && oldTier === TIER_FULL) {
85
+ // Demote: destroy log, beads, event watchers
86
+ this._destroySecondaryWatchers();
87
+ }
88
+ }
89
+
90
+ /** Create all watchers. Starts in current tier (creates status always, others only if Full). */
91
+ create() {
92
+ this._createStatusWatcher();
93
+ if (this._tier === TIER_FULL) {
94
+ this._createSecondaryWatchers();
95
+ }
96
+ // Start multi-pipeline watcher (skip for pipeline-level WatcherSets to avoid recursion)
97
+ if (!this._skipMultiWatcher) {
98
+ this._createMultiWatcher();
99
+ }
100
+ }
101
+
102
+ /** Create multi-pipeline watcher for this project's .worca/multi/pipelines.d/. */
103
+ _createMultiWatcher() {
104
+ try {
105
+ this.multiWatcher = new MultiWatcher(
106
+ this.projectId,
107
+ this._worcaDir,
108
+ this._deps,
109
+ );
110
+ this.multiWatcher.start();
111
+ } catch (err) {
112
+ console.error(
113
+ `[WatcherSet:${this.projectId}] multiWatcher failed:`,
114
+ err.message,
115
+ );
116
+ this.multiWatcher = null;
117
+ }
118
+ }
119
+
120
+ /** Create status watcher (always needed). */
121
+ _createStatusWatcher() {
122
+ const { broadcaster, getSubs, wss, settingsPath } = this._deps;
123
+ const worcaDir = this._worcaDir;
124
+
125
+ try {
126
+ this.statusWatcher = this._factories.createStatusWatcher({
127
+ worcaDir,
128
+ settingsPath,
129
+ broadcaster,
130
+ getSubs,
131
+ wss,
132
+ projectId: this.projectId,
133
+ onActiveRunChange: () => {
134
+ if (this.logWatcher) this.logWatcher.clearLogWatchers();
135
+ },
136
+ });
137
+ } catch (err) {
138
+ console.error(
139
+ `[WatcherSet:${this.projectId}] statusWatcher failed:`,
140
+ err.message,
141
+ );
142
+ this.statusWatcher = null;
143
+ }
144
+ }
145
+
146
+ /** Create secondary watchers (log, beads, event). */
147
+ _createSecondaryWatchers() {
148
+ const { broadcaster, getSubs, wss } = this._deps;
149
+ const worcaDir = this._worcaDir;
150
+
151
+ // Log watcher
152
+ if (!this.logWatcher) {
153
+ try {
154
+ this.logWatcher = this._factories.createLogWatcher({
155
+ broadcaster,
156
+ resolveActiveRunDir: this.statusWatcher
157
+ ? this.statusWatcher.resolveActiveRunDir
158
+ : () => worcaDir,
159
+ worcaDir,
160
+ currentActiveRunId: this.statusWatcher
161
+ ? this.statusWatcher.currentActiveRunId
162
+ : () => null,
163
+ });
164
+ } catch (err) {
165
+ console.error(
166
+ `[WatcherSet:${this.projectId}] logWatcher failed:`,
167
+ err.message,
168
+ );
169
+ this.logWatcher = null;
170
+ }
171
+ }
172
+
173
+ // Beads watcher
174
+ if (!this.beadsWatcher) {
175
+ try {
176
+ this.beadsWatcher = this._factories.createBeadsWatcher({
177
+ worcaDir,
178
+ broadcaster,
179
+ projectId: this.projectId,
180
+ });
181
+ } catch (err) {
182
+ console.error(
183
+ `[WatcherSet:${this.projectId}] beadsWatcher failed:`,
184
+ err.message,
185
+ );
186
+ this.beadsWatcher = null;
187
+ }
188
+ }
189
+
190
+ // Event watcher
191
+ if (!this.eventWatcher) {
192
+ try {
193
+ const resolveRunDirById = (runId) => {
194
+ const candidates = [
195
+ join(worcaDir, 'runs', runId),
196
+ join(worcaDir, 'results', runId),
197
+ ];
198
+ for (const c of candidates) {
199
+ if (existsSync(c)) return c;
200
+ }
201
+ return join(worcaDir, 'runs', runId);
202
+ };
203
+
204
+ this.eventWatcher = this._factories.createEventWatcher({
205
+ broadcaster,
206
+ getSubs,
207
+ wss,
208
+ resolveRunDirById,
209
+ });
210
+ } catch (err) {
211
+ console.error(
212
+ `[WatcherSet:${this.projectId}] eventWatcher failed:`,
213
+ err.message,
214
+ );
215
+ this.eventWatcher = null;
216
+ }
217
+ }
218
+ }
219
+
220
+ /** Destroy secondary watchers (keep status). */
221
+ _destroySecondaryWatchers() {
222
+ for (const w of [this.logWatcher, this.beadsWatcher, this.eventWatcher]) {
223
+ try {
224
+ w?.destroy();
225
+ } catch {
226
+ // ignore cleanup errors
227
+ }
228
+ }
229
+ this.logWatcher = null;
230
+ this.beadsWatcher = null;
231
+ this.eventWatcher = null;
232
+ }
233
+
234
+ /** Destroy all child watchers. Idempotent. */
235
+ destroy() {
236
+ if (this._closed) return;
237
+ this._closed = true;
238
+
239
+ if (this.multiWatcher) {
240
+ try {
241
+ this.multiWatcher.destroy();
242
+ } catch {
243
+ // ignore cleanup errors
244
+ }
245
+ this.multiWatcher = null;
246
+ }
247
+
248
+ for (const w of [
249
+ this.statusWatcher,
250
+ this.logWatcher,
251
+ this.beadsWatcher,
252
+ this.eventWatcher,
253
+ ]) {
254
+ try {
255
+ w?.destroy();
256
+ } catch {
257
+ // ignore cleanup errors
258
+ }
259
+ }
260
+ }
261
+
262
+ /** Check if this WatcherSet is still usable. */
263
+ isAlive() {
264
+ return !this._closed && existsSync(this._worcaDir);
265
+ }
266
+
267
+ /** Approximate number of active watcher modules. */
268
+ getWatcherCount() {
269
+ let count = 0;
270
+ if (this.statusWatcher) count++;
271
+ if (this.logWatcher) count++;
272
+ if (this.beadsWatcher) count++;
273
+ if (this.eventWatcher) count++;
274
+ return count;
275
+ }
276
+
277
+ /** Get multi-pipeline watcher (may be null for pipeline-level WatcherSets). */
278
+ getMultiWatcher() {
279
+ return this.multiWatcher;
280
+ }
281
+
282
+ /** Delegate to status watcher's scheduleRefresh. */
283
+ scheduleRefresh() {
284
+ this.statusWatcher?.scheduleRefresh();
285
+ }
286
+ }
@@ -0,0 +1,357 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync, watch } from 'node:fs';
3
+ import { readdir, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+
6
+ export function createRunId(status) {
7
+ // Prefer run_id from status (new per-run format)
8
+ if (status.run_id) return status.run_id;
9
+ // Legacy: hash-based ID
10
+ const key = `${status.started_at}:${status.work_request?.title || ''}`;
11
+ return createHash('sha256').update(key).digest('hex').slice(0, 12);
12
+ }
13
+
14
+ function isTerminal(status) {
15
+ if (status.completed_at) return true;
16
+ if (!status.stages) return false;
17
+ const values = Object.values(status.stages);
18
+ return (
19
+ values.length > 0 &&
20
+ values.every(
21
+ (s) =>
22
+ s.status === 'completed' ||
23
+ s.status === 'error' ||
24
+ s.status === 'interrupted',
25
+ )
26
+ );
27
+ }
28
+
29
+ function isPipelineRunning(worcaDir) {
30
+ const pidPath = join(worcaDir, 'pipeline.pid');
31
+ if (!existsSync(pidPath)) return false;
32
+ try {
33
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
34
+ process.kill(pid, 0); // signal 0 = check if alive
35
+ return true;
36
+ } catch {
37
+ return false; // stale PID or unreadable
38
+ }
39
+ }
40
+
41
+ export function discoverRuns(worcaDir) {
42
+ const runs = [];
43
+ const seenIds = new Set();
44
+ const pipelineRunning = isPipelineRunning(worcaDir);
45
+
46
+ // 1. Check active_run pointer for the current run
47
+ const activeRunPath = join(worcaDir, 'active_run');
48
+ if (existsSync(activeRunPath)) {
49
+ try {
50
+ const activeId = readFileSync(activeRunPath, 'utf8').trim();
51
+ const candidate = join(worcaDir, 'runs', activeId, 'status.json');
52
+ if (existsSync(candidate)) {
53
+ const status = JSON.parse(readFileSync(candidate, 'utf8'));
54
+ const active = !isTerminal(status) && pipelineRunning;
55
+ const id = createRunId(status);
56
+ runs.push({ id, active, ...status });
57
+ seenIds.add(id);
58
+ }
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+
64
+ // 2. Scan .worca/runs/ for other runs
65
+ const runsDir = join(worcaDir, 'runs');
66
+ if (existsSync(runsDir)) {
67
+ for (const entry of readdirSync(runsDir)) {
68
+ const statusPath = join(runsDir, entry, 'status.json');
69
+ if (!existsSync(statusPath)) continue;
70
+ try {
71
+ const status = JSON.parse(readFileSync(statusPath, 'utf8'));
72
+ const id = createRunId(status);
73
+ if (seenIds.has(id)) continue;
74
+ seenIds.add(id);
75
+ const active =
76
+ !isTerminal(status) && status.pipeline_status === 'running';
77
+ runs.push({ id, active, ...status });
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ }
82
+ }
83
+
84
+ // 3. Legacy: flat .worca/status.json
85
+ const statusPath = join(worcaDir, 'status.json');
86
+ if (existsSync(statusPath)) {
87
+ try {
88
+ const status = JSON.parse(readFileSync(statusPath, 'utf8'));
89
+ const id = createRunId(status);
90
+ if (!seenIds.has(id)) {
91
+ const active = !isTerminal(status) && pipelineRunning;
92
+ runs.push({ id, active, ...status });
93
+ seenIds.add(id);
94
+ }
95
+ } catch {
96
+ /* ignore malformed */
97
+ }
98
+ }
99
+
100
+ // 4. Results: handle both dir format (results/{id}/status.json) and file format (results/{id}.json)
101
+ const resultsDir = join(worcaDir, 'results');
102
+ if (existsSync(resultsDir)) {
103
+ for (const entry of readdirSync(resultsDir, { withFileTypes: true })) {
104
+ try {
105
+ if (entry.isFile() && entry.name.endsWith('.json')) {
106
+ // Legacy file format
107
+ const data = JSON.parse(
108
+ readFileSync(join(resultsDir, entry.name), 'utf8'),
109
+ );
110
+ if (data.started_at) {
111
+ const id = createRunId(data);
112
+ if (!seenIds.has(id)) {
113
+ seenIds.add(id);
114
+ runs.push({ id, active: false, ...data });
115
+ }
116
+ }
117
+ } else if (entry.isDirectory()) {
118
+ // New dir format
119
+ const sp = join(resultsDir, entry.name, 'status.json');
120
+ if (existsSync(sp)) {
121
+ const data = JSON.parse(readFileSync(sp, 'utf8'));
122
+ const id = createRunId(data);
123
+ if (!seenIds.has(id)) {
124
+ seenIds.add(id);
125
+ runs.push({ id, active: false, ...data });
126
+ }
127
+ }
128
+ }
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ }
133
+ }
134
+
135
+ return runs;
136
+ }
137
+
138
+ /**
139
+ * Async version of discoverRuns — avoids blocking the event loop.
140
+ * Used by the status watcher's debounced refresh.
141
+ */
142
+ export async function discoverRunsAsync(worcaDir) {
143
+ const runs = [];
144
+ const seenIds = new Set();
145
+ const pipelineRunning = isPipelineRunning(worcaDir); // cheap check (one stat + one kill)
146
+
147
+ // 1. Active run
148
+ const activeRunPath = join(worcaDir, 'active_run');
149
+ try {
150
+ const activeId = (await readFile(activeRunPath, 'utf8')).trim();
151
+ const candidate = join(worcaDir, 'runs', activeId, 'status.json');
152
+ const status = JSON.parse(await readFile(candidate, 'utf8'));
153
+ const active = !isTerminal(status) && pipelineRunning;
154
+ const id = createRunId(status);
155
+ runs.push({ id, active, ...status });
156
+ seenIds.add(id);
157
+ } catch {
158
+ /* ignore */
159
+ }
160
+
161
+ // 2. Scan .worca/runs/
162
+ const runsDir = join(worcaDir, 'runs');
163
+ try {
164
+ const entries = await readdir(runsDir);
165
+ const readPromises = entries.map(async (entry) => {
166
+ try {
167
+ const statusPath = join(runsDir, entry, 'status.json');
168
+ const status = JSON.parse(await readFile(statusPath, 'utf8'));
169
+ return status;
170
+ } catch {
171
+ return null;
172
+ }
173
+ });
174
+ for (const status of await Promise.all(readPromises)) {
175
+ if (!status) continue;
176
+ const id = createRunId(status);
177
+ if (seenIds.has(id)) continue;
178
+ seenIds.add(id);
179
+ const active =
180
+ !isTerminal(status) && status.pipeline_status === 'running';
181
+ runs.push({ id, active, ...status });
182
+ }
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+
187
+ // 3. Legacy flat status.json
188
+ try {
189
+ const status = JSON.parse(
190
+ await readFile(join(worcaDir, 'status.json'), 'utf8'),
191
+ );
192
+ const id = createRunId(status);
193
+ if (!seenIds.has(id)) {
194
+ const active = !isTerminal(status) && pipelineRunning;
195
+ runs.push({ id, active, ...status });
196
+ seenIds.add(id);
197
+ }
198
+ } catch {
199
+ /* ignore */
200
+ }
201
+
202
+ // 4. Results
203
+ const resultsDir = join(worcaDir, 'results');
204
+ try {
205
+ const entries = await readdir(resultsDir, { withFileTypes: true });
206
+ const readPromises = entries.map(async (entry) => {
207
+ try {
208
+ if (entry.isFile() && entry.name.endsWith('.json')) {
209
+ return JSON.parse(
210
+ await readFile(join(resultsDir, entry.name), 'utf8'),
211
+ );
212
+ }
213
+ if (entry.isDirectory()) {
214
+ const sp = join(resultsDir, entry.name, 'status.json');
215
+ return JSON.parse(await readFile(sp, 'utf8'));
216
+ }
217
+ } catch {
218
+ /* ignore */
219
+ }
220
+ return null;
221
+ });
222
+ for (const data of await Promise.all(readPromises)) {
223
+ if (!data || !data.started_at) continue;
224
+ const id = createRunId(data);
225
+ if (!seenIds.has(id)) {
226
+ seenIds.add(id);
227
+ runs.push({ id, active: false, ...data });
228
+ }
229
+ }
230
+ } catch {
231
+ /* ignore */
232
+ }
233
+
234
+ return runs;
235
+ }
236
+
237
+ /**
238
+ * Watch {runDir}/events.jsonl for new lines (byte-offset tracking).
239
+ * Handles file creation if the file doesn't exist yet.
240
+ * Calls callback(event) for each parsed JSON line; skips malformed lines.
241
+ *
242
+ * @param {string} runDir - Run directory that may contain events.jsonl
243
+ * @param {(event: object) => void} callback
244
+ * @returns {{ close: () => void }}
245
+ */
246
+ export function watchEvents(runDir, callback) {
247
+ const eventsPath = join(runDir, 'events.jsonl');
248
+ let byteOffset = 0;
249
+ let fileWatcher = null;
250
+ let dirWatcher = null;
251
+ let closed = false;
252
+
253
+ function processNewContent() {
254
+ if (closed) return;
255
+ try {
256
+ if (!existsSync(eventsPath)) return;
257
+ const buf = readFileSync(eventsPath);
258
+ if (buf.length <= byteOffset) return;
259
+ const newContent = buf.slice(byteOffset).toString('utf8');
260
+ byteOffset = buf.length;
261
+ for (const line of newContent.split('\n')) {
262
+ if (!line.trim()) continue;
263
+ try {
264
+ callback(JSON.parse(line));
265
+ } catch {
266
+ /* skip malformed */
267
+ }
268
+ }
269
+ } catch {
270
+ /* ignore read errors */
271
+ }
272
+ }
273
+
274
+ function startFileWatcher() {
275
+ if (closed || fileWatcher) return;
276
+ try {
277
+ fileWatcher = watch(eventsPath, (eventType) => {
278
+ if (eventType === 'change') {
279
+ processNewContent();
280
+ } else if (eventType === 'rename') {
281
+ // File deleted or recreated — reset and retry
282
+ if (fileWatcher) {
283
+ try {
284
+ fileWatcher.close();
285
+ } catch {
286
+ /* ignore */
287
+ }
288
+ fileWatcher = null;
289
+ }
290
+ setTimeout(() => {
291
+ if (!closed && existsSync(eventsPath)) {
292
+ startFileWatcher();
293
+ processNewContent();
294
+ }
295
+ }, 100);
296
+ }
297
+ });
298
+ } catch {
299
+ /* ignore — file may have been deleted */
300
+ }
301
+ }
302
+
303
+ if (existsSync(eventsPath)) {
304
+ // Start from current end of file (tail only new content)
305
+ try {
306
+ byteOffset = readFileSync(eventsPath).length;
307
+ } catch {
308
+ /* ignore */
309
+ }
310
+ startFileWatcher();
311
+ }
312
+
313
+ // Watch the run directory so we detect events.jsonl being created
314
+ if (existsSync(runDir)) {
315
+ try {
316
+ dirWatcher = watch(
317
+ runDir,
318
+ { recursive: false },
319
+ (_eventType, filename) => {
320
+ if (
321
+ filename === 'events.jsonl' &&
322
+ existsSync(eventsPath) &&
323
+ !fileWatcher
324
+ ) {
325
+ byteOffset = 0; // Newly created — read from the beginning
326
+ startFileWatcher();
327
+ processNewContent();
328
+ }
329
+ },
330
+ );
331
+ } catch {
332
+ /* ignore */
333
+ }
334
+ }
335
+
336
+ return {
337
+ close() {
338
+ closed = true;
339
+ if (fileWatcher) {
340
+ try {
341
+ fileWatcher.close();
342
+ } catch {
343
+ /* ignore */
344
+ }
345
+ fileWatcher = null;
346
+ }
347
+ if (dirWatcher) {
348
+ try {
349
+ dirWatcher.close();
350
+ } catch {
351
+ /* ignore */
352
+ }
353
+ dirWatcher = null;
354
+ }
355
+ },
356
+ };
357
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * In-memory ring buffer store for webhook inbox events.
3
+ * No persistence — cleared on server restart.
4
+ */
5
+
6
+ export function createInbox(maxSize = 500) {
7
+ const events = [];
8
+ let nextId = 1;
9
+ let controlAction = 'continue'; // 'continue' | 'pause' | 'abort'
10
+
11
+ return {
12
+ push(event) {
13
+ const stored = {
14
+ id: nextId++,
15
+ receivedAt: new Date().toISOString(),
16
+ headers: event.headers || {},
17
+ envelope: event.envelope || {},
18
+ projectId: event.projectId || null,
19
+ controlResponse: { action: controlAction },
20
+ };
21
+ events.push(stored);
22
+ if (events.length > maxSize) {
23
+ events.splice(0, events.length - maxSize);
24
+ }
25
+ return stored;
26
+ },
27
+
28
+ list(sinceId, projectId) {
29
+ let result = events;
30
+ if (sinceId != null) {
31
+ result = result.filter((e) => e.id > sinceId);
32
+ }
33
+ if (projectId) {
34
+ result = result.filter(
35
+ (e) => !e.projectId || e.projectId === projectId,
36
+ );
37
+ }
38
+ return [...result];
39
+ },
40
+
41
+ clear() {
42
+ events.length = 0;
43
+ },
44
+
45
+ size() {
46
+ return events.length;
47
+ },
48
+
49
+ getControlAction() {
50
+ return controlAction;
51
+ },
52
+
53
+ setControlAction(action) {
54
+ if (['continue', 'pause', 'abort'].includes(action)) {
55
+ controlAction = action;
56
+ }
57
+ },
58
+ };
59
+ }