@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,299 @@
1
+ /**
2
+ * Log file watcher — manages real-time log tailing and archived log reading.
3
+ * Owns logWatchers map and logLineCounts tracking.
4
+ */
5
+
6
+ import { existsSync, readdirSync, statSync, watch } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import {
9
+ fileByteLength,
10
+ listIterationFiles,
11
+ listLogFiles,
12
+ readLastLines,
13
+ readNewLines,
14
+ resolveLogPath,
15
+ } from './log-tailer.js';
16
+
17
+ /**
18
+ * @param {{
19
+ * broadcaster: { broadcastToLogSubscribers: Function },
20
+ * resolveActiveRunDir: Function,
21
+ * worcaDir: string,
22
+ * currentActiveRunId: Function
23
+ * }} deps
24
+ */
25
+ export function createLogWatcher({
26
+ broadcaster,
27
+ resolveActiveRunDir,
28
+ worcaDir,
29
+ currentActiveRunId,
30
+ }) {
31
+ /** @type {Map<string, import('node:fs').FSWatcher>} */
32
+ const logWatchers = new Map();
33
+
34
+ /** Track byte offsets per log file so we only read new content */
35
+ const logByteOffsets = new Map();
36
+
37
+ function resolveLogsBaseDir() {
38
+ const runDir = resolveActiveRunDir();
39
+ return runDir === worcaDir ? worcaDir : runDir;
40
+ }
41
+
42
+ /**
43
+ * Close all active log watchers and reset tracking state.
44
+ */
45
+ function clearLogWatchers() {
46
+ for (const w of logWatchers.values()) {
47
+ try {
48
+ w.close();
49
+ } catch {
50
+ /* ignore */
51
+ }
52
+ }
53
+ logWatchers.clear();
54
+ logByteOffsets.clear();
55
+ }
56
+
57
+ function watchSingleLogFile(stage, filePath, iteration) {
58
+ const key =
59
+ iteration != null
60
+ ? `${stage}__iter${iteration}`
61
+ : stage || '__orchestrator__';
62
+ if (logWatchers.has(key)) return;
63
+ try {
64
+ if (!existsSync(filePath)) return;
65
+ logByteOffsets.set(key, fileByteLength(filePath));
66
+ const watcherRunId = currentActiveRunId();
67
+ const watcher = watch(filePath, (eventType) => {
68
+ if (eventType === 'change') {
69
+ try {
70
+ const prevOffset = logByteOffsets.get(key) || 0;
71
+ const { lines: newLines, newOffset } = readNewLines(
72
+ filePath,
73
+ prevOffset,
74
+ );
75
+ if (newLines.length > 0) {
76
+ logByteOffsets.set(key, newOffset);
77
+ for (const line of newLines) {
78
+ broadcaster.broadcastToLogSubscribers(
79
+ stage,
80
+ 'log-line',
81
+ {
82
+ stage: stage || 'orchestrator',
83
+ iteration: iteration ?? undefined,
84
+ line,
85
+ timestamp: new Date().toISOString(),
86
+ },
87
+ watcherRunId,
88
+ );
89
+ }
90
+ }
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ }
95
+ });
96
+ logWatchers.set(key, watcher);
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+
102
+ function watchStageDir(stage, stageDir) {
103
+ const dirKey = `${stage}__dir`;
104
+ if (logWatchers.has(dirKey)) return;
105
+ try {
106
+ const dirWatcher = watch(stageDir, (_eventType, filename) => {
107
+ if (filename && /^iter-\d+\.log$/.test(filename)) {
108
+ const iterNum = parseInt(filename.match(/\d+/)[0], 10);
109
+ const iterPath = join(stageDir, filename);
110
+ watchSingleLogFile(stage, iterPath, iterNum);
111
+ }
112
+ });
113
+ logWatchers.set(dirKey, dirWatcher);
114
+ const logsBase = resolveLogsBaseDir();
115
+ const backfill = listIterationFiles(logsBase, stage);
116
+ for (const { iteration, path } of backfill) {
117
+ watchSingleLogFile(stage, path, iteration);
118
+ }
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ }
123
+
124
+ function watchLogFile(stage) {
125
+ const logsBase = resolveLogsBaseDir();
126
+ if (!stage) {
127
+ const logPath = resolveLogPath(logsBase, null);
128
+ watchSingleLogFile(null, logPath, null);
129
+ return;
130
+ }
131
+ const stageDir = resolveLogPath(logsBase, stage);
132
+ if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
133
+ const iters = listIterationFiles(logsBase, stage);
134
+ for (const { iteration, path } of iters) {
135
+ watchSingleLogFile(stage, path, iteration);
136
+ }
137
+ watchStageDir(stage, stageDir);
138
+ } else {
139
+ const logPath = join(logsBase, 'logs', `${stage}.log`);
140
+ if (existsSync(logPath)) {
141
+ watchSingleLogFile(stage, logPath, null);
142
+ }
143
+ }
144
+ }
145
+
146
+ function watchAllLogFiles() {
147
+ const logsBase = resolveLogsBaseDir();
148
+ const logFiles = listLogFiles(logsBase);
149
+ const watchedStages = new Set();
150
+ for (const { stage } of logFiles) {
151
+ if (watchedStages.has(stage)) continue;
152
+ watchedStages.add(stage);
153
+ const actualStage = stage === 'orchestrator' ? null : stage;
154
+ watchLogFile(actualStage);
155
+ }
156
+ const logsDir = join(logsBase, 'logs');
157
+ const dirKey = '__logs_dir__';
158
+ if (logWatchers.has(dirKey)) return;
159
+ if (!existsSync(logsDir)) return;
160
+ try {
161
+ const dirWatcher = watch(logsDir, (_eventType, filename) => {
162
+ if (!filename) return;
163
+ if (filename.endsWith('.log')) {
164
+ const stage = filename.replace('.log', '');
165
+ const actualStage = stage === 'orchestrator' ? null : stage;
166
+ watchLogFile(actualStage);
167
+ } else {
168
+ const stagePath = join(logsDir, filename);
169
+ try {
170
+ if (existsSync(stagePath) && statSync(stagePath).isDirectory()) {
171
+ const iters = listIterationFiles(logsBase, filename);
172
+ for (const { iteration, path } of iters) {
173
+ watchSingleLogFile(filename, path, iteration);
174
+ }
175
+ watchStageDir(filename, stagePath);
176
+ }
177
+ } catch {
178
+ /* ignore */
179
+ }
180
+ }
181
+ });
182
+ logWatchers.set(dirKey, dirWatcher);
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+ }
187
+
188
+ function sendArchivedLogs(ws, archivedLogDir, stage, iteration) {
189
+ try {
190
+ if (stage) {
191
+ const stageDir = join(archivedLogDir, stage);
192
+ if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
193
+ const files = readdirSync(stageDir)
194
+ .filter((f) => /^iter-\d+\.log$/.test(f))
195
+ .sort(
196
+ (a, b) =>
197
+ parseInt(a.match(/\d+/)[0], 10) -
198
+ parseInt(b.match(/\d+/)[0], 10),
199
+ );
200
+ for (const f of files) {
201
+ const iterNum = parseInt(f.match(/\d+/)[0], 10);
202
+ if (iteration != null && iterNum !== iteration) continue;
203
+ const lines = readLastLines(join(stageDir, f), 200);
204
+ if (lines.length > 0) {
205
+ ws.send(
206
+ JSON.stringify({
207
+ id: `evt-${Date.now()}-iter${iterNum}`,
208
+ ok: true,
209
+ type: 'log-bulk',
210
+ payload: { stage, iteration: iterNum, lines },
211
+ }),
212
+ );
213
+ }
214
+ }
215
+ } else {
216
+ const logPath = join(archivedLogDir, `${stage}.log`);
217
+ const lines = readLastLines(logPath, 200);
218
+ if (lines.length > 0) {
219
+ ws.send(
220
+ JSON.stringify({
221
+ id: `evt-${Date.now()}`,
222
+ ok: true,
223
+ type: 'log-bulk',
224
+ payload: { stage, lines },
225
+ }),
226
+ );
227
+ }
228
+ }
229
+ } else {
230
+ const entries = readdirSync(archivedLogDir, { withFileTypes: true });
231
+ for (const entry of entries) {
232
+ if (entry.isFile() && entry.name.endsWith('.log')) {
233
+ const s2 = entry.name.replace('.log', '');
234
+ const lines = readLastLines(join(archivedLogDir, entry.name), 200);
235
+ if (lines.length > 0) {
236
+ ws.send(
237
+ JSON.stringify({
238
+ id: `evt-${Date.now()}-${s2}`,
239
+ ok: true,
240
+ type: 'log-bulk',
241
+ payload: { stage: s2, lines },
242
+ }),
243
+ );
244
+ }
245
+ } else if (entry.isDirectory()) {
246
+ const stageDir2 = join(archivedLogDir, entry.name);
247
+ const iterFiles = readdirSync(stageDir2)
248
+ .filter((f) => /^iter-\d+\.log$/.test(f))
249
+ .sort(
250
+ (a, b) =>
251
+ parseInt(a.match(/\d+/)[0], 10) -
252
+ parseInt(b.match(/\d+/)[0], 10),
253
+ );
254
+ for (const f of iterFiles) {
255
+ const iterNum = parseInt(f.match(/\d+/)[0], 10);
256
+ const lines = readLastLines(join(stageDir2, f), 200);
257
+ if (lines.length > 0) {
258
+ ws.send(
259
+ JSON.stringify({
260
+ id: `evt-${Date.now()}-${entry.name}-iter${iterNum}`,
261
+ ok: true,
262
+ type: 'log-bulk',
263
+ payload: {
264
+ stage: entry.name,
265
+ iteration: iterNum,
266
+ lines,
267
+ },
268
+ }),
269
+ );
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+ } catch {
276
+ /* ignore */
277
+ }
278
+ }
279
+
280
+ function destroy() {
281
+ for (const w of logWatchers.values()) {
282
+ try {
283
+ w.close();
284
+ } catch {
285
+ /* ignore */
286
+ }
287
+ }
288
+ logWatchers.clear();
289
+ }
290
+
291
+ return {
292
+ clearLogWatchers,
293
+ watchLogFile,
294
+ watchAllLogFiles,
295
+ sendArchivedLogs,
296
+ resolveLogsBaseDir,
297
+ destroy,
298
+ };
299
+ }