@worca/ui 0.13.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ import { join } from 'node:path';
2
+ import { Router } from 'express';
3
+ import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
4
+
5
+ const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
6
+ const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
7
+ const MIN_DISK_BYTES = 500_000_000;
8
+ const MAX_DISK_BYTES = 50_000_000_000;
9
+
10
+ export function validateGlobalSettingsPayload(body) {
11
+ const details = [];
12
+
13
+ if (body.worca !== undefined) {
14
+ if (
15
+ typeof body.worca !== 'object' ||
16
+ body.worca === null ||
17
+ Array.isArray(body.worca)
18
+ ) {
19
+ details.push('worca must be an object');
20
+ return { valid: false, details };
21
+ }
22
+ const w = body.worca;
23
+
24
+ if (w.parallel !== undefined) {
25
+ if (
26
+ typeof w.parallel !== 'object' ||
27
+ w.parallel === null ||
28
+ Array.isArray(w.parallel)
29
+ ) {
30
+ details.push('worca.parallel must be an object');
31
+ } else {
32
+ if (w.parallel.max_concurrent_pipelines !== undefined) {
33
+ const v = w.parallel.max_concurrent_pipelines;
34
+ if (!Number.isInteger(v) || v < 1 || v > 100) {
35
+ details.push(
36
+ 'max_concurrent_pipelines must be an integer between 1 and 100',
37
+ );
38
+ }
39
+ }
40
+ if (w.parallel.cleanup_policy !== undefined) {
41
+ if (!VALID_CLEANUP_POLICIES.includes(w.parallel.cleanup_policy)) {
42
+ details.push(
43
+ `cleanup_policy must be one of: ${VALID_CLEANUP_POLICIES.join(', ')}`,
44
+ );
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ if (w.ui !== undefined) {
51
+ if (typeof w.ui !== 'object' || w.ui === null || Array.isArray(w.ui)) {
52
+ details.push('worca.ui must be an object');
53
+ } else if (w.ui.worktree_disk_warning_bytes !== undefined) {
54
+ const v = w.ui.worktree_disk_warning_bytes;
55
+ if (
56
+ typeof v !== 'number' ||
57
+ !Number.isFinite(v) ||
58
+ v < MIN_DISK_BYTES ||
59
+ v > MAX_DISK_BYTES
60
+ ) {
61
+ details.push(
62
+ `worktree_disk_warning_bytes must be a number between ${MIN_DISK_BYTES} and ${MAX_DISK_BYTES}`,
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ if (w.circuit_breaker !== undefined) {
69
+ if (
70
+ typeof w.circuit_breaker !== 'object' ||
71
+ w.circuit_breaker === null ||
72
+ Array.isArray(w.circuit_breaker)
73
+ ) {
74
+ details.push('worca.circuit_breaker must be an object');
75
+ } else if (w.circuit_breaker.classifier_model !== undefined) {
76
+ if (!VALID_MODELS.includes(w.circuit_breaker.classifier_model)) {
77
+ details.push(
78
+ `classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
79
+ );
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return details.length ? { valid: false, details } : { valid: true };
86
+ }
87
+
88
+ export function createPreferencesRouter({ prefsDir }) {
89
+ const router = Router();
90
+ const globalSettingsPath = join(prefsDir, 'settings.json');
91
+
92
+ router.get('/', (_req, res) => {
93
+ try {
94
+ const prefs = readGlobalSettings(globalSettingsPath);
95
+ res.json({ ok: true, preferences: prefs });
96
+ } catch (err) {
97
+ res.status(500).json({
98
+ ok: false,
99
+ error: 'Failed to read global preferences',
100
+ detail: err.message,
101
+ });
102
+ }
103
+ });
104
+
105
+ router.put('/', (req, res) => {
106
+ const body = req.body;
107
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
108
+ return res.status(400).json({
109
+ ok: false,
110
+ error: {
111
+ code: 'validation_error',
112
+ message: 'Request body must be a JSON object',
113
+ details: [],
114
+ },
115
+ });
116
+ }
117
+
118
+ const validation = validateGlobalSettingsPayload(body);
119
+ if (!validation.valid) {
120
+ return res.status(400).json({
121
+ ok: false,
122
+ error: {
123
+ code: 'validation_error',
124
+ message: 'Invalid preferences payload',
125
+ details: validation.details,
126
+ },
127
+ });
128
+ }
129
+
130
+ try {
131
+ const merged = writeGlobalSettings(globalSettingsPath, body);
132
+ res.json({ ok: true, preferences: merged });
133
+ } catch (err) {
134
+ res.status(500).json({
135
+ ok: false,
136
+ error: 'Failed to write global preferences',
137
+ detail: err.message,
138
+ });
139
+ }
140
+ });
141
+
142
+ return router;
143
+ }
@@ -22,6 +22,8 @@ import { tmpdir } from 'node:os';
22
22
  import { join, resolve } from 'node:path';
23
23
 
24
24
  import { dispatchExternal } from './dispatch-external.js';
25
+ import { readGlobalSettings } from './settings-reader.js';
26
+ import { removeWorktree } from './worktree-ops.js';
25
27
 
26
28
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
27
29
  const ARG_INLINE_LIMIT = 128 * 1024;
@@ -78,10 +80,11 @@ export class ProcessManager {
78
80
  /**
79
81
  * @param {{ worcaDir: string, projectRoot?: string, settingsPath?: string }} options
80
82
  */
81
- constructor({ worcaDir, projectRoot, settingsPath }) {
83
+ constructor({ worcaDir, projectRoot, settingsPath, prefsDir }) {
82
84
  this.worcaDir = worcaDir;
83
85
  this.projectRoot = projectRoot || process.cwd();
84
86
  this.settingsPath = settingsPath ?? null;
87
+ this.prefsDir = prefsDir ?? null;
85
88
  }
86
89
 
87
90
  /**
@@ -127,10 +130,17 @@ export class ProcessManager {
127
130
  * @returns {{ pid: number } | null}
128
131
  */
129
132
  getRunningPid(runId) {
130
- // Build candidate PID paths: per-run first, then project-level fallback
133
+ // Build candidate PID paths: per-run first (with worktree overlay),
134
+ // then project-level fallback. Worktree runs live under
135
+ // <worktree_path>/.worca/runs/<id>/ and are routed via pipelines.d/.
131
136
  const candidates = [];
132
137
  if (runId) {
133
- candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
138
+ const ctx = this.resolveRunContext(runId);
139
+ if (ctx) {
140
+ candidates.push(join(ctx.runDir, 'pipeline.pid'));
141
+ } else {
142
+ candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
143
+ }
134
144
  }
135
145
  candidates.push(join(this.worcaDir, 'pipeline.pid'));
136
146
 
@@ -285,12 +295,76 @@ export class ProcessManager {
285
295
  }
286
296
  }
287
297
  }
298
+
299
+ this.maybeAutoCleanup(runId);
288
300
  }
289
301
 
290
302
  await Promise.all(dispatches);
291
303
  return fixed;
292
304
  }
293
305
 
306
+ /**
307
+ * Post-completion cleanup hook (§5b).
308
+ * When cleanup_policy is 'on-success' and the run completed cleanly,
309
+ * removes the worktree via worktree-ops and emits a worktree.auto_cleanup
310
+ * event. 'never' (default) and 'manual-only' are both no-ops.
311
+ * @param {string} runId
312
+ * @returns {{ cleaned: boolean, runId?: string, path?: string, reason?: string }}
313
+ */
314
+ maybeAutoCleanup(runId) {
315
+ const ctx = this.resolveRunContext(runId);
316
+ const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
317
+ const statusPath = join(runDir, 'status.json');
318
+
319
+ if (!existsSync(statusPath)) return { cleaned: false };
320
+
321
+ let status;
322
+ try {
323
+ status = JSON.parse(readFileSync(statusPath, 'utf8'));
324
+ } catch {
325
+ return { cleaned: false };
326
+ }
327
+
328
+ const worktreePath = status.worktree_path;
329
+ if (!worktreePath) return { cleaned: false };
330
+
331
+ const exitOk = status.pipeline_status === 'completed';
332
+ if (!exitOk) return { cleaned: false };
333
+
334
+ let policy = 'never';
335
+ if (this.prefsDir) {
336
+ try {
337
+ const globalPrefs = readGlobalSettings(
338
+ join(this.prefsDir, 'settings.json'),
339
+ );
340
+ policy = globalPrefs?.worca?.parallel?.cleanup_policy ?? 'never';
341
+ } catch {
342
+ // Fall back to default 'never'
343
+ }
344
+ }
345
+
346
+ if (policy !== 'on-success') return { cleaned: false };
347
+
348
+ removeWorktree(this.worcaDir, runId);
349
+
350
+ try {
351
+ const eventsPath = join(runDir, 'events.jsonl');
352
+ const evt = {
353
+ schema_version: '1',
354
+ event_id: randomUUID(),
355
+ event_type: 'worktree.auto_cleanup',
356
+ timestamp: new Date().toISOString(),
357
+ run_id: status.run_id ?? runId,
358
+ payload: { runId, path: worktreePath, reason: 'on-success' },
359
+ };
360
+ appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
361
+ } catch {
362
+ /* non-fatal */
363
+ }
364
+
365
+ return { cleaned: true, runId, path: worktreePath, reason: 'on-success' };
366
+ }
367
+
294
368
  /**
295
369
  * Start a new pipeline run.
296
370
  * @param {{ inputType?: string, inputValue?: string, msize?: number, mloops?: number, planFile?: string, resume?: boolean, projectRoot?: string }} opts
@@ -527,14 +601,17 @@ export class ProcessManager {
527
601
  // Fire-and-forget: reconcileStatus is async but we intentionally don't
528
602
  // await it — this is a background cleanup path after the response is sent.
529
603
  const worcaDir = this.worcaDir;
530
- const { settingsPath } = this;
604
+ const { settingsPath, prefsDir } = this;
531
605
  const watchdog = setTimeout(() => {
532
606
  try {
533
607
  process.kill(pid, 0); // check alive
534
608
  process.kill(pid, 'SIGKILL');
535
- setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
609
+ setTimeout(
610
+ () => reconcileStatus(worcaDir, settingsPath, prefsDir),
611
+ 500,
612
+ );
536
613
  } catch {
537
- reconcileStatus(worcaDir, settingsPath);
614
+ reconcileStatus(worcaDir, settingsPath, prefsDir);
538
615
  }
539
616
  }, 10000);
540
617
  watchdog.unref();
@@ -823,9 +900,13 @@ export function getRunningPid(worcaDir, runId) {
823
900
  return new ProcessManager({ worcaDir }).getRunningPid(runId);
824
901
  }
825
902
 
826
- /** @param {string} worcaDir @param {string} [settingsPath] */
827
- export function reconcileStatus(worcaDir, settingsPath) {
828
- return new ProcessManager({ worcaDir, settingsPath }).reconcileStatus();
903
+ /** @param {string} worcaDir @param {string} [settingsPath] @param {string} [prefsDir] */
904
+ export function reconcileStatus(worcaDir, settingsPath, prefsDir) {
905
+ return new ProcessManager({
906
+ worcaDir,
907
+ settingsPath,
908
+ prefsDir,
909
+ }).reconcileStatus();
829
910
  }
830
911
 
831
912
  /** @param {string} worcaDir @param {object} opts */
@@ -0,0 +1,92 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { atomicWriteSync } from './atomic-write.js';
4
+
5
+ function isPidAlive(pid) {
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ } catch (err) {
10
+ if (err.code === 'EPERM') return true;
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function clearStalePid(statusPath, status) {
16
+ try {
17
+ const patched = {
18
+ ...status,
19
+ pipeline_status: 'error',
20
+ error: 'Stale PID: process no longer running',
21
+ };
22
+ atomicWriteSync(statusPath, `${JSON.stringify(patched, null, 2)}\n`);
23
+ } catch {
24
+ // best-effort
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Count running pipelines across all registered projects.
30
+ * Walks ~/.worca/projects.d/, checks each project's .worca/runs/ for
31
+ * status.json entries with pipeline_status=running, and verifies PID liveness.
32
+ * Prunes stale PIDs (dead processes still marked as running).
33
+ */
34
+ export function countRunningPipelinesAcrossProjects(prefsDir) {
35
+ const projectsDir = join(prefsDir, 'projects.d');
36
+ if (!existsSync(projectsDir)) return 0;
37
+
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(projectsDir);
41
+ } catch {
42
+ return 0;
43
+ }
44
+
45
+ let count = 0;
46
+
47
+ for (const file of entries) {
48
+ if (!file.endsWith('.json')) continue;
49
+
50
+ let project;
51
+ try {
52
+ project = JSON.parse(readFileSync(join(projectsDir, file), 'utf-8'));
53
+ } catch {
54
+ continue;
55
+ }
56
+
57
+ if (!project || typeof project.path !== 'string') continue;
58
+
59
+ const runsDir = join(project.path, '.worca', 'runs');
60
+ if (!existsSync(runsDir)) continue;
61
+
62
+ let runEntries;
63
+ try {
64
+ runEntries = readdirSync(runsDir);
65
+ } catch {
66
+ continue;
67
+ }
68
+
69
+ for (const runEntry of runEntries) {
70
+ const statusPath = join(runsDir, runEntry, 'status.json');
71
+ if (!existsSync(statusPath)) continue;
72
+
73
+ let status;
74
+ try {
75
+ status = JSON.parse(readFileSync(statusPath, 'utf-8'));
76
+ } catch {
77
+ continue;
78
+ }
79
+
80
+ if (status.pipeline_status !== 'running') continue;
81
+ if (!status.pid) continue;
82
+
83
+ if (isPidAlive(status.pid)) {
84
+ count++;
85
+ } else {
86
+ clearStalePid(statusPath, status);
87
+ }
88
+ }
89
+ }
90
+
91
+ return count;
92
+ }