@worca/ui 0.9.0 → 0.10.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 +900 -803
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +210 -8
- 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 +212 -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,10 @@ 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 = 'failed';
|
|
170
189
|
try {
|
|
171
190
|
writeFileSync(
|
|
172
191
|
statusPath,
|
|
@@ -177,8 +196,67 @@ export class ProcessManager {
|
|
|
177
196
|
} catch {
|
|
178
197
|
/* ignore */
|
|
179
198
|
}
|
|
199
|
+
|
|
200
|
+
// Append synthetic failed event if no terminal event exists yet.
|
|
201
|
+
// Status is "failed" (process crash), so the event type must match.
|
|
202
|
+
const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
|
|
203
|
+
let hasTerminalEvent = false;
|
|
204
|
+
if (existsSync(eventsPath)) {
|
|
205
|
+
try {
|
|
206
|
+
const lines = readFileSync(eventsPath, 'utf8')
|
|
207
|
+
.split('\n')
|
|
208
|
+
.filter(Boolean);
|
|
209
|
+
hasTerminalEvent = lines.some((line) => {
|
|
210
|
+
try {
|
|
211
|
+
const evt = JSON.parse(line);
|
|
212
|
+
return TERMINAL_EVENTS.includes(evt.event_type);
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
/* ignore */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!hasTerminalEvent) {
|
|
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
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
180
257
|
}
|
|
181
258
|
|
|
259
|
+
await Promise.all(dispatches);
|
|
182
260
|
return fixed;
|
|
183
261
|
}
|
|
184
262
|
|
|
@@ -384,17 +462,18 @@ export class ProcessManager {
|
|
|
384
462
|
throw err;
|
|
385
463
|
}
|
|
386
464
|
|
|
387
|
-
// 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.
|
|
388
468
|
const worcaDir = this.worcaDir;
|
|
469
|
+
const { settingsPath } = this;
|
|
389
470
|
const watchdog = setTimeout(() => {
|
|
390
471
|
try {
|
|
391
472
|
process.kill(pid, 0); // check alive
|
|
392
473
|
process.kill(pid, 'SIGKILL');
|
|
393
|
-
|
|
394
|
-
setTimeout(() => reconcileStatus(worcaDir), 500);
|
|
474
|
+
setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
|
|
395
475
|
} catch {
|
|
396
|
-
|
|
397
|
-
reconcileStatus(worcaDir);
|
|
476
|
+
reconcileStatus(worcaDir, settingsPath);
|
|
398
477
|
}
|
|
399
478
|
}, 10000);
|
|
400
479
|
watchdog.unref();
|
|
@@ -411,6 +490,80 @@ export class ProcessManager {
|
|
|
411
490
|
return { pid, stopped: true };
|
|
412
491
|
}
|
|
413
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
|
+
|
|
414
567
|
/**
|
|
415
568
|
* Read the active_run file to get the current run ID.
|
|
416
569
|
* @returns {string|null}
|
|
@@ -426,6 +579,51 @@ export class ProcessManager {
|
|
|
426
579
|
}
|
|
427
580
|
}
|
|
428
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
|
+
|
|
429
627
|
/**
|
|
430
628
|
* Pause a running pipeline by writing a control file.
|
|
431
629
|
* @param {string} runId - Pipeline run identifier
|
|
@@ -581,9 +779,9 @@ export function getRunningPid(worcaDir, runId) {
|
|
|
581
779
|
return new ProcessManager({ worcaDir }).getRunningPid(runId);
|
|
582
780
|
}
|
|
583
781
|
|
|
584
|
-
/** @param {string} worcaDir */
|
|
585
|
-
export function reconcileStatus(worcaDir) {
|
|
586
|
-
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();
|
|
587
785
|
}
|
|
588
786
|
|
|
589
787
|
/** @param {string} worcaDir @param {object} opts */
|