@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.
@@ -1,4 +1,7 @@
1
- import { readMergedSettings } from './settings-merge.js';
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
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
- import { MultiWatcher } from './multi-watcher.js';
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
- ...factories,
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
- resolveActiveRunDir: this.statusWatcher
157
- ? this.statusWatcher.resolveActiveRunDir
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
- 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
- };
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. Check active_run pointer for the current run
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
- // 3. Legacy: flat .worca/status.json
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
- // 4. Results: handle both dir format (results/{id}/status.json) and file format (results/{id}.json)
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. Active run
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
- // 3. Legacy flat status.json
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
- // 4. Results
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
+ }