@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.
- package/app/main.bundle.js +836 -716
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +9 -1
- package/bin/worca-ui.js +170 -56
- package/package.json +3 -1
- package/scripts/build-frontend.js +15 -0
- 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 +27 -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/schemas/keys.json +39 -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
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
+
}
|