@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.
- package/app/main.bundle.js +895 -813
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +216 -9
- package/app/utils/state-actions.js +55 -0
- package/package.json +6 -4
- package/server/app.js +291 -6
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +231 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +217 -14
- package/server/project-routes.js +210 -44
- package/server/settings-validator.js +250 -0
- package/server/ws-beads-watcher.js +22 -6
- 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
|
-
|
|
394
|
-
setTimeout(() => reconcileStatus(worcaDir), 500);
|
|
479
|
+
setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
|
|
395
480
|
} catch {
|
|
396
|
-
|
|
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 */
|