@worca/ui 0.9.0 → 0.11.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.
Files changed (33) hide show
  1. package/app/main.bundle.js +895 -813
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +216 -9
  4. package/app/utils/state-actions.js +55 -0
  5. package/package.json +6 -4
  6. package/server/app.js +291 -6
  7. package/server/beads-reader.js +1 -1
  8. package/server/dispatch-external.js +106 -0
  9. package/server/ensure-webhook.js +66 -0
  10. package/server/index.js +22 -0
  11. package/server/integrations/adapter.js +91 -0
  12. package/server/integrations/adapters/discord.js +109 -0
  13. package/server/integrations/adapters/slack.js +106 -0
  14. package/server/integrations/adapters/telegram.js +231 -0
  15. package/server/integrations/adapters/webhook_out.js +253 -0
  16. package/server/integrations/allowlist.js +19 -0
  17. package/server/integrations/chat_context.js +68 -0
  18. package/server/integrations/commands/control.js +120 -0
  19. package/server/integrations/commands/global.js +239 -0
  20. package/server/integrations/commands/parser.js +29 -0
  21. package/server/integrations/commands/project.js +394 -0
  22. package/server/integrations/config-loader.js +40 -0
  23. package/server/integrations/index.js +390 -0
  24. package/server/integrations/markdown.js +220 -0
  25. package/server/integrations/rate_limiter.js +131 -0
  26. package/server/integrations/renderers.js +191 -0
  27. package/server/integrations/rest_client.js +17 -0
  28. package/server/integrations/verify.js +23 -0
  29. package/server/process-manager.js +217 -14
  30. package/server/project-routes.js +210 -44
  31. package/server/settings-validator.js +250 -0
  32. package/server/ws-beads-watcher.js +22 -6
  33. package/server/ws-message-router.js +1 -1
@@ -0,0 +1,131 @@
1
+ const BACKOFF_DELAYS = [1000, 5000, 30000];
2
+
3
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+
5
+ export class RingBuffer {
6
+ constructor(size = 100) {
7
+ this._size = size;
8
+ this._buf = new Array(size);
9
+ this._head = 0;
10
+ this._count = 0;
11
+ this.dropped = 0;
12
+ }
13
+
14
+ push(item) {
15
+ if (this._count < this._size) {
16
+ this._count++;
17
+ } else {
18
+ this.dropped++;
19
+ }
20
+ this._buf[this._head] = item;
21
+ this._head = (this._head + 1) % this._size;
22
+ }
23
+
24
+ toArray() {
25
+ if (this._count < this._size) {
26
+ return this._buf.slice(0, this._count);
27
+ }
28
+ const result = [];
29
+ for (let i = 0; i < this._size; i++) {
30
+ result.push(this._buf[(this._head + i) % this._size]);
31
+ }
32
+ return result;
33
+ }
34
+ }
35
+
36
+ export class TokenBucket {
37
+ constructor(ratePerMin, { now = Date.now.bind(Date) } = {}) {
38
+ this._rate = ratePerMin;
39
+ this._tokens = ratePerMin;
40
+ this._lastRefill = now();
41
+ this._now = now;
42
+ }
43
+
44
+ tryConsume() {
45
+ const t = this._now();
46
+ const mins = (t - this._lastRefill) / 60000;
47
+ this._tokens = Math.min(this._rate, this._tokens + mins * this._rate);
48
+ this._lastRefill = t;
49
+ if (this._tokens >= 1) {
50
+ this._tokens -= 1;
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export function createRateLimiter({
58
+ ratePerMin = 20,
59
+ ringSize = 100,
60
+ _sleep = defaultSleep,
61
+ } = {}) {
62
+ const ring = new RingBuffer(ringSize);
63
+ const bucket = new TokenBucket(ratePerMin);
64
+ let droppedMessages = 0;
65
+
66
+ async function trySend(msg, sendFn) {
67
+ for (let attempt = 0; attempt <= BACKOFF_DELAYS.length; attempt++) {
68
+ if (attempt > 0) {
69
+ await _sleep(BACKOFF_DELAYS[attempt - 1]);
70
+ }
71
+ try {
72
+ await sendFn(msg);
73
+ return true;
74
+ } catch (err) {
75
+ if (err?.status === 429) {
76
+ if (attempt < BACKOFF_DELAYS.length) continue;
77
+ console.warn(
78
+ '[rate_limiter] 429 exhausted after all retries — dropping message',
79
+ );
80
+ return false;
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+
88
+ const pendingQueue = [];
89
+ let workerRunning = false;
90
+
91
+ async function runWorker() {
92
+ if (workerRunning) return;
93
+ workerRunning = true;
94
+ while (pendingQueue.length > 0) {
95
+ if (!bucket.tryConsume()) {
96
+ await _sleep(Math.ceil(60000 / ratePerMin));
97
+ continue;
98
+ }
99
+ const { msg, sendFn, resolve, reject } = pendingQueue.shift();
100
+ try {
101
+ const sent = await trySend(msg, sendFn);
102
+ if (sent) {
103
+ const prevDropped = ring.dropped;
104
+ ring.push(msg);
105
+ droppedMessages += ring.dropped - prevDropped;
106
+ } else {
107
+ droppedMessages++;
108
+ }
109
+ resolve(sent);
110
+ } catch (err) {
111
+ reject(err);
112
+ }
113
+ }
114
+ workerRunning = false;
115
+ }
116
+
117
+ return {
118
+ send(msg, sendFn) {
119
+ return new Promise((resolve, reject) => {
120
+ pendingQueue.push({ msg, sendFn, resolve, reject });
121
+ runWorker();
122
+ });
123
+ },
124
+ getStats() {
125
+ return { dropped_messages: droppedMessages };
126
+ },
127
+ getRing() {
128
+ return ring.toArray();
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Tier 1 event renderers — map pipeline event envelopes to NormalizedMessage.
3
+ * Uses markdown segments so each adapter converts to its native format.
4
+ * @module renderers
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** @param {string} value @returns {import('./adapter.js').MessageSegment} */
12
+ const md = (value) => ({ kind: 'markdown', value });
13
+
14
+ function fmtMs(ms) {
15
+ if (ms == null) return null;
16
+ const totalSec = Math.floor(ms / 1000);
17
+ const m = Math.floor(totalSec / 60);
18
+ const s = totalSec % 60;
19
+ return m > 0 ? `${m}m${String(s).padStart(2, '0')}s` : `${s}s`;
20
+ }
21
+
22
+ function fmtUsd(usd) {
23
+ if (usd == null) return null;
24
+ return `$${Number(usd).toFixed(2)}`;
25
+ }
26
+
27
+ function runId(envelope) {
28
+ return envelope.run_id ?? 'run';
29
+ }
30
+
31
+ function mdMsg(text, severity) {
32
+ return { title: null, body: [md(text)], severity };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Per-event renderers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function renderRunStarted(envelope) {
40
+ const p = envelope.payload;
41
+ const title = p.title ?? p.prompt ?? '';
42
+ const label = title.length > 60 ? `${title.slice(0, 60)}\u2026` : title;
43
+ const parts = [`\u{1F7E2} **Run:** \`${runId(envelope)}\``];
44
+ if (label) parts.push(` **Title:** ${label}`);
45
+ parts.push(' **Status:** started');
46
+ return mdMsg(parts.join('\n'), 'info');
47
+ }
48
+
49
+ function renderRunCompleted(envelope) {
50
+ const p = envelope.payload;
51
+ const parts = [`\u2705 **Run:** \`${runId(envelope)}\``];
52
+ if (p.title) parts.push(` **Title:** ${p.title}`);
53
+ parts.push(' **Status:** completed');
54
+ const dur = fmtMs(p.duration_ms);
55
+ if (dur) parts.push(` **Duration:** ${dur}`);
56
+ const cost = fmtUsd(p.total_cost_usd);
57
+ if (cost) parts.push(` **Cost:** ${cost}`);
58
+ return mdMsg(parts.join('\n'), 'success');
59
+ }
60
+
61
+ function renderRunFailed(envelope) {
62
+ const p = envelope.payload;
63
+ const errLabel = p.error_type ?? p.error ?? 'error';
64
+ const stage = p.failed_stage ?? 'unknown';
65
+ const parts = [`\u{1F534} **Run:** \`${runId(envelope)}\``];
66
+ if (p.title) parts.push(` **Title:** ${p.title}`);
67
+ parts.push(` **Status:** failed at ${stage}`);
68
+ parts.push(` **Error:** ${errLabel}`);
69
+ return mdMsg(parts.join('\n'), 'error');
70
+ }
71
+
72
+ function renderRunInterrupted(envelope) {
73
+ const p = envelope.payload;
74
+ const stage = p.interrupted_stage ?? 'unknown';
75
+ const parts = [`\u{1F534} **Run:** \`${runId(envelope)}\``];
76
+ parts.push(` **Status:** interrupted at ${stage}`);
77
+ const dur = fmtMs(p.elapsed_ms);
78
+ if (dur) parts.push(` **Duration:** ${dur}`);
79
+ return mdMsg(parts.join('\n'), 'warning');
80
+ }
81
+
82
+ function renderRunPaused(envelope) {
83
+ const p = envelope.payload;
84
+ const stage = p.stage ?? '';
85
+ const parts = [`\u{1F7E1} **Run:** \`${runId(envelope)}\``];
86
+ const statusLine = stage ? `paused at ${stage}` : 'paused';
87
+ parts.push(` **Status:** ${statusLine}`);
88
+ return mdMsg(parts.join('\n'), 'warning');
89
+ }
90
+
91
+ function renderRunResumed(envelope) {
92
+ const parts = [`\u{1F7E2} **Run:** \`${runId(envelope)}\``];
93
+ parts.push(' **Status:** resumed');
94
+ return mdMsg(parts.join('\n'), 'info');
95
+ }
96
+
97
+ function renderRunResumedFromPause(envelope) {
98
+ const parts = [`\u{1F7E2} **Run:** \`${runId(envelope)}\``];
99
+ parts.push(' **Status:** resumed from pause');
100
+ return mdMsg(parts.join('\n'), 'info');
101
+ }
102
+
103
+ function renderStageStarted(envelope) {
104
+ const p = envelope.payload;
105
+ const iterPart = p.iteration ? ` (iteration ${p.iteration})` : '';
106
+ const parts = [`\u2699 **Run:** \`${runId(envelope)}\``];
107
+ parts.push(` **Stage:** ${p.stage ?? 'unknown'}${iterPart}`);
108
+ return mdMsg(parts.join('\n'), 'info');
109
+ }
110
+
111
+ function renderStageCompleted(envelope) {
112
+ const p = envelope.payload;
113
+ const parts = [`\u2705 **Run:** \`${runId(envelope)}\``];
114
+ parts.push(` **Stage:** ${p.stage ?? 'unknown'} completed`);
115
+ const dur = fmtMs(p.duration_ms);
116
+ if (dur) parts.push(` **Duration:** ${dur}`);
117
+ return mdMsg(parts.join('\n'), 'success');
118
+ }
119
+
120
+ function renderStageInterrupted(envelope) {
121
+ const p = envelope.payload;
122
+ const parts = [`\u23F8 **Run:** \`${runId(envelope)}\``];
123
+ parts.push(` **Stage:** ${p.stage ?? 'unknown'} interrupted`);
124
+ return mdMsg(parts.join('\n'), 'warning');
125
+ }
126
+
127
+ function renderGitPrCreated(envelope) {
128
+ const p = envelope.payload;
129
+ const parts = [`\u{1F500} **Run:** \`${runId(envelope)}\``];
130
+ parts.push(` **PR:** [#${p.pr_number}](${p.pr_url}) \u2014 ${p.title}`);
131
+ return mdMsg(parts.join('\n'), 'info');
132
+ }
133
+
134
+ function renderGitPrMerged(envelope) {
135
+ const p = envelope.payload;
136
+ const parts = [`\u2705 **PR merged:** [#${p.pr_number}](${p.pr_url})`];
137
+ return mdMsg(parts.join('\n'), 'success');
138
+ }
139
+
140
+ function renderCbTripped(envelope) {
141
+ const p = envelope.payload;
142
+ const parts = [`\u26A0 **Run:** \`${runId(envelope)}\``];
143
+ parts.push(
144
+ ` **Circuit breaker:** ${p.consecutive_failures}\u00D7 ${p.category} \u2014 run halted`,
145
+ );
146
+ return mdMsg(parts.join('\n'), 'error');
147
+ }
148
+
149
+ function renderCostBudgetWarning(envelope) {
150
+ const p = envelope.payload;
151
+ const pct = Math.round(p.pct_used * 100);
152
+ const parts = [`\u{1F4B8} **Run:** \`${runId(envelope)}\``];
153
+ parts.push(` **Budget:** ${pct}% of ${fmtUsd(p.budget_usd)} used`);
154
+ return mdMsg(parts.join('\n'), 'warning');
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Registry
159
+ // ---------------------------------------------------------------------------
160
+
161
+ const EVENT_RENDERERS = {
162
+ 'pipeline.run.started': renderRunStarted,
163
+ 'pipeline.run.completed': renderRunCompleted,
164
+ 'pipeline.run.failed': renderRunFailed,
165
+ 'pipeline.run.interrupted': renderRunInterrupted,
166
+ 'pipeline.run.paused': renderRunPaused,
167
+ 'pipeline.run.resumed': renderRunResumed,
168
+ 'pipeline.run.resumed_from_pause': renderRunResumedFromPause,
169
+ 'pipeline.stage.started': renderStageStarted,
170
+ 'pipeline.stage.completed': renderStageCompleted,
171
+ 'pipeline.stage.interrupted': renderStageInterrupted,
172
+ 'pipeline.git.pr_created': renderGitPrCreated,
173
+ 'pipeline.git.pr_merged': renderGitPrMerged,
174
+ 'pipeline.circuit_breaker.tripped': renderCbTripped,
175
+ 'pipeline.cost.budget_warning': renderCostBudgetWarning,
176
+ };
177
+
178
+ export const TIER1_EVENTS = Object.keys(EVENT_RENDERERS);
179
+
180
+ /**
181
+ * Map a pipeline event envelope to a NormalizedMessage.
182
+ * Returns null for unrecognised event types.
183
+ *
184
+ * @param {object|null|undefined} envelope - event envelope (event_type, run_id, payload)
185
+ * @returns {import('./adapter.js').NormalizedMessage|null}
186
+ */
187
+ export function renderEvent(envelope) {
188
+ const renderer = EVENT_RENDERERS[envelope?.event_type];
189
+ if (!renderer) return null;
190
+ return renderer(envelope);
191
+ }
@@ -0,0 +1,17 @@
1
+ export function createRestClient({ host, port }) {
2
+ const base = `http://${host}:${port}`;
3
+ return {
4
+ async get(path) {
5
+ const r = await fetch(`${base}${path}`);
6
+ return { status: r.status, data: r.ok ? await r.json() : null };
7
+ },
8
+ async post(path, body) {
9
+ const r = await fetch(`${base}${path}`, {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/json' },
12
+ body: JSON.stringify(body ?? {}),
13
+ });
14
+ return { status: r.status, data: r.ok ? await r.json() : null };
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,23 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * @param {string|Buffer} rawBody
5
+ * @param {string|null|undefined} sigHeader — value of X-Worca-Signature header
6
+ * @param {string[]} secrets — any-match set
7
+ * @returns {boolean}
8
+ */
9
+ export function verify(rawBody, sigHeader, secrets) {
10
+ if (!sigHeader?.startsWith('sha256=')) return false;
11
+ const received = Buffer.from(sigHeader.slice(7));
12
+ for (const secret of secrets) {
13
+ const expected = Buffer.from(
14
+ createHmac('sha256', secret).update(rawBody).digest('hex'),
15
+ );
16
+ if (
17
+ expected.length === received.length &&
18
+ timingSafeEqual(expected, received)
19
+ )
20
+ return true;
21
+ }
22
+ return false;
23
+ }
@@ -4,24 +4,41 @@
4
4
  */
5
5
 
6
6
  import { spawn } from 'node:child_process';
7
- import { randomBytes } from 'node:crypto';
7
+ import { randomBytes, randomUUID } from 'node:crypto';
8
8
  import {
9
+ appendFileSync,
9
10
  closeSync,
10
11
  existsSync,
11
12
  mkdirSync,
12
13
  openSync,
13
14
  readdirSync,
14
15
  readFileSync,
16
+ rmSync,
15
17
  unlinkSync,
16
18
  writeFileSync,
17
19
  writeSync,
18
20
  } from 'node:fs';
19
21
  import { tmpdir } from 'node:os';
20
- import { join } from 'node:path';
22
+ import { join, resolve } from 'node:path';
23
+
24
+ import { dispatchExternal } from './dispatch-external.js';
21
25
 
22
26
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
23
27
  const ARG_INLINE_LIMIT = 128 * 1024;
24
28
 
29
+ const TERMINAL_EVENTS = [
30
+ 'pipeline.run.interrupted',
31
+ 'pipeline.run.failed',
32
+ 'pipeline.run.completed',
33
+ ];
34
+
35
+ function elapsedMsSince(startedAtIso) {
36
+ if (!startedAtIso) return 0;
37
+ const started = Date.parse(startedAtIso);
38
+ if (Number.isNaN(started)) return 0;
39
+ return Math.max(0, Date.now() - started);
40
+ }
41
+
25
42
  /**
26
43
  * Write content to a temp file with restricted permissions (0o600) and return its path.
27
44
  * Used to avoid E2BIG when passing large prompts as CLI arguments.
@@ -59,11 +76,12 @@ function cleanupPromptFile(filePath) {
59
76
  */
60
77
  export class ProcessManager {
61
78
  /**
62
- * @param {{ worcaDir: string, projectRoot?: string }} options
79
+ * @param {{ worcaDir: string, projectRoot?: string, settingsPath?: string }} options
63
80
  */
64
- constructor({ worcaDir, projectRoot }) {
81
+ constructor({ worcaDir, projectRoot, settingsPath }) {
65
82
  this.worcaDir = worcaDir;
66
83
  this.projectRoot = projectRoot || process.cwd();
84
+ this.settingsPath = settingsPath ?? null;
67
85
  }
68
86
 
69
87
  /**
@@ -114,8 +132,9 @@ export class ProcessManager {
114
132
  *
115
133
  * @returns {boolean} true if any status was fixed
116
134
  */
117
- reconcileStatus() {
135
+ async reconcileStatus() {
118
136
  let fixed = false;
137
+ const dispatches = [];
119
138
 
120
139
  // Collect run IDs to check: scan runs/*/pipeline.pid + active_run fallback
121
140
  const runIds = new Set();
@@ -163,10 +182,11 @@ export class ProcessManager {
163
182
 
164
183
  if (status.pipeline_status !== 'running') continue;
165
184
 
166
- status.pipeline_status = 'failed';
167
185
  if (!status.stop_reason) {
168
186
  status.stop_reason = 'stale';
169
187
  }
188
+ status.pipeline_status =
189
+ status.stop_reason === 'signal' ? 'interrupted' : 'failed';
170
190
  try {
171
191
  writeFileSync(
172
192
  statusPath,
@@ -177,8 +197,71 @@ export class ProcessManager {
177
197
  } catch {
178
198
  /* ignore */
179
199
  }
200
+
201
+ // Append synthetic terminal event if none exists yet.
202
+ // Use pipeline.run.interrupted for signal-killed runs, pipeline.run.failed otherwise.
203
+ const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
204
+ let hasTerminalEvent = false;
205
+ if (existsSync(eventsPath)) {
206
+ try {
207
+ const lines = readFileSync(eventsPath, 'utf8')
208
+ .split('\n')
209
+ .filter(Boolean);
210
+ hasTerminalEvent = lines.some((line) => {
211
+ try {
212
+ const evt = JSON.parse(line);
213
+ return TERMINAL_EVENTS.includes(evt.event_type);
214
+ } catch {
215
+ return false;
216
+ }
217
+ });
218
+ } catch {
219
+ /* ignore */
220
+ }
221
+ }
222
+ if (!hasTerminalEvent) {
223
+ const eventType =
224
+ status.stop_reason === 'signal'
225
+ ? 'pipeline.run.interrupted'
226
+ : 'pipeline.run.failed';
227
+ const payload = {
228
+ failed_stage: status.current_stage ?? 'unknown',
229
+ elapsed_ms: elapsedMsSince(status.started_at),
230
+ source: 'stale',
231
+ };
232
+
233
+ if (this.settingsPath) {
234
+ dispatches.push(
235
+ dispatchExternal({
236
+ runDir: join(this.worcaDir, 'runs', runId),
237
+ settingsPath: this.settingsPath,
238
+ eventType,
239
+ payload,
240
+ }).catch(() => {}),
241
+ );
242
+ } else {
243
+ try {
244
+ const evt = {
245
+ schema_version: '1',
246
+ event_id: randomUUID(),
247
+ event_type: eventType,
248
+ timestamp: new Date().toISOString(),
249
+ run_id: status.run_id ?? runId,
250
+ pipeline: {
251
+ branch: status.branch ?? null,
252
+ work_request: status.work_request ?? null,
253
+ },
254
+ payload: { ...payload, source: 'reconcile' },
255
+ };
256
+ appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
257
+ } catch {
258
+ /* ignore */
259
+ }
260
+ }
261
+ }
180
262
  }
181
263
 
264
+ await Promise.all(dispatches);
182
265
  return fixed;
183
266
  }
184
267
 
@@ -384,17 +467,18 @@ export class ProcessManager {
384
467
  throw err;
385
468
  }
386
469
 
387
- // Watchdog: SIGKILL after 10s if still alive, then reconcile status
470
+ // Watchdog: SIGKILL after 10s if still alive, then reconcile status.
471
+ // Fire-and-forget: reconcileStatus is async but we intentionally don't
472
+ // await it — this is a background cleanup path after the response is sent.
388
473
  const worcaDir = this.worcaDir;
474
+ const { settingsPath } = this;
389
475
  const watchdog = setTimeout(() => {
390
476
  try {
391
477
  process.kill(pid, 0); // check alive
392
478
  process.kill(pid, 'SIGKILL');
393
- // Give the OS a moment to reap the process, then fix stale status
394
- setTimeout(() => reconcileStatus(worcaDir), 500);
479
+ setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
395
480
  } catch {
396
- // Already dead — reconcile in case signal handler didn't save
397
- reconcileStatus(worcaDir);
481
+ reconcileStatus(worcaDir, settingsPath);
398
482
  }
399
483
  }, 10000);
400
484
  watchdog.unref();
@@ -411,6 +495,80 @@ export class ProcessManager {
411
495
  return { pid, stopped: true };
412
496
  }
413
497
 
498
+ /**
499
+ * Synchronous-style stop: control.json + signal + poll for exit.
500
+ * @param {string} runId
501
+ * @param {{ timeoutMs?: number }} [opts]
502
+ * @returns {Promise<{ pid: number, exitCode: null, forced?: boolean }>}
503
+ */
504
+ async stopPipelineSync(runId, { timeoutMs } = {}) {
505
+ if (timeoutMs === undefined) {
506
+ timeoutMs = process.platform === 'win32' ? 30000 : 5000;
507
+ }
508
+
509
+ const running = this.getRunningPid(runId);
510
+ if (!running) {
511
+ const e = new Error('not running');
512
+ e.code = 'not_running';
513
+ throw e;
514
+ }
515
+ const { pid } = running;
516
+
517
+ const controlDir = join(this.worcaDir, 'runs', runId);
518
+ mkdirSync(controlDir, { recursive: true });
519
+ writeFileSync(
520
+ join(controlDir, 'control.json'),
521
+ `${JSON.stringify({ action: 'stop', requested_at: new Date().toISOString(), source: 'ui' }, null, 2)}\n`,
522
+ 'utf8',
523
+ );
524
+
525
+ if (process.platform !== 'win32') {
526
+ try {
527
+ process.kill(pid, 'SIGTERM');
528
+ } catch {
529
+ /* already dead */
530
+ }
531
+ } else {
532
+ this._killAgentSubprocess(runId);
533
+ }
534
+
535
+ const pollMs = timeoutMs > 10000 ? 500 : 100;
536
+ const deadline = Date.now() + timeoutMs;
537
+ while (Date.now() < deadline) {
538
+ try {
539
+ process.kill(pid, 0);
540
+ } catch {
541
+ return { pid, exitCode: null };
542
+ }
543
+ await new Promise((r) => setTimeout(r, pollMs));
544
+ }
545
+
546
+ try {
547
+ process.kill(pid, 'SIGKILL');
548
+ } catch {
549
+ /* already dead */
550
+ }
551
+ return { pid, exitCode: null, forced: true };
552
+ }
553
+
554
+ /**
555
+ * Kill the agent subprocess (claude CLI) via agent.pid.
556
+ * Used on Windows where SIGTERM doesn't propagate to child processes.
557
+ * @param {string} runId
558
+ */
559
+ _killAgentSubprocess(runId) {
560
+ const pidPath = join(this.worcaDir, 'runs', runId, 'agent.pid');
561
+ if (!existsSync(pidPath)) return;
562
+ try {
563
+ const agentPid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
564
+ if (!Number.isNaN(agentPid) && agentPid > 0) {
565
+ process.kill(agentPid, 'SIGTERM');
566
+ }
567
+ } catch {
568
+ /* agent already dead or pid file invalid */
569
+ }
570
+ }
571
+
414
572
  /**
415
573
  * Read the active_run file to get the current run ID.
416
574
  * @returns {string|null}
@@ -426,6 +584,51 @@ export class ProcessManager {
426
584
  }
427
585
  }
428
586
 
587
+ /**
588
+ * Delete a run directory and clean up references.
589
+ * Refuses if the pipeline is currently running.
590
+ * @param {string} runId
591
+ * @returns {{ deleted: boolean }}
592
+ */
593
+ deleteRun(runId) {
594
+ const running = this.getRunningPid(runId);
595
+ if (running) {
596
+ const err = new Error(
597
+ 'Cannot delete a running pipeline — stop or cancel it first',
598
+ );
599
+ err.code = 'still_running';
600
+ throw err;
601
+ }
602
+
603
+ const runsParent = resolve(this.worcaDir, 'runs');
604
+ const runDir = resolve(runsParent, runId);
605
+ if (!runDir.startsWith(runsParent)) {
606
+ const err = new Error('Invalid runId');
607
+ err.code = 'invalid_id';
608
+ throw err;
609
+ }
610
+ if (!existsSync(runDir)) {
611
+ const err = new Error(`Run "${runId}" not found`);
612
+ err.code = 'not_found';
613
+ throw err;
614
+ }
615
+
616
+ rmSync(runDir, { recursive: true, force: true });
617
+
618
+ // Clear active_run pointer if it references this run
619
+ const activeRunPath = join(this.worcaDir, 'active_run');
620
+ if (existsSync(activeRunPath)) {
621
+ try {
622
+ const activeId = readFileSync(activeRunPath, 'utf8').trim();
623
+ if (activeId === runId) unlinkSync(activeRunPath);
624
+ } catch {
625
+ /* ignore */
626
+ }
627
+ }
628
+
629
+ return { deleted: true };
630
+ }
631
+
429
632
  /**
430
633
  * Pause a running pipeline by writing a control file.
431
634
  * @param {string} runId - Pipeline run identifier
@@ -581,9 +784,9 @@ export function getRunningPid(worcaDir, runId) {
581
784
  return new ProcessManager({ worcaDir }).getRunningPid(runId);
582
785
  }
583
786
 
584
- /** @param {string} worcaDir */
585
- export function reconcileStatus(worcaDir) {
586
- return new ProcessManager({ worcaDir }).reconcileStatus();
787
+ /** @param {string} worcaDir @param {string} [settingsPath] */
788
+ export function reconcileStatus(worcaDir, settingsPath) {
789
+ return new ProcessManager({ worcaDir, settingsPath }).reconcileStatus();
587
790
  }
588
791
 
589
792
  /** @param {string} worcaDir @param {object} opts */