@worca/ui 0.13.0 → 0.14.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.
@@ -1,5 +1,6 @@
1
1
  // server/settings-validator.js
2
2
  import { STAGE_ORDER } from '../app/utils/stage-order.js';
3
+ import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
3
4
 
4
5
  const VALID_AGENTS = [
5
6
  'planner',
@@ -12,7 +13,7 @@ const VALID_AGENTS = [
12
13
  'learner',
13
14
  ];
14
15
  const VALID_STAGES = STAGE_ORDER;
15
- const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
16
+ export const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
16
17
  const VALID_LOOPS = [
17
18
  'implement_test',
18
19
  'pr_changes',
@@ -265,6 +266,70 @@ export function validateSettingsPayload(body) {
265
266
  }
266
267
  }
267
268
 
269
+ // parallel
270
+ if (w.parallel !== undefined) {
271
+ if (
272
+ typeof w.parallel !== 'object' ||
273
+ w.parallel === null ||
274
+ Array.isArray(w.parallel)
275
+ ) {
276
+ details.push('worca.parallel must be an object');
277
+ } else {
278
+ const p = w.parallel;
279
+ if (
280
+ p.worktree_base_dir !== undefined &&
281
+ (typeof p.worktree_base_dir !== 'string' ||
282
+ p.worktree_base_dir.length === 0)
283
+ ) {
284
+ details.push('parallel.worktree_base_dir must be a non-empty string');
285
+ }
286
+ if (
287
+ p.default_base_branch !== undefined &&
288
+ (typeof p.default_base_branch !== 'string' ||
289
+ p.default_base_branch.length === 0)
290
+ ) {
291
+ details.push(
292
+ 'parallel.default_base_branch must be a non-empty string',
293
+ );
294
+ }
295
+ }
296
+ }
297
+
298
+ // circuit_breaker
299
+ if (w.circuit_breaker !== undefined) {
300
+ if (
301
+ typeof w.circuit_breaker !== 'object' ||
302
+ w.circuit_breaker === null ||
303
+ Array.isArray(w.circuit_breaker)
304
+ ) {
305
+ details.push('worca.circuit_breaker must be an object');
306
+ } else {
307
+ const cb = w.circuit_breaker;
308
+ if (cb.enabled !== undefined && typeof cb.enabled !== 'boolean') {
309
+ details.push('circuit_breaker.enabled must be a boolean');
310
+ }
311
+ if (
312
+ cb.max_consecutive_failures !== undefined &&
313
+ (!Number.isInteger(cb.max_consecutive_failures) ||
314
+ cb.max_consecutive_failures < 1 ||
315
+ cb.max_consecutive_failures > 10)
316
+ ) {
317
+ details.push(
318
+ 'circuit_breaker.max_consecutive_failures must be an integer between 1 and 10',
319
+ );
320
+ }
321
+ }
322
+ }
323
+
324
+ // reject misplaced global keys in project settings
325
+ for (const [section, key] of GLOBAL_ONLY_KEYS) {
326
+ if (w?.[section]?.[key] !== undefined) {
327
+ details.push(
328
+ `worca.${section}.${key} is a global preference (~/.worca/settings.json), not a project setting. Configure it in the global Preferences tab.`,
329
+ );
330
+ }
331
+ }
332
+
268
333
  // governance
269
334
  if (w.governance !== undefined) {
270
335
  if (
@@ -782,3 +847,49 @@ export function validateIntegrationsConfig(cfg) {
782
847
 
783
848
  return details.length ? { valid: false, details } : { valid: true };
784
849
  }
850
+
851
+ const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
852
+
853
+ export function validateGlobalSettings(prefs) {
854
+ const details = [];
855
+ const w = prefs?.worca;
856
+ if (!w) return { ok: true };
857
+
858
+ if (w.ui?.worktree_disk_warning_bytes !== undefined) {
859
+ const v = w.ui.worktree_disk_warning_bytes;
860
+ if (!Number.isInteger(v) || v < 500_000_000 || v > 50_000_000_000) {
861
+ details.push(
862
+ 'ui.worktree_disk_warning_bytes must be an integer between 500_000_000 (500 MB) and 50_000_000_000 (50 GB)',
863
+ );
864
+ }
865
+ }
866
+
867
+ if (
868
+ w.circuit_breaker?.classifier_model !== undefined &&
869
+ !VALID_MODELS.includes(w.circuit_breaker.classifier_model)
870
+ ) {
871
+ details.push(
872
+ `circuit_breaker.classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
873
+ );
874
+ }
875
+
876
+ if (
877
+ w.parallel?.cleanup_policy !== undefined &&
878
+ !VALID_CLEANUP_POLICIES.includes(w.parallel.cleanup_policy)
879
+ ) {
880
+ details.push(
881
+ `parallel.cleanup_policy must be one of: ${VALID_CLEANUP_POLICIES.join(', ')}`,
882
+ );
883
+ }
884
+
885
+ if (w.parallel?.max_concurrent_pipelines !== undefined) {
886
+ const n = w.parallel.max_concurrent_pipelines;
887
+ if (!Number.isInteger(n) || n < 1 || n > 20) {
888
+ details.push(
889
+ 'parallel.max_concurrent_pipelines must be an integer between 1 and 20',
890
+ );
891
+ }
892
+ }
893
+
894
+ return details.length === 0 ? { ok: true } : { ok: false, details };
895
+ }
@@ -0,0 +1,23 @@
1
+ import { join } from 'node:path';
2
+ import { Router } from 'express';
3
+ import { countRunningPipelinesAcrossProjects } from './process-registry.js';
4
+ import { readGlobalSettings } from './settings-reader.js';
5
+
6
+ export function createStatusRouter({ prefsDir }) {
7
+ const router = Router();
8
+
9
+ router.get('/runs-count', (_req, res) => {
10
+ try {
11
+ const totalRunning = countRunningPipelinesAcrossProjects(prefsDir);
12
+ const globalSettingsPath = join(prefsDir, 'settings.json');
13
+ const globalSettings = readGlobalSettings(globalSettingsPath);
14
+ const cap =
15
+ globalSettings.worca?.parallel?.max_concurrent_pipelines ?? 10;
16
+ res.json({ ok: true, totalRunning, cap });
17
+ } catch (err) {
18
+ res.status(500).json({ ok: false, error: err.message });
19
+ }
20
+ });
21
+
22
+ return router;
23
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
+ import { resolveRunDir } from './run-dir-resolver.js';
13
14
  import { createBeadsWatcher } from './ws-beads-watcher.js';
14
15
  import { createEventWatcher } from './ws-event-watcher.js';
15
16
  import { createLogWatcher } from './ws-log-watcher.js';
@@ -163,16 +164,13 @@ export class WatcherSet {
163
164
  // Event watcher
164
165
  if (!this.eventWatcher) {
165
166
  try {
166
- const resolveRunDirById = (runId) => {
167
- const candidates = [
168
- join(worcaDir, 'runs', runId),
169
- join(worcaDir, 'results', runId),
170
- ];
171
- for (const c of candidates) {
172
- if (existsSync(c)) return c;
173
- }
174
- return join(worcaDir, 'runs', runId);
175
- };
167
+ // Use the shared overlay resolver so worktree-hosted runs (registered
168
+ // in <worcaDir>/multi/pipelines.d/<runId>.json) resolve to their
169
+ // <worktree_path>/.worca/runs/<runId>/ directory. Falls back to the
170
+ // legacy local path for non-existent runs so the existing `watch()`
171
+ // call keeps working when the file is created later.
172
+ const resolveRunDirById = (runId) =>
173
+ resolveRunDir(worcaDir, runId) || join(worcaDir, 'runs', runId);
176
174
 
177
175
  this.eventWatcher = this._factories.createEventWatcher({
178
176
  broadcaster,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared worktree operations — single owner of `git worktree remove` shell-out.
3
+ */
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+ import {
7
+ existsSync,
8
+ lstatSync,
9
+ readFileSync,
10
+ rmSync,
11
+ unlinkSync,
12
+ } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ /**
16
+ * Remove a worktree and its registry entry.
17
+ * Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
18
+ * 1. Attempt `git worktree remove --force <path>` from the project root
19
+ * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rmSync
20
+ * 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
21
+ * drops the entry even when the directory was removed manually
22
+ * 4. Delete the registry file
23
+ */
24
+ export function removeWorktree(worcaDir, runId) {
25
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
26
+ const projectRoot = join(worcaDir, '..');
27
+ let worktreePath = null;
28
+
29
+ if (existsSync(regFile)) {
30
+ try {
31
+ const reg = JSON.parse(readFileSync(regFile, 'utf8'));
32
+ worktreePath = reg.worktree_path || null;
33
+ } catch {
34
+ /* ignore */
35
+ }
36
+ }
37
+
38
+ if (worktreePath && existsSync(worktreePath)) {
39
+ try {
40
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
41
+ cwd: projectRoot,
42
+ stdio: 'pipe',
43
+ timeout: 30_000,
44
+ });
45
+ } catch {
46
+ let isRealDir = false;
47
+ try {
48
+ const st = lstatSync(worktreePath);
49
+ isRealDir = st.isDirectory() && !st.isSymbolicLink();
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ if (isRealDir) {
54
+ rmSync(worktreePath, { recursive: true, force: true });
55
+ }
56
+ }
57
+ }
58
+
59
+ try {
60
+ execFileSync('git', ['worktree', 'prune'], {
61
+ cwd: projectRoot,
62
+ stdio: 'pipe',
63
+ timeout: 30_000,
64
+ });
65
+ } catch {
66
+ /* non-fatal */
67
+ }
68
+
69
+ if (existsSync(regFile)) {
70
+ unlinkSync(regFile);
71
+ }
72
+ }
@@ -7,18 +7,10 @@
7
7
  * Expects req.project.worcaDir to be set by projectResolver middleware.
8
8
  */
9
9
 
10
- import { execFileSync } from 'node:child_process';
11
- import {
12
- existsSync,
13
- lstatSync,
14
- readdirSync,
15
- readFileSync,
16
- rmSync,
17
- statSync,
18
- unlinkSync,
19
- } from 'node:fs';
10
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
20
11
  import { join } from 'node:path';
21
12
  import { Router } from 'express';
13
+ import { removeWorktree } from './worktree-ops.js';
22
14
 
23
15
  const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
24
16
 
@@ -172,75 +164,6 @@ function _listWorktrees(worcaDir) {
172
164
  return entries;
173
165
  }
174
166
 
175
- /**
176
- * Remove a worktree and its registry entry.
177
- * Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
178
- * 1. Attempt `git worktree remove --force <path>` from the project root
179
- * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rmSync
180
- * 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
181
- * drops the entry even when the directory was removed manually
182
- * 4. Delete the registry file
183
- */
184
- function _removeWorktree(worcaDir, runId) {
185
- const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
186
- // worcaDir is `<projectRoot>/.worca` — git commands must run inside the
187
- // project repo, not the server's cwd, or `git worktree remove` errors out
188
- // and the .git/worktrees/<id>/ metadata is left as `prunable`.
189
- const projectRoot = join(worcaDir, '..');
190
- let worktreePath = null;
191
-
192
- if (existsSync(regFile)) {
193
- try {
194
- const reg = JSON.parse(readFileSync(regFile, 'utf8'));
195
- worktreePath = reg.worktree_path || null;
196
- } catch {
197
- /* ignore */
198
- }
199
- }
200
-
201
- if (worktreePath && existsSync(worktreePath)) {
202
- try {
203
- execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
204
- cwd: projectRoot,
205
- stdio: 'pipe',
206
- timeout: 30_000,
207
- });
208
- } catch {
209
- // Path is not a registered git worktree — brute-force remove.
210
- // Refuse to follow symlinks: rmSync on a symlink to a real directory
211
- // would delete the link itself (good), but we don't want to risk a
212
- // user-symlinked path here being mistaken for a worktree we own.
213
- let isRealDir = false;
214
- try {
215
- const st = lstatSync(worktreePath);
216
- isRealDir = st.isDirectory() && !st.isSymbolicLink();
217
- } catch {
218
- /* ignore */
219
- }
220
- if (isRealDir) {
221
- rmSync(worktreePath, { recursive: true, force: true });
222
- }
223
- }
224
- }
225
-
226
- // Always prune — covers (a) successful remove leaving residual metadata,
227
- // (b) brute-force rmSync path, and (c) entries already left prunable by
228
- // earlier failures. Errors are non-fatal (e.g. project not a git repo).
229
- try {
230
- execFileSync('git', ['worktree', 'prune'], {
231
- cwd: projectRoot,
232
- stdio: 'pipe',
233
- timeout: 30_000,
234
- });
235
- } catch {
236
- /* non-fatal */
237
- }
238
-
239
- if (existsSync(regFile)) {
240
- unlinkSync(regFile);
241
- }
242
- }
243
-
244
167
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
245
168
  function _validateRunId(runId) {
246
169
  return (
@@ -338,7 +261,7 @@ export function createWorktreesRouter() {
338
261
  });
339
262
  }
340
263
 
341
- _removeWorktree(worcaDir, run_id);
264
+ removeWorktree(worcaDir, run_id);
342
265
  res.json({ ok: true, run_id });
343
266
  } catch (err) {
344
267
  res.status(500).json({ ok: false, error: err.message });
@@ -54,16 +54,21 @@ export function createLogWatcher({
54
54
  logByteOffsets.clear();
55
55
  }
56
56
 
57
- function watchSingleLogFile(stage, filePath, iteration) {
58
- const key =
59
- iteration != null
60
- ? `${stage}__iter${iteration}`
61
- : stage || '__orchestrator__';
57
+ function _watcherKey(runId, stage, iteration, suffix = '') {
58
+ const stagePart = stage || '__orchestrator__';
59
+ const iterPart = iteration != null ? `__iter${iteration}` : '';
60
+ const runPart = runId ? `${runId}__` : '';
61
+ return `${runPart}${stagePart}${iterPart}${suffix}`;
62
+ }
63
+
64
+ function watchSingleLogFile(stage, filePath, iteration, options = {}) {
65
+ const explicitRunId = options.runId || null;
66
+ const key = _watcherKey(explicitRunId, stage, iteration);
62
67
  if (logWatchers.has(key)) return;
63
68
  try {
64
69
  if (!existsSync(filePath)) return;
65
70
  logByteOffsets.set(key, fileByteLength(filePath));
66
- const watcherRunId = currentActiveRunId();
71
+ const watcherRunId = explicitRunId || currentActiveRunId();
67
72
  const watcher = watch(filePath, (eventType) => {
68
73
  if (eventType === 'change') {
69
74
  try {
@@ -99,62 +104,66 @@ export function createLogWatcher({
99
104
  }
100
105
  }
101
106
 
102
- function watchStageDir(stage, stageDir) {
103
- const dirKey = `${stage}__dir`;
107
+ function watchStageDir(stage, stageDir, options = {}) {
108
+ const explicitRunId = options.runId || null;
109
+ const dirKey = _watcherKey(explicitRunId, stage, null, '__dir');
104
110
  if (logWatchers.has(dirKey)) return;
105
111
  try {
106
112
  const dirWatcher = watch(stageDir, (_eventType, filename) => {
107
113
  if (filename && /^iter-\d+\.log$/.test(filename)) {
108
114
  const iterNum = parseInt(filename.match(/\d+/)[0], 10);
109
115
  const iterPath = join(stageDir, filename);
110
- watchSingleLogFile(stage, iterPath, iterNum);
116
+ watchSingleLogFile(stage, iterPath, iterNum, options);
111
117
  }
112
118
  });
113
119
  logWatchers.set(dirKey, dirWatcher);
114
- const logsBase = resolveLogsBaseDir();
120
+ const logsBase = options.runDir || resolveLogsBaseDir();
115
121
  const backfill = listIterationFiles(logsBase, stage);
116
122
  for (const { iteration, path } of backfill) {
117
- watchSingleLogFile(stage, path, iteration);
123
+ watchSingleLogFile(stage, path, iteration, options);
118
124
  }
119
125
  } catch {
120
126
  /* ignore */
121
127
  }
122
128
  }
123
129
 
124
- function watchLogFile(stage) {
125
- const logsBase = resolveLogsBaseDir();
130
+ function watchLogFile(stage, options = {}) {
131
+ const logsBase = options.runDir || resolveLogsBaseDir();
126
132
  if (!stage) {
127
133
  const logPath = resolveLogPath(logsBase, null);
128
- watchSingleLogFile(null, logPath, null);
134
+ watchSingleLogFile(null, logPath, null, options);
129
135
  return;
130
136
  }
131
137
  const stageDir = resolveLogPath(logsBase, stage);
132
138
  if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
133
139
  const iters = listIterationFiles(logsBase, stage);
134
140
  for (const { iteration, path } of iters) {
135
- watchSingleLogFile(stage, path, iteration);
141
+ watchSingleLogFile(stage, path, iteration, options);
136
142
  }
137
- watchStageDir(stage, stageDir);
143
+ watchStageDir(stage, stageDir, options);
138
144
  } else {
139
145
  const logPath = join(logsBase, 'logs', `${stage}.log`);
140
146
  if (existsSync(logPath)) {
141
- watchSingleLogFile(stage, logPath, null);
147
+ watchSingleLogFile(stage, logPath, null, options);
142
148
  }
143
149
  }
144
150
  }
145
151
 
146
- function watchAllLogFiles() {
147
- const logsBase = resolveLogsBaseDir();
152
+ function watchAllLogFiles(options = {}) {
153
+ const logsBase = options.runDir || resolveLogsBaseDir();
148
154
  const logFiles = listLogFiles(logsBase);
149
155
  const watchedStages = new Set();
150
156
  for (const { stage } of logFiles) {
151
157
  if (watchedStages.has(stage)) continue;
152
158
  watchedStages.add(stage);
153
159
  const actualStage = stage === 'orchestrator' ? null : stage;
154
- watchLogFile(actualStage);
160
+ watchLogFile(actualStage, options);
155
161
  }
156
162
  const logsDir = join(logsBase, 'logs');
157
- const dirKey = '__logs_dir__';
163
+ const explicitRunId = options.runId || null;
164
+ const dirKey = explicitRunId
165
+ ? `${explicitRunId}__logs_dir__`
166
+ : '__logs_dir__';
158
167
  if (logWatchers.has(dirKey)) return;
159
168
  if (!existsSync(logsDir)) return;
160
169
  try {
@@ -163,16 +172,16 @@ export function createLogWatcher({
163
172
  if (filename.endsWith('.log')) {
164
173
  const stage = filename.replace('.log', '');
165
174
  const actualStage = stage === 'orchestrator' ? null : stage;
166
- watchLogFile(actualStage);
175
+ watchLogFile(actualStage, options);
167
176
  } else {
168
177
  const stagePath = join(logsDir, filename);
169
178
  try {
170
179
  if (existsSync(stagePath) && statSync(stagePath).isDirectory()) {
171
180
  const iters = listIterationFiles(logsBase, filename);
172
181
  for (const { iteration, path } of iters) {
173
- watchSingleLogFile(filename, path, iteration);
182
+ watchSingleLogFile(filename, path, iteration, options);
174
183
  }
175
- watchStageDir(filename, stagePath);
184
+ watchStageDir(filename, stagePath, options);
176
185
  }
177
186
  } catch {
178
187
  /* ignore */
@@ -32,6 +32,7 @@ import {
32
32
  stopPipeline as pmStopPipeline,
33
33
  reconcileStatus,
34
34
  } from './process-manager.js';
35
+ import { resolveRunDir } from './run-dir-resolver.js';
35
36
  import { readSettings } from './settings-reader.js';
36
37
  import { discoverRuns } from './watcher.js';
37
38
 
@@ -342,90 +343,100 @@ export function createMessageRouter({
342
343
 
343
344
  if (!proj.wset.logWatcher) return;
344
345
 
345
- const archivedRunDir = runId
346
- ? join(proj.worcaDir, 'results', runId)
347
- : null;
348
- const archivedLogDir = archivedRunDir
349
- ? join(archivedRunDir, 'logs')
350
- : null;
351
- const isArchived = archivedLogDir && existsSync(archivedLogDir);
352
-
353
- if (isArchived) {
354
- proj.wset.logWatcher.sendArchivedLogs(
355
- ws,
356
- archivedLogDir,
357
- stage,
358
- iteration,
359
- );
346
+ // Resolve runId → on-disk run dir. Handles local active (runs/<id>),
347
+ // archived (results/<id>), and worktree overlay (pipelines.d/<id>.json
348
+ // → worktree_path/.worca/runs/<id>). Falls back to the project's
349
+ // latest-active-run base when no runId is given.
350
+ let logsBase;
351
+ let watchOptions;
352
+ if (runId) {
353
+ const runDir = resolveRunDir(proj.worcaDir, runId);
354
+ if (runDir) {
355
+ logsBase = runDir;
356
+ // Tail only when the run is still alive (pipeline.pid present);
357
+ // archived dirs get backfill but no live watcher.
358
+ watchOptions = existsSync(join(runDir, 'pipeline.pid'))
359
+ ? { runDir, runId }
360
+ : null;
361
+ } else {
362
+ // Run not found anywhere; nothing to send or watch.
363
+ return;
364
+ }
360
365
  } else {
361
- const logsBase = proj.wset.logWatcher.resolveLogsBaseDir();
362
- if (stage) {
363
- if (iteration != null) {
364
- const logPath = resolveIterationLogPath(logsBase, stage, iteration);
365
- const lines = readLastLines(logPath, 200);
366
- if (lines.length > 0) {
367
- ws.send(
368
- JSON.stringify({
369
- id: `evt-${Date.now()}`,
370
- ok: true,
371
- type: 'log-bulk',
372
- payload: { stage, iteration, lines },
373
- }),
374
- );
375
- }
376
- } else {
377
- const stageDir = resolveLogPath(logsBase, stage);
378
- if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
379
- const iters = listIterationFiles(logsBase, stage);
380
- for (const { iteration: iterNum, path } of iters) {
381
- const lines = readLastLines(path, 200);
382
- if (lines.length > 0) {
383
- ws.send(
384
- JSON.stringify({
385
- id: `evt-${Date.now()}-iter${iterNum}`,
386
- ok: true,
387
- type: 'log-bulk',
388
- payload: { stage, iteration: iterNum, lines },
389
- }),
390
- );
391
- }
392
- }
393
- } else {
394
- const logPath = join(logsBase, 'logs', `${stage}.log`);
395
- const lines = readLastLines(logPath, 200);
366
+ logsBase = proj.wset.logWatcher.resolveLogsBaseDir();
367
+ watchOptions = {};
368
+ }
369
+
370
+ if (stage) {
371
+ if (iteration != null) {
372
+ const logPath = resolveIterationLogPath(logsBase, stage, iteration);
373
+ const lines = readLastLines(logPath, 200);
374
+ if (lines.length > 0) {
375
+ ws.send(
376
+ JSON.stringify({
377
+ id: `evt-${Date.now()}`,
378
+ ok: true,
379
+ type: 'log-bulk',
380
+ payload: { stage, iteration, lines },
381
+ }),
382
+ );
383
+ }
384
+ } else {
385
+ const stageDir = resolveLogPath(logsBase, stage);
386
+ if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
387
+ const iters = listIterationFiles(logsBase, stage);
388
+ for (const { iteration: iterNum, path } of iters) {
389
+ const lines = readLastLines(path, 200);
396
390
  if (lines.length > 0) {
397
391
  ws.send(
398
392
  JSON.stringify({
399
- id: `evt-${Date.now()}`,
393
+ id: `evt-${Date.now()}-iter${iterNum}`,
400
394
  ok: true,
401
395
  type: 'log-bulk',
402
- payload: { stage, lines },
396
+ payload: { stage, iteration: iterNum, lines },
403
397
  }),
404
398
  );
405
399
  }
406
400
  }
407
- }
408
- proj.wset.logWatcher.watchLogFile(stage);
409
- } else {
410
- const logFiles = listLogFiles(logsBase);
411
- for (const { stage: s2, iteration: iterNum, path } of logFiles) {
412
- const lines = readLastLines(path, 200);
401
+ } else {
402
+ const logPath = join(logsBase, 'logs', `${stage}.log`);
403
+ const lines = readLastLines(logPath, 200);
413
404
  if (lines.length > 0) {
414
405
  ws.send(
415
406
  JSON.stringify({
416
- id: `evt-${Date.now()}-${s2}-${iterNum || 0}`,
407
+ id: `evt-${Date.now()}`,
417
408
  ok: true,
418
409
  type: 'log-bulk',
419
- payload: {
420
- stage: s2,
421
- iteration: iterNum ?? undefined,
422
- lines,
423
- },
410
+ payload: { stage, lines },
424
411
  }),
425
412
  );
426
413
  }
427
414
  }
428
- proj.wset.logWatcher.watchAllLogFiles();
415
+ }
416
+ if (watchOptions) {
417
+ proj.wset.logWatcher.watchLogFile(stage, watchOptions);
418
+ }
419
+ } else {
420
+ const logFiles = listLogFiles(logsBase);
421
+ for (const { stage: s2, iteration: iterNum, path } of logFiles) {
422
+ const lines = readLastLines(path, 200);
423
+ if (lines.length > 0) {
424
+ ws.send(
425
+ JSON.stringify({
426
+ id: `evt-${Date.now()}-${s2}-${iterNum || 0}`,
427
+ ok: true,
428
+ type: 'log-bulk',
429
+ payload: {
430
+ stage: s2,
431
+ iteration: iterNum ?? undefined,
432
+ lines,
433
+ },
434
+ }),
435
+ );
436
+ }
437
+ }
438
+ if (watchOptions) {
439
+ proj.wset.logWatcher.watchAllLogFiles(watchOptions);
429
440
  }
430
441
  }
431
442
  return;