@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.
- package/app/main.bundle.js +836 -716
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +9 -1
- package/package.json +2 -1
- package/server/app.js +24 -2
- package/server/atomic-write.js +18 -0
- package/server/beads-reader.js +29 -4
- package/server/global-keys.js +49 -0
- package/server/keys-schema.js +16 -0
- package/server/launch-lock.js +25 -0
- package/server/preferences-routes.js +143 -0
- package/server/process-manager.js +90 -9
- package/server/process-registry.js +92 -0
- package/server/project-routes.js +222 -142
- package/server/run-dir-resolver.js +79 -0
- package/server/settings-reader.js +31 -1
- package/server/settings-validator.js +112 -1
- package/server/status-routes.js +23 -0
- package/server/watcher-set.js +8 -10
- package/server/worktree-ops.js +72 -0
- package/server/worktrees-routes.js +3 -80
- package/server/ws-log-watcher.js +33 -24
- package/server/ws-message-router.js +76 -65
|
@@ -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
|
+
}
|
package/server/watcher-set.js
CHANGED
|
@@ -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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 {
|
|
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
|
-
|
|
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 });
|
package/server/ws-log-watcher.js
CHANGED
|
@@ -54,16 +54,21 @@ export function createLogWatcher({
|
|
|
54
54
|
logByteOffsets.clear();
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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()}
|
|
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
|
-
|
|
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;
|