@worca/ui 0.9.0-rc.3 → 0.9.0-rc.5
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 +733 -720
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +5 -4
- package/app/utils/state-actions.js +55 -0
- package/package.json +2 -1
- package/server/app.js +6 -0
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/process-manager.js +174 -35
- package/server/project-routes.js +166 -42
- package/server/ws-beads-watcher.js +22 -6
- package/server/ws-message-router.js +1 -1
package/app/styles.css
CHANGED
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
--status-paused: #f59e0b;
|
|
23
23
|
--status-completed: #22c55e;
|
|
24
24
|
--status-failed: #ef4444;
|
|
25
|
-
--status-resuming: #3b82f6;
|
|
26
25
|
--status-skipped: #94a3b8;
|
|
27
26
|
--status-interrupted: #f59e0b;
|
|
27
|
+
--status-cancelled: #94a3b8;
|
|
28
28
|
/* legacy */
|
|
29
29
|
--status-in-progress: #3b82f6;
|
|
30
30
|
--status-error: #ef4444;
|
|
@@ -1125,8 +1125,8 @@ sl-details.log-history-panel::part(content) {
|
|
|
1125
1125
|
color: var(--status-failed);
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
-
.status-
|
|
1129
|
-
color: var(--status-
|
|
1128
|
+
.status-cancelled {
|
|
1129
|
+
color: var(--status-cancelled);
|
|
1130
1130
|
}
|
|
1131
1131
|
|
|
1132
1132
|
/* --- 18b. Control Buttons --- */
|
|
@@ -1281,11 +1281,11 @@ sl-details.log-history-panel::part(content) {
|
|
|
1281
1281
|
|
|
1282
1282
|
/* Status-colored left border accent on run cards */
|
|
1283
1283
|
.run-card.status-running { border-left: 3px solid var(--status-running); }
|
|
1284
|
-
.run-card.status-resuming { border-left: 3px solid var(--status-resuming); }
|
|
1285
1284
|
.run-card.status-paused { border-left: 3px solid var(--status-paused); }
|
|
1286
1285
|
.run-card.status-completed { border-left: 3px solid var(--status-completed); }
|
|
1287
1286
|
.run-card.status-failed { border-left: 3px solid var(--status-failed); }
|
|
1288
1287
|
.run-card.status-pending { border-left: 3px solid var(--status-pending); }
|
|
1288
|
+
.run-card.status-cancelled { border-left: 3px solid var(--status-cancelled); }
|
|
1289
1289
|
|
|
1290
1290
|
/* --- 21. Dashboard --- */
|
|
1291
1291
|
.dashboard {
|
|
@@ -3991,6 +3991,7 @@ sl-details.learnings-panel::part(content) {
|
|
|
3991
3991
|
.pipeline-succeeded { border-left-color: var(--status-completed); }
|
|
3992
3992
|
.pipeline-failed { border-left-color: var(--status-failed); }
|
|
3993
3993
|
.pipeline-paused { border-left-color: var(--status-paused); }
|
|
3994
|
+
.pipeline-cancelled { border-left-color: var(--status-cancelled); }
|
|
3994
3995
|
.pipeline-unknown { border-left-color: var(--muted); }
|
|
3995
3996
|
|
|
3996
3997
|
.pipeline-card-header {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const STATES = [
|
|
2
|
+
'pending',
|
|
3
|
+
'running',
|
|
4
|
+
'paused',
|
|
5
|
+
'completed',
|
|
6
|
+
'failed',
|
|
7
|
+
'interrupted',
|
|
8
|
+
'cancelled',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const ACTION_MATRIX = {
|
|
12
|
+
stop: { running: true },
|
|
13
|
+
pause: { running: true },
|
|
14
|
+
resume: { paused: true, failed: true, interrupted: true },
|
|
15
|
+
cancel: {
|
|
16
|
+
pending: true,
|
|
17
|
+
running: true,
|
|
18
|
+
paused: true,
|
|
19
|
+
failed: true,
|
|
20
|
+
interrupted: true,
|
|
21
|
+
},
|
|
22
|
+
archive: {
|
|
23
|
+
pending: true,
|
|
24
|
+
paused: true,
|
|
25
|
+
completed: true,
|
|
26
|
+
failed: true,
|
|
27
|
+
interrupted: true,
|
|
28
|
+
cancelled: true,
|
|
29
|
+
},
|
|
30
|
+
unarchive: {
|
|
31
|
+
completed: true,
|
|
32
|
+
failed: true,
|
|
33
|
+
interrupted: true,
|
|
34
|
+
cancelled: true,
|
|
35
|
+
},
|
|
36
|
+
delete: {
|
|
37
|
+
pending: true,
|
|
38
|
+
paused: true,
|
|
39
|
+
completed: true,
|
|
40
|
+
failed: true,
|
|
41
|
+
interrupted: true,
|
|
42
|
+
cancelled: true,
|
|
43
|
+
},
|
|
44
|
+
learn: {
|
|
45
|
+
paused: true,
|
|
46
|
+
completed: true,
|
|
47
|
+
failed: true,
|
|
48
|
+
interrupted: true,
|
|
49
|
+
cancelled: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function actionAllowed(action, status) {
|
|
54
|
+
return Boolean(ACTION_MATRIX[action]?.[status]);
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.9.0-rc.
|
|
3
|
+
"version": "0.9.0-rc.5",
|
|
4
4
|
"description": "Pipeline monitoring UI for worca-cc",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"app/vendor/",
|
|
36
36
|
"app/protocol.js",
|
|
37
37
|
"app/utils/stage-order.js",
|
|
38
|
+
"app/utils/state-actions.js",
|
|
38
39
|
"scripts/build-frontend.js"
|
|
39
40
|
],
|
|
40
41
|
"engines": {
|
package/server/app.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from './project-routes.js';
|
|
21
21
|
import { validateIntegrationsConfig } from './settings-validator.js';
|
|
22
22
|
import { discoverSubagents } from './subagents-discovery.js';
|
|
23
|
+
import { checkWorcaVersion } from './version-check.js';
|
|
23
24
|
import { getVersionInfo } from './versions.js';
|
|
24
25
|
import { createInbox } from './webhook-inbox.js';
|
|
25
26
|
|
|
@@ -105,6 +106,7 @@ export function createApp(options = {}) {
|
|
|
105
106
|
? new ProcessManager({
|
|
106
107
|
worcaDir,
|
|
107
108
|
projectRoot: projectRoot || process.cwd(),
|
|
109
|
+
settingsPath,
|
|
108
110
|
})
|
|
109
111
|
: null,
|
|
110
112
|
};
|
|
@@ -502,6 +504,10 @@ export function createApp(options = {}) {
|
|
|
502
504
|
app.get('/api/versions', async (req, res) => {
|
|
503
505
|
const force = req.query.force === '1';
|
|
504
506
|
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
507
|
+
// Re-check installed worca-cc version on force refresh
|
|
508
|
+
if (force) {
|
|
509
|
+
app.locals.worcaVersion = await checkWorcaVersion();
|
|
510
|
+
}
|
|
505
511
|
const worcaVersion = app.locals.worcaVersion || null;
|
|
506
512
|
try {
|
|
507
513
|
const data = await getVersionInfo({ prefsPath, worcaVersion, force });
|
package/server/beads-reader.js
CHANGED
|
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
|
|
7
7
|
async function runBd(args, dbPath) {
|
|
8
|
-
const fullArgs = [...args, '--json', '--db', dbPath
|
|
8
|
+
const fullArgs = [...args, '--json', '--db', dbPath];
|
|
9
9
|
const { stdout } = await execFileAsync('bd', fullArgs, {
|
|
10
10
|
encoding: 'utf8',
|
|
11
11
|
timeout: 10000,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
|
|
5
|
+
export function resolvePythonCmd() {
|
|
6
|
+
if (process.env.WORCA_PYTHON) {
|
|
7
|
+
return [process.env.WORCA_PYTHON];
|
|
8
|
+
}
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
return ['py', 'python3', 'python'];
|
|
11
|
+
}
|
|
12
|
+
return ['python3', 'python'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function dispatchExternal({
|
|
16
|
+
runDir,
|
|
17
|
+
settingsPath,
|
|
18
|
+
eventType,
|
|
19
|
+
payload,
|
|
20
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
21
|
+
}) {
|
|
22
|
+
const candidates = resolvePythonCmd();
|
|
23
|
+
const args = [
|
|
24
|
+
'-m',
|
|
25
|
+
'worca.events.dispatch_external',
|
|
26
|
+
'--run-dir',
|
|
27
|
+
runDir,
|
|
28
|
+
'--settings',
|
|
29
|
+
settingsPath,
|
|
30
|
+
'--event-type',
|
|
31
|
+
eventType,
|
|
32
|
+
'--payload-json',
|
|
33
|
+
JSON.stringify(payload),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
let candidateIdx = 0;
|
|
37
|
+
|
|
38
|
+
function trySpawn(resolve) {
|
|
39
|
+
if (candidateIdx >= candidates.length) {
|
|
40
|
+
resolve({ ok: false, reason: 'python_not_found' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cmd = candidates[candidateIdx];
|
|
45
|
+
const spawnArgs = cmd === 'py' ? ['-3', ...args] : args;
|
|
46
|
+
|
|
47
|
+
const child = spawn(cmd, spawnArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
|
+
|
|
49
|
+
let stdoutBuf = '';
|
|
50
|
+
let stderrBuf = '';
|
|
51
|
+
let settled = false;
|
|
52
|
+
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
if (!settled) {
|
|
55
|
+
settled = true;
|
|
56
|
+
child.kill();
|
|
57
|
+
resolve({ ok: false, reason: 'timeout' });
|
|
58
|
+
}
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
|
|
61
|
+
child.stdout.on('data', (chunk) => {
|
|
62
|
+
stdoutBuf += chunk;
|
|
63
|
+
});
|
|
64
|
+
child.stderr.on('data', (chunk) => {
|
|
65
|
+
stderrBuf += chunk;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.on('error', (err) => {
|
|
69
|
+
if (!settled && err.code === 'ENOENT') {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
candidateIdx++;
|
|
72
|
+
trySpawn(resolve);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!settled) {
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve({ ok: false, reason: 'spawn_error', stderr: err.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
child.on('close', (code) => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
|
|
87
|
+
if (code !== 0) {
|
|
88
|
+
resolve({
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: `exit_code_${code}`,
|
|
91
|
+
stderr: stderrBuf,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(stdoutBuf);
|
|
98
|
+
resolve(parsed);
|
|
99
|
+
} catch {
|
|
100
|
+
resolve({ ok: false, reason: 'invalid_response', stdout: stdoutBuf });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve) => trySpawn(resolve));
|
|
106
|
+
}
|
|
@@ -13,12 +13,15 @@ import {
|
|
|
13
13
|
openSync,
|
|
14
14
|
readdirSync,
|
|
15
15
|
readFileSync,
|
|
16
|
+
rmSync,
|
|
16
17
|
unlinkSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
writeSync,
|
|
19
20
|
} from 'node:fs';
|
|
20
21
|
import { tmpdir } from 'node:os';
|
|
21
|
-
import { join } from 'node:path';
|
|
22
|
+
import { join, resolve } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { dispatchExternal } from './dispatch-external.js';
|
|
22
25
|
|
|
23
26
|
/** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
|
|
24
27
|
const ARG_INLINE_LIMIT = 128 * 1024;
|
|
@@ -73,11 +76,12 @@ function cleanupPromptFile(filePath) {
|
|
|
73
76
|
*/
|
|
74
77
|
export class ProcessManager {
|
|
75
78
|
/**
|
|
76
|
-
* @param {{ worcaDir: string, projectRoot?: string }} options
|
|
79
|
+
* @param {{ worcaDir: string, projectRoot?: string, settingsPath?: string }} options
|
|
77
80
|
*/
|
|
78
|
-
constructor({ worcaDir, projectRoot }) {
|
|
81
|
+
constructor({ worcaDir, projectRoot, settingsPath }) {
|
|
79
82
|
this.worcaDir = worcaDir;
|
|
80
83
|
this.projectRoot = projectRoot || process.cwd();
|
|
84
|
+
this.settingsPath = settingsPath ?? null;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
@@ -128,8 +132,9 @@ export class ProcessManager {
|
|
|
128
132
|
*
|
|
129
133
|
* @returns {boolean} true if any status was fixed
|
|
130
134
|
*/
|
|
131
|
-
reconcileStatus() {
|
|
135
|
+
async reconcileStatus() {
|
|
132
136
|
let fixed = false;
|
|
137
|
+
const dispatches = [];
|
|
133
138
|
|
|
134
139
|
// Collect run IDs to check: scan runs/*/pipeline.pid + active_run fallback
|
|
135
140
|
const runIds = new Set();
|
|
@@ -180,8 +185,7 @@ export class ProcessManager {
|
|
|
180
185
|
if (!status.stop_reason) {
|
|
181
186
|
status.stop_reason = 'stale';
|
|
182
187
|
}
|
|
183
|
-
status.pipeline_status =
|
|
184
|
-
status.stop_reason === 'stale' ? 'interrupted' : 'failed';
|
|
188
|
+
status.pipeline_status = 'failed';
|
|
185
189
|
try {
|
|
186
190
|
writeFileSync(
|
|
187
191
|
statusPath,
|
|
@@ -193,7 +197,8 @@ export class ProcessManager {
|
|
|
193
197
|
/* ignore */
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
// Append synthetic
|
|
200
|
+
// Append synthetic failed event if no terminal event exists yet.
|
|
201
|
+
// Status is "failed" (process crash), so the event type must match.
|
|
197
202
|
const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
|
|
198
203
|
let hasTerminalEvent = false;
|
|
199
204
|
if (existsSync(eventsPath)) {
|
|
@@ -214,30 +219,44 @@ export class ProcessManager {
|
|
|
214
219
|
}
|
|
215
220
|
}
|
|
216
221
|
if (!hasTerminalEvent) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
222
|
+
const payload = {
|
|
223
|
+
failed_stage: status.current_stage ?? 'unknown',
|
|
224
|
+
elapsed_ms: elapsedMsSince(status.started_at),
|
|
225
|
+
source: 'stale',
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (this.settingsPath) {
|
|
229
|
+
dispatches.push(
|
|
230
|
+
dispatchExternal({
|
|
231
|
+
runDir: join(this.worcaDir, 'runs', runId),
|
|
232
|
+
settingsPath: this.settingsPath,
|
|
233
|
+
eventType: 'pipeline.run.failed',
|
|
234
|
+
payload,
|
|
235
|
+
}).catch(() => {}),
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
try {
|
|
239
|
+
const evt = {
|
|
240
|
+
schema_version: '1',
|
|
241
|
+
event_id: randomUUID(),
|
|
242
|
+
event_type: 'pipeline.run.failed',
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
run_id: status.run_id ?? runId,
|
|
245
|
+
pipeline: {
|
|
246
|
+
branch: status.branch ?? null,
|
|
247
|
+
work_request: status.work_request ?? null,
|
|
248
|
+
},
|
|
249
|
+
payload: { ...payload, source: 'reconcile' },
|
|
250
|
+
};
|
|
251
|
+
appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
|
|
252
|
+
} catch {
|
|
253
|
+
/* ignore */
|
|
254
|
+
}
|
|
237
255
|
}
|
|
238
256
|
}
|
|
239
257
|
}
|
|
240
258
|
|
|
259
|
+
await Promise.all(dispatches);
|
|
241
260
|
return fixed;
|
|
242
261
|
}
|
|
243
262
|
|
|
@@ -443,17 +462,18 @@ export class ProcessManager {
|
|
|
443
462
|
throw err;
|
|
444
463
|
}
|
|
445
464
|
|
|
446
|
-
// Watchdog: SIGKILL after 10s if still alive, then reconcile status
|
|
465
|
+
// Watchdog: SIGKILL after 10s if still alive, then reconcile status.
|
|
466
|
+
// Fire-and-forget: reconcileStatus is async but we intentionally don't
|
|
467
|
+
// await it — this is a background cleanup path after the response is sent.
|
|
447
468
|
const worcaDir = this.worcaDir;
|
|
469
|
+
const { settingsPath } = this;
|
|
448
470
|
const watchdog = setTimeout(() => {
|
|
449
471
|
try {
|
|
450
472
|
process.kill(pid, 0); // check alive
|
|
451
473
|
process.kill(pid, 'SIGKILL');
|
|
452
|
-
|
|
453
|
-
setTimeout(() => reconcileStatus(worcaDir), 500);
|
|
474
|
+
setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
|
|
454
475
|
} catch {
|
|
455
|
-
|
|
456
|
-
reconcileStatus(worcaDir);
|
|
476
|
+
reconcileStatus(worcaDir, settingsPath);
|
|
457
477
|
}
|
|
458
478
|
}, 10000);
|
|
459
479
|
watchdog.unref();
|
|
@@ -470,6 +490,80 @@ export class ProcessManager {
|
|
|
470
490
|
return { pid, stopped: true };
|
|
471
491
|
}
|
|
472
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Synchronous-style stop: control.json + signal + poll for exit.
|
|
495
|
+
* @param {string} runId
|
|
496
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
497
|
+
* @returns {Promise<{ pid: number, exitCode: null, forced?: boolean }>}
|
|
498
|
+
*/
|
|
499
|
+
async stopPipelineSync(runId, { timeoutMs } = {}) {
|
|
500
|
+
if (timeoutMs === undefined) {
|
|
501
|
+
timeoutMs = process.platform === 'win32' ? 30000 : 5000;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const running = this.getRunningPid(runId);
|
|
505
|
+
if (!running) {
|
|
506
|
+
const e = new Error('not running');
|
|
507
|
+
e.code = 'not_running';
|
|
508
|
+
throw e;
|
|
509
|
+
}
|
|
510
|
+
const { pid } = running;
|
|
511
|
+
|
|
512
|
+
const controlDir = join(this.worcaDir, 'runs', runId);
|
|
513
|
+
mkdirSync(controlDir, { recursive: true });
|
|
514
|
+
writeFileSync(
|
|
515
|
+
join(controlDir, 'control.json'),
|
|
516
|
+
`${JSON.stringify({ action: 'stop', requested_at: new Date().toISOString(), source: 'ui' }, null, 2)}\n`,
|
|
517
|
+
'utf8',
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
if (process.platform !== 'win32') {
|
|
521
|
+
try {
|
|
522
|
+
process.kill(pid, 'SIGTERM');
|
|
523
|
+
} catch {
|
|
524
|
+
/* already dead */
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
this._killAgentSubprocess(runId);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const pollMs = timeoutMs > 10000 ? 500 : 100;
|
|
531
|
+
const deadline = Date.now() + timeoutMs;
|
|
532
|
+
while (Date.now() < deadline) {
|
|
533
|
+
try {
|
|
534
|
+
process.kill(pid, 0);
|
|
535
|
+
} catch {
|
|
536
|
+
return { pid, exitCode: null };
|
|
537
|
+
}
|
|
538
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
process.kill(pid, 'SIGKILL');
|
|
543
|
+
} catch {
|
|
544
|
+
/* already dead */
|
|
545
|
+
}
|
|
546
|
+
return { pid, exitCode: null, forced: true };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Kill the agent subprocess (claude CLI) via agent.pid.
|
|
551
|
+
* Used on Windows where SIGTERM doesn't propagate to child processes.
|
|
552
|
+
* @param {string} runId
|
|
553
|
+
*/
|
|
554
|
+
_killAgentSubprocess(runId) {
|
|
555
|
+
const pidPath = join(this.worcaDir, 'runs', runId, 'agent.pid');
|
|
556
|
+
if (!existsSync(pidPath)) return;
|
|
557
|
+
try {
|
|
558
|
+
const agentPid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
559
|
+
if (!Number.isNaN(agentPid) && agentPid > 0) {
|
|
560
|
+
process.kill(agentPid, 'SIGTERM');
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
/* agent already dead or pid file invalid */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
473
567
|
/**
|
|
474
568
|
* Read the active_run file to get the current run ID.
|
|
475
569
|
* @returns {string|null}
|
|
@@ -485,6 +579,51 @@ export class ProcessManager {
|
|
|
485
579
|
}
|
|
486
580
|
}
|
|
487
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Delete a run directory and clean up references.
|
|
584
|
+
* Refuses if the pipeline is currently running.
|
|
585
|
+
* @param {string} runId
|
|
586
|
+
* @returns {{ deleted: boolean }}
|
|
587
|
+
*/
|
|
588
|
+
deleteRun(runId) {
|
|
589
|
+
const running = this.getRunningPid(runId);
|
|
590
|
+
if (running) {
|
|
591
|
+
const err = new Error(
|
|
592
|
+
'Cannot delete a running pipeline — stop or cancel it first',
|
|
593
|
+
);
|
|
594
|
+
err.code = 'still_running';
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const runsParent = resolve(this.worcaDir, 'runs');
|
|
599
|
+
const runDir = resolve(runsParent, runId);
|
|
600
|
+
if (!runDir.startsWith(runsParent)) {
|
|
601
|
+
const err = new Error('Invalid runId');
|
|
602
|
+
err.code = 'invalid_id';
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
if (!existsSync(runDir)) {
|
|
606
|
+
const err = new Error(`Run "${runId}" not found`);
|
|
607
|
+
err.code = 'not_found';
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
612
|
+
|
|
613
|
+
// Clear active_run pointer if it references this run
|
|
614
|
+
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
615
|
+
if (existsSync(activeRunPath)) {
|
|
616
|
+
try {
|
|
617
|
+
const activeId = readFileSync(activeRunPath, 'utf8').trim();
|
|
618
|
+
if (activeId === runId) unlinkSync(activeRunPath);
|
|
619
|
+
} catch {
|
|
620
|
+
/* ignore */
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return { deleted: true };
|
|
625
|
+
}
|
|
626
|
+
|
|
488
627
|
/**
|
|
489
628
|
* Pause a running pipeline by writing a control file.
|
|
490
629
|
* @param {string} runId - Pipeline run identifier
|
|
@@ -640,9 +779,9 @@ export function getRunningPid(worcaDir, runId) {
|
|
|
640
779
|
return new ProcessManager({ worcaDir }).getRunningPid(runId);
|
|
641
780
|
}
|
|
642
781
|
|
|
643
|
-
/** @param {string} worcaDir */
|
|
644
|
-
export function reconcileStatus(worcaDir) {
|
|
645
|
-
return new ProcessManager({ worcaDir }).reconcileStatus();
|
|
782
|
+
/** @param {string} worcaDir @param {string} [settingsPath] */
|
|
783
|
+
export function reconcileStatus(worcaDir, settingsPath) {
|
|
784
|
+
return new ProcessManager({ worcaDir, settingsPath }).reconcileStatus();
|
|
646
785
|
}
|
|
647
786
|
|
|
648
787
|
/** @param {string} worcaDir @param {object} opts */
|