@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
package/app/styles.css
CHANGED
|
@@ -1039,9 +1039,17 @@ sl-details.log-history-panel::part(content) {
|
|
|
1039
1039
|
min-width: 140px;
|
|
1040
1040
|
}
|
|
1041
1041
|
|
|
1042
|
-
|
|
1042
|
+
/* Prefix-slot icon centering. Inline SVGs default to baseline alignment,
|
|
1043
|
+
which sits them at the top of an sl-input. Flex-center the slot wrapper
|
|
1044
|
+
so any iconSvg(...) we drop into a prefix lines up with the placeholder
|
|
1045
|
+
/ value text. Generic rule covers every sl-input; .log-controls keeps
|
|
1046
|
+
the extra left padding it always had. */
|
|
1047
|
+
sl-input [slot="prefix"] {
|
|
1043
1048
|
display: flex;
|
|
1044
1049
|
align-items: center;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.log-controls sl-input [slot="prefix"] {
|
|
1045
1053
|
padding-left: 4px;
|
|
1046
1054
|
}
|
|
1047
1055
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pipeline monitoring UI for worca-cc",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"lit-html": "^3.3.1",
|
|
64
64
|
"lucide": "^0.577.0",
|
|
65
65
|
"marked": "^17.0.1",
|
|
66
|
+
"proper-lockfile": "^4.1.2",
|
|
66
67
|
"ws": "^8.18.3"
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
package/server/app.js
CHANGED
|
@@ -11,6 +11,8 @@ import express from 'express';
|
|
|
11
11
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
12
12
|
import { RAW_BODY } from './integrations/index.js';
|
|
13
13
|
import { verify } from './integrations/verify.js';
|
|
14
|
+
import { LaunchLock } from './launch-lock.js';
|
|
15
|
+
import { createPreferencesRouter } from './preferences-routes.js';
|
|
14
16
|
import { ProcessManager } from './process-manager.js';
|
|
15
17
|
import { scanDirectory } from './project-registry.js';
|
|
16
18
|
import {
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
projectResolver,
|
|
20
22
|
} from './project-routes.js';
|
|
21
23
|
import { validateIntegrationsConfig } from './settings-validator.js';
|
|
24
|
+
import { createStatusRouter } from './status-routes.js';
|
|
22
25
|
import { discoverSubagents } from './subagents-discovery.js';
|
|
23
26
|
import { checkWorcaVersion } from './version-check.js';
|
|
24
27
|
import { getVersionInfo } from './versions.js';
|
|
@@ -89,6 +92,13 @@ export function createApp(options = {}) {
|
|
|
89
92
|
const webhookInbox = options.webhookInbox || createInbox();
|
|
90
93
|
app.locals.webhookInbox = webhookInbox;
|
|
91
94
|
|
|
95
|
+
// Single LaunchLock instance shared across BOTH legacy /api and
|
|
96
|
+
// /api/projects/:id mounts so the global max_concurrent_pipelines cap is
|
|
97
|
+
// enforced atomically across all entry points. Without this, two routers
|
|
98
|
+
// each held their own mutex and concurrent launches via /api/runs +
|
|
99
|
+
// /api/projects/:id/runs could both pass the cap check and start.
|
|
100
|
+
const launchLock = new LaunchLock();
|
|
101
|
+
|
|
92
102
|
// ─── Legacy single-project API ─────────────────────────────────────────
|
|
93
103
|
// Mounts the shared project-scoped routes at /api with a middleware that
|
|
94
104
|
// injects req.project from the closure options, so /api/runs, /api/settings,
|
|
@@ -112,7 +122,12 @@ export function createApp(options = {}) {
|
|
|
112
122
|
};
|
|
113
123
|
next();
|
|
114
124
|
},
|
|
115
|
-
createProjectScopedRoutes({
|
|
125
|
+
createProjectScopedRoutes({
|
|
126
|
+
prefsDir,
|
|
127
|
+
serverHost,
|
|
128
|
+
serverPort,
|
|
129
|
+
launchLock,
|
|
130
|
+
}),
|
|
116
131
|
);
|
|
117
132
|
|
|
118
133
|
// ─── Unique routes (not in project-scoped router) ──────────────────────
|
|
@@ -519,6 +534,8 @@ export function createApp(options = {}) {
|
|
|
519
534
|
|
|
520
535
|
// ─── Multi-project routes ──────────────────────────────────────────────
|
|
521
536
|
if (prefsDir) {
|
|
537
|
+
app.use('/api/preferences', createPreferencesRouter({ prefsDir }));
|
|
538
|
+
app.use('/api/status', createStatusRouter({ prefsDir }));
|
|
522
539
|
app.use(
|
|
523
540
|
'/api/projects',
|
|
524
541
|
createProjectRoutes({ prefsDir, projectRoot, serverHost, serverPort }),
|
|
@@ -526,7 +543,12 @@ export function createApp(options = {}) {
|
|
|
526
543
|
app.use(
|
|
527
544
|
'/api/projects/:projectId',
|
|
528
545
|
projectResolver({ prefsDir, projectRoot }),
|
|
529
|
-
createProjectScopedRoutes({
|
|
546
|
+
createProjectScopedRoutes({
|
|
547
|
+
prefsDir,
|
|
548
|
+
serverHost,
|
|
549
|
+
serverPort,
|
|
550
|
+
launchLock,
|
|
551
|
+
}),
|
|
530
552
|
);
|
|
531
553
|
}
|
|
532
554
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write: write to a temp file then rename into place.
|
|
3
|
+
* Prevents partial reads when a reader opens the file mid-write.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function atomicWriteSync(filePath, data, options = {}) {
|
|
10
|
+
const dir = dirname(filePath);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
const tmp = join(
|
|
13
|
+
dir,
|
|
14
|
+
`.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
15
|
+
);
|
|
16
|
+
writeFileSync(tmp, data, options);
|
|
17
|
+
renameSync(tmp, filePath);
|
|
18
|
+
}
|
package/server/beads-reader.js
CHANGED
|
@@ -89,15 +89,40 @@ export async function listUnlinkedIssues(beadsDb) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Returns { runId: { total, done } } for every run:<id> label in the beads db.
|
|
94
|
+
*
|
|
95
|
+
* `total` comes from the cheap `bd label list-all` count. `done` requires
|
|
96
|
+
* looking at issue status, so we query `bd list --label-any run:<id>` per
|
|
97
|
+
* run and count statuses === "closed". N+1 queries, but N is bounded by
|
|
98
|
+
* the number of pipeline runs and this endpoint is called on app load /
|
|
99
|
+
* project switch only, not on every render.
|
|
100
|
+
*/
|
|
92
101
|
export async function countIssuesByRunLabel(beadsDb) {
|
|
93
102
|
try {
|
|
94
103
|
const rows = await runBd(['label', 'list-all'], beadsDb);
|
|
95
104
|
const counts = {};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
105
|
+
const runLabels = rows.filter((r) => r.label.startsWith('run:'));
|
|
106
|
+
for (const row of runLabels) {
|
|
107
|
+
counts[row.label.replace('run:', '')] = { total: row.count, done: 0 };
|
|
100
108
|
}
|
|
109
|
+
// Count closed issues per label in parallel.
|
|
110
|
+
await Promise.all(
|
|
111
|
+
runLabels.map(async (row) => {
|
|
112
|
+
const runId = row.label.replace('run:', '');
|
|
113
|
+
try {
|
|
114
|
+
const issues = await runBd(
|
|
115
|
+
['list', '--label-any', row.label, '--all', '--limit', '0'],
|
|
116
|
+
beadsDb,
|
|
117
|
+
);
|
|
118
|
+
counts[runId].done = issues.filter(
|
|
119
|
+
(i) => i.status === 'closed',
|
|
120
|
+
).length;
|
|
121
|
+
} catch {
|
|
122
|
+
/* leave done at 0 on per-run failure */
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
101
126
|
return counts;
|
|
102
127
|
} catch {
|
|
103
128
|
return {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
|
|
2
|
+
|
|
3
|
+
const INERT_MILESTONE_KEYS = ['pr_approval', 'deploy_approval'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mutates `blob` in place: extracts misplaced global-only keys and strips
|
|
7
|
+
* inert milestone keys (pr_approval/deploy_approval when set to `true`).
|
|
8
|
+
*
|
|
9
|
+
* Returns { globalExtracted, removedMilestones } for the caller to merge
|
|
10
|
+
* into ~/.worca/settings.json and to surface in the response.
|
|
11
|
+
*/
|
|
12
|
+
export function extractAndStripGlobalKeys(blob) {
|
|
13
|
+
const globalExtracted = {};
|
|
14
|
+
const removedMilestones = [];
|
|
15
|
+
|
|
16
|
+
const worca = blob.worca;
|
|
17
|
+
if (!worca || typeof worca !== 'object') {
|
|
18
|
+
return { globalExtracted, removedMilestones };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const [section, key] of GLOBAL_ONLY_KEYS) {
|
|
22
|
+
const sectionObj = worca[section];
|
|
23
|
+
if (!sectionObj || typeof sectionObj !== 'object') continue;
|
|
24
|
+
if (!(key in sectionObj)) continue;
|
|
25
|
+
|
|
26
|
+
if (!globalExtracted[section]) globalExtracted[section] = {};
|
|
27
|
+
globalExtracted[section][key] = sectionObj[key];
|
|
28
|
+
delete sectionObj[key];
|
|
29
|
+
|
|
30
|
+
if (Object.keys(sectionObj).length === 0) {
|
|
31
|
+
delete worca[section];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const milestones = worca.milestones;
|
|
36
|
+
if (milestones && typeof milestones === 'object') {
|
|
37
|
+
for (const key of INERT_MILESTONE_KEYS) {
|
|
38
|
+
if (milestones[key] === true) {
|
|
39
|
+
delete milestones[key];
|
|
40
|
+
removedMilestones.push(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (Object.keys(milestones).length === 0) {
|
|
44
|
+
delete worca.milestones;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { globalExtracted, removedMilestones };
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const schema = JSON.parse(
|
|
7
|
+
readFileSync(
|
|
8
|
+
resolve(__dirname, '../../src/worca/schemas/keys.json'),
|
|
9
|
+
'utf-8',
|
|
10
|
+
),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const GLOBAL_ONLY_KEYS = schema.global_only_keys;
|
|
14
|
+
export const NORMALIZE_SKIP_KEYS = schema.normalize_skip_keys;
|
|
15
|
+
export const GLOBAL_DEFAULTS = schema.defaults.global;
|
|
16
|
+
export const PROJECT_DEFAULTS = schema.defaults.project;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process async mutex using a promise chain.
|
|
3
|
+
* Used to serialize pipeline launches so max_concurrent_pipelines is enforced atomically.
|
|
4
|
+
*/
|
|
5
|
+
export class LaunchLock {
|
|
6
|
+
#tail = Promise.resolve();
|
|
7
|
+
|
|
8
|
+
acquire() {
|
|
9
|
+
let release;
|
|
10
|
+
const prev = this.#tail;
|
|
11
|
+
this.#tail = new Promise((resolve) => {
|
|
12
|
+
release = resolve;
|
|
13
|
+
});
|
|
14
|
+
return prev.then(() => release);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async withLock(fn) {
|
|
18
|
+
const release = await this.acquire();
|
|
19
|
+
try {
|
|
20
|
+
return await fn();
|
|
21
|
+
} finally {
|
|
22
|
+
release();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -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
|
+
}
|