@worca/ui 0.12.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 +1192 -940
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +1 -7
- package/app/styles.css +67 -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 +208 -88
- package/server/process-registry.js +92 -0
- package/server/project-routes.js +226 -230
- 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 +11 -54
- package/server/watcher.js +112 -43
- package/server/worktree-ops.js +72 -0
- package/server/worktrees-routes.js +272 -0
- package/server/ws-log-watcher.js +36 -27
- package/server/ws-message-router.js +76 -115
- package/server/ws-modular.js +11 -2
- package/server/ws-status-watcher.js +187 -23
- package/server/ws.js +1 -1
- package/server/multi-watcher.js +0 -237
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteSync } from './atomic-write.js';
|
|
3
|
+
import { GLOBAL_DEFAULTS } from './keys-schema.js';
|
|
4
|
+
import { deepMerge, readMergedSettings } from './settings-merge.js';
|
|
2
5
|
|
|
3
6
|
export function readSettings(path) {
|
|
4
7
|
try {
|
|
@@ -21,3 +24,30 @@ export function readSettings(path) {
|
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
export function readGlobalSettings(globalSettingsPath) {
|
|
29
|
+
let raw = {};
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(readFileSync(globalSettingsPath, 'utf-8'));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.code === 'ENOENT') {
|
|
34
|
+
// First-run: file doesn't exist yet — return defaults
|
|
35
|
+
} else if (err instanceof SyntaxError) {
|
|
36
|
+
console.error(
|
|
37
|
+
`Invalid JSON in ${globalSettingsPath}: ${err.message}; falling back to defaults`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) raw = {};
|
|
44
|
+
raw.worca = deepMerge(GLOBAL_DEFAULTS, raw.worca || {});
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeGlobalSettings(globalSettingsPath, partial) {
|
|
49
|
+
const existing = readGlobalSettings(globalSettingsPath);
|
|
50
|
+
const merged = deepMerge(existing, partial);
|
|
51
|
+
atomicWriteSync(globalSettingsPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
@@ -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,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import {
|
|
13
|
+
import { resolveRunDir } from './run-dir-resolver.js';
|
|
14
14
|
import { createBeadsWatcher } from './ws-beads-watcher.js';
|
|
15
15
|
import { createEventWatcher } from './ws-event-watcher.js';
|
|
16
16
|
import { createLogWatcher } from './ws-log-watcher.js';
|
|
@@ -32,14 +32,12 @@ export class WatcherSet {
|
|
|
32
32
|
this._deps = deps;
|
|
33
33
|
this._closed = false;
|
|
34
34
|
this._tier = TIER_POLLING;
|
|
35
|
-
this._skipMultiWatcher = !!factoryOverrides._skipMultiWatcher;
|
|
36
|
-
const { _skipMultiWatcher, ...factories } = factoryOverrides;
|
|
37
35
|
this._factories = {
|
|
38
36
|
createStatusWatcher,
|
|
39
37
|
createLogWatcher,
|
|
40
38
|
createBeadsWatcher,
|
|
41
39
|
createEventWatcher,
|
|
42
|
-
...
|
|
40
|
+
...factoryOverrides,
|
|
43
41
|
};
|
|
44
42
|
|
|
45
43
|
/** @type {ReturnType<typeof createStatusWatcher> | null} */
|
|
@@ -50,8 +48,6 @@ export class WatcherSet {
|
|
|
50
48
|
this.beadsWatcher = null;
|
|
51
49
|
/** @type {ReturnType<typeof createEventWatcher> | null} */
|
|
52
50
|
this.eventWatcher = null;
|
|
53
|
-
/** @type {MultiWatcher | null} */
|
|
54
|
-
this.multiWatcher = null;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
get worcaDir() {
|
|
@@ -93,28 +89,6 @@ export class WatcherSet {
|
|
|
93
89
|
if (this._tier === TIER_FULL) {
|
|
94
90
|
this._createSecondaryWatchers();
|
|
95
91
|
}
|
|
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
92
|
}
|
|
119
93
|
|
|
120
94
|
/** Create status watcher (always needed). */
|
|
@@ -153,8 +127,8 @@ export class WatcherSet {
|
|
|
153
127
|
try {
|
|
154
128
|
this.logWatcher = this._factories.createLogWatcher({
|
|
155
129
|
broadcaster,
|
|
156
|
-
|
|
157
|
-
? this.statusWatcher.
|
|
130
|
+
resolveLatestRunDir: this.statusWatcher
|
|
131
|
+
? this.statusWatcher.resolveLatestRunDir
|
|
158
132
|
: () => worcaDir,
|
|
159
133
|
worcaDir,
|
|
160
134
|
currentActiveRunId: this.statusWatcher
|
|
@@ -190,16 +164,13 @@ export class WatcherSet {
|
|
|
190
164
|
// Event watcher
|
|
191
165
|
if (!this.eventWatcher) {
|
|
192
166
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
return join(worcaDir, 'runs', runId);
|
|
202
|
-
};
|
|
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);
|
|
203
174
|
|
|
204
175
|
this.eventWatcher = this._factories.createEventWatcher({
|
|
205
176
|
broadcaster,
|
|
@@ -236,15 +207,6 @@ export class WatcherSet {
|
|
|
236
207
|
if (this._closed) return;
|
|
237
208
|
this._closed = true;
|
|
238
209
|
|
|
239
|
-
if (this.multiWatcher) {
|
|
240
|
-
try {
|
|
241
|
-
this.multiWatcher.destroy();
|
|
242
|
-
} catch {
|
|
243
|
-
// ignore cleanup errors
|
|
244
|
-
}
|
|
245
|
-
this.multiWatcher = null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
210
|
for (const w of [
|
|
249
211
|
this.statusWatcher,
|
|
250
212
|
this.logWatcher,
|
|
@@ -274,11 +236,6 @@ export class WatcherSet {
|
|
|
274
236
|
return count;
|
|
275
237
|
}
|
|
276
238
|
|
|
277
|
-
/** Get multi-pipeline watcher (may be null for pipeline-level WatcherSets). */
|
|
278
|
-
getMultiWatcher() {
|
|
279
|
-
return this.multiWatcher;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
239
|
/** Delegate to status watcher's scheduleRefresh. */
|
|
283
240
|
scheduleRefresh() {
|
|
284
241
|
this.statusWatcher?.scheduleRefresh();
|
package/server/watcher.js
CHANGED
|
@@ -49,28 +49,7 @@ export function discoverRuns(worcaDir) {
|
|
|
49
49
|
const runs = [];
|
|
50
50
|
const seenIds = new Set();
|
|
51
51
|
|
|
52
|
-
// 1.
|
|
53
|
-
const activeRunPath = join(worcaDir, 'active_run');
|
|
54
|
-
if (existsSync(activeRunPath)) {
|
|
55
|
-
try {
|
|
56
|
-
const activeId = readFileSync(activeRunPath, 'utf8').trim();
|
|
57
|
-
const runDir = join(worcaDir, 'runs', activeId);
|
|
58
|
-
const candidate = join(runDir, 'status.json');
|
|
59
|
-
if (existsSync(candidate)) {
|
|
60
|
-
let status = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
61
|
-
status = enrichWithDispatchEvents(status, runDir);
|
|
62
|
-
const active =
|
|
63
|
-
!isTerminal(status) && status.pipeline_status === 'running';
|
|
64
|
-
const id = createRunId(status);
|
|
65
|
-
runs.push({ id, active, ...status });
|
|
66
|
-
seenIds.add(id);
|
|
67
|
-
}
|
|
68
|
-
} catch {
|
|
69
|
-
/* ignore */
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 2. Scan .worca/runs/ for other runs
|
|
52
|
+
// 1. Scan .worca/runs/ for runs
|
|
74
53
|
const runsDir = join(worcaDir, 'runs');
|
|
75
54
|
if (existsSync(runsDir)) {
|
|
76
55
|
for (const entry of readdirSync(runsDir)) {
|
|
@@ -92,7 +71,7 @@ export function discoverRuns(worcaDir) {
|
|
|
92
71
|
}
|
|
93
72
|
}
|
|
94
73
|
|
|
95
|
-
//
|
|
74
|
+
// 2. Legacy: flat .worca/status.json
|
|
96
75
|
const statusPath = join(worcaDir, 'status.json');
|
|
97
76
|
if (existsSync(statusPath)) {
|
|
98
77
|
try {
|
|
@@ -109,7 +88,7 @@ export function discoverRuns(worcaDir) {
|
|
|
109
88
|
}
|
|
110
89
|
}
|
|
111
90
|
|
|
112
|
-
//
|
|
91
|
+
// 3. Results: handle both dir format (results/{id}/status.json) and file format (results/{id}.json)
|
|
113
92
|
const resultsDir = join(worcaDir, 'results');
|
|
114
93
|
if (existsSync(resultsDir)) {
|
|
115
94
|
for (const entry of readdirSync(resultsDir, { withFileTypes: true })) {
|
|
@@ -148,6 +127,51 @@ export function discoverRuns(worcaDir) {
|
|
|
148
127
|
}
|
|
149
128
|
}
|
|
150
129
|
|
|
130
|
+
// 4. Fan out across pipelines.d/ registry entries (worktree runs)
|
|
131
|
+
const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
|
|
132
|
+
if (existsSync(pipelinesDir)) {
|
|
133
|
+
for (const entry of readdirSync(pipelinesDir)) {
|
|
134
|
+
if (!entry.endsWith('.json')) continue;
|
|
135
|
+
try {
|
|
136
|
+
const reg = JSON.parse(readFileSync(join(pipelinesDir, entry), 'utf8'));
|
|
137
|
+
if (!reg.worktree_path) continue;
|
|
138
|
+
const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
|
|
139
|
+
if (!existsSync(wtRunsDir)) continue;
|
|
140
|
+
for (const runEntry of readdirSync(wtRunsDir)) {
|
|
141
|
+
const sp = join(wtRunsDir, runEntry, 'status.json');
|
|
142
|
+
if (!existsSync(sp)) continue;
|
|
143
|
+
try {
|
|
144
|
+
let status = JSON.parse(readFileSync(sp, 'utf8'));
|
|
145
|
+
status = enrichWithDispatchEvents(
|
|
146
|
+
status,
|
|
147
|
+
join(wtRunsDir, runEntry),
|
|
148
|
+
);
|
|
149
|
+
const id = createRunId(status);
|
|
150
|
+
if (seenIds.has(id)) continue;
|
|
151
|
+
seenIds.add(id);
|
|
152
|
+
const active =
|
|
153
|
+
!isTerminal(status) && status.pipeline_status === 'running';
|
|
154
|
+
runs.push({
|
|
155
|
+
id,
|
|
156
|
+
active,
|
|
157
|
+
...status,
|
|
158
|
+
worktree_worca_dir: join(reg.worktree_path, '.worca'),
|
|
159
|
+
is_worktree_run: true,
|
|
160
|
+
fleet_id: reg.fleet_id || null,
|
|
161
|
+
workspace_id: reg.workspace_id || null,
|
|
162
|
+
group_type: reg.group_type || null,
|
|
163
|
+
target_branch: reg.target_branch || null,
|
|
164
|
+
});
|
|
165
|
+
} catch {
|
|
166
|
+
/* ignore malformed status */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore malformed registry entry */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
151
175
|
return runs;
|
|
152
176
|
}
|
|
153
177
|
|
|
@@ -159,23 +183,7 @@ export async function discoverRunsAsync(worcaDir) {
|
|
|
159
183
|
const runs = [];
|
|
160
184
|
const seenIds = new Set();
|
|
161
185
|
|
|
162
|
-
// 1.
|
|
163
|
-
const activeRunPath = join(worcaDir, 'active_run');
|
|
164
|
-
try {
|
|
165
|
-
const activeId = (await readFile(activeRunPath, 'utf8')).trim();
|
|
166
|
-
const runDir = join(worcaDir, 'runs', activeId);
|
|
167
|
-
const candidate = join(runDir, 'status.json');
|
|
168
|
-
let status = JSON.parse(await readFile(candidate, 'utf8'));
|
|
169
|
-
status = enrichWithDispatchEvents(status, runDir);
|
|
170
|
-
const active = !isTerminal(status) && status.pipeline_status === 'running';
|
|
171
|
-
const id = createRunId(status);
|
|
172
|
-
runs.push({ id, active, ...status });
|
|
173
|
-
seenIds.add(id);
|
|
174
|
-
} catch {
|
|
175
|
-
/* ignore */
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// 2. Scan .worca/runs/
|
|
186
|
+
// 1. Scan .worca/runs/
|
|
179
187
|
const runsDir = join(worcaDir, 'runs');
|
|
180
188
|
try {
|
|
181
189
|
const entries = await readdir(runsDir);
|
|
@@ -203,7 +211,7 @@ export async function discoverRunsAsync(worcaDir) {
|
|
|
203
211
|
/* ignore */
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
//
|
|
214
|
+
// 2. Legacy flat status.json
|
|
207
215
|
try {
|
|
208
216
|
const status = JSON.parse(
|
|
209
217
|
await readFile(join(worcaDir, 'status.json'), 'utf8'),
|
|
@@ -219,7 +227,7 @@ export async function discoverRunsAsync(worcaDir) {
|
|
|
219
227
|
/* ignore */
|
|
220
228
|
}
|
|
221
229
|
|
|
222
|
-
//
|
|
230
|
+
// 3. Results
|
|
223
231
|
const resultsDir = join(worcaDir, 'results');
|
|
224
232
|
try {
|
|
225
233
|
const entries = await readdir(resultsDir, { withFileTypes: true });
|
|
@@ -252,6 +260,67 @@ export async function discoverRunsAsync(worcaDir) {
|
|
|
252
260
|
/* ignore */
|
|
253
261
|
}
|
|
254
262
|
|
|
263
|
+
// 4. Fan out across pipelines.d/ registry entries (worktree runs)
|
|
264
|
+
const pipelinesDirAsync = join(worcaDir, 'multi', 'pipelines.d');
|
|
265
|
+
try {
|
|
266
|
+
const regEntries = await readdir(pipelinesDirAsync);
|
|
267
|
+
const wtReadPromises = regEntries
|
|
268
|
+
.filter((e) => e.endsWith('.json'))
|
|
269
|
+
.map(async (e) => {
|
|
270
|
+
try {
|
|
271
|
+
const reg = JSON.parse(
|
|
272
|
+
await readFile(join(pipelinesDirAsync, e), 'utf8'),
|
|
273
|
+
);
|
|
274
|
+
if (!reg.worktree_path) return [];
|
|
275
|
+
const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
|
|
276
|
+
let runEntries;
|
|
277
|
+
try {
|
|
278
|
+
runEntries = await readdir(wtRunsDir);
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
const results = [];
|
|
283
|
+
for (const runEntry of runEntries) {
|
|
284
|
+
try {
|
|
285
|
+
const sp = join(wtRunsDir, runEntry, 'status.json');
|
|
286
|
+
let status = JSON.parse(await readFile(sp, 'utf8'));
|
|
287
|
+
status = enrichWithDispatchEvents(
|
|
288
|
+
status,
|
|
289
|
+
join(wtRunsDir, runEntry),
|
|
290
|
+
);
|
|
291
|
+
results.push({ status, reg });
|
|
292
|
+
} catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return results;
|
|
297
|
+
} catch {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
const wtResults = (await Promise.all(wtReadPromises)).flat();
|
|
302
|
+
for (const { status, reg } of wtResults) {
|
|
303
|
+
const id = createRunId(status);
|
|
304
|
+
if (seenIds.has(id)) continue;
|
|
305
|
+
seenIds.add(id);
|
|
306
|
+
const active =
|
|
307
|
+
!isTerminal(status) && status.pipeline_status === 'running';
|
|
308
|
+
runs.push({
|
|
309
|
+
id,
|
|
310
|
+
active,
|
|
311
|
+
...status,
|
|
312
|
+
worktree_worca_dir: join(reg.worktree_path, '.worca'),
|
|
313
|
+
is_worktree_run: true,
|
|
314
|
+
fleet_id: reg.fleet_id || null,
|
|
315
|
+
workspace_id: reg.workspace_id || null,
|
|
316
|
+
group_type: reg.group_type || null,
|
|
317
|
+
target_branch: reg.target_branch || null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
/* ignore */
|
|
322
|
+
}
|
|
323
|
+
|
|
255
324
|
return runs;
|
|
256
325
|
}
|
|
257
326
|
|
|
@@ -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
|
+
}
|