@worca/ui 0.9.0-rc.1 → 0.9.0-rc.2
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/package.json +5 -4
- 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 +228 -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
|
@@ -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
|
+
}
|