@worca/ui 0.8.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.
Files changed (35) hide show
  1. package/app/main.bundle.js +1424 -755
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +399 -23
  4. package/package.json +5 -4
  5. package/server/app.js +341 -6
  6. package/server/dispatch-events-aggregator.js +161 -0
  7. package/server/ensure-webhook.js +66 -0
  8. package/server/index.js +22 -0
  9. package/server/integrations/adapter.js +91 -0
  10. package/server/integrations/adapters/discord.js +109 -0
  11. package/server/integrations/adapters/slack.js +106 -0
  12. package/server/integrations/adapters/telegram.js +228 -0
  13. package/server/integrations/adapters/webhook_out.js +253 -0
  14. package/server/integrations/allowlist.js +19 -0
  15. package/server/integrations/chat_context.js +68 -0
  16. package/server/integrations/commands/control.js +120 -0
  17. package/server/integrations/commands/global.js +239 -0
  18. package/server/integrations/commands/parser.js +29 -0
  19. package/server/integrations/commands/project.js +394 -0
  20. package/server/integrations/config-loader.js +40 -0
  21. package/server/integrations/index.js +390 -0
  22. package/server/integrations/markdown.js +220 -0
  23. package/server/integrations/rate_limiter.js +131 -0
  24. package/server/integrations/renderers.js +191 -0
  25. package/server/integrations/rest_client.js +17 -0
  26. package/server/integrations/verify.js +23 -0
  27. package/server/process-manager.js +61 -2
  28. package/server/project-registry.js +37 -0
  29. package/server/project-routes.js +175 -6
  30. package/server/settings-validator.js +279 -2
  31. package/server/subagents-discovery.js +116 -0
  32. package/server/version-check.js +35 -0
  33. package/server/watcher.js +37 -10
  34. package/server/worca-setup.js +15 -1
  35. package/server/ws-modular.js +6 -2
@@ -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,8 +4,9 @@
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,
@@ -22,6 +23,19 @@ import { join } from 'node:path';
22
23
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
23
24
  const ARG_INLINE_LIMIT = 128 * 1024;
24
25
 
26
+ const TERMINAL_EVENTS = [
27
+ 'pipeline.run.interrupted',
28
+ 'pipeline.run.failed',
29
+ 'pipeline.run.completed',
30
+ ];
31
+
32
+ function elapsedMsSince(startedAtIso) {
33
+ if (!startedAtIso) return 0;
34
+ const started = Date.parse(startedAtIso);
35
+ if (Number.isNaN(started)) return 0;
36
+ return Math.max(0, Date.now() - started);
37
+ }
38
+
25
39
  /**
26
40
  * Write content to a temp file with restricted permissions (0o600) and return its path.
27
41
  * Used to avoid E2BIG when passing large prompts as CLI arguments.
@@ -163,10 +177,11 @@ export class ProcessManager {
163
177
 
164
178
  if (status.pipeline_status !== 'running') continue;
165
179
 
166
- status.pipeline_status = 'failed';
167
180
  if (!status.stop_reason) {
168
181
  status.stop_reason = 'stale';
169
182
  }
183
+ status.pipeline_status =
184
+ status.stop_reason === 'stale' ? 'interrupted' : 'failed';
170
185
  try {
171
186
  writeFileSync(
172
187
  statusPath,
@@ -177,6 +192,50 @@ export class ProcessManager {
177
192
  } catch {
178
193
  /* ignore */
179
194
  }
195
+
196
+ // Append synthetic interrupted event if no terminal event exists yet
197
+ const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
198
+ let hasTerminalEvent = false;
199
+ if (existsSync(eventsPath)) {
200
+ try {
201
+ const lines = readFileSync(eventsPath, 'utf8')
202
+ .split('\n')
203
+ .filter(Boolean);
204
+ hasTerminalEvent = lines.some((line) => {
205
+ try {
206
+ const evt = JSON.parse(line);
207
+ return TERMINAL_EVENTS.includes(evt.event_type);
208
+ } catch {
209
+ return false;
210
+ }
211
+ });
212
+ } catch {
213
+ /* ignore */
214
+ }
215
+ }
216
+ if (!hasTerminalEvent) {
217
+ try {
218
+ const evt = {
219
+ schema_version: '1',
220
+ event_id: randomUUID(),
221
+ event_type: 'pipeline.run.interrupted',
222
+ timestamp: new Date().toISOString(),
223
+ run_id: status.run_id ?? runId,
224
+ pipeline: {
225
+ branch: status.branch ?? null,
226
+ work_request: status.work_request ?? null,
227
+ },
228
+ payload: {
229
+ interrupted_stage: status.current_stage ?? 'unknown',
230
+ elapsed_ms: elapsedMsSince(status.started_at),
231
+ source: 'reconcile',
232
+ },
233
+ };
234
+ appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ }
180
239
  }
181
240
 
182
241
  return fixed;
@@ -10,7 +10,9 @@ import {
10
10
  unlinkSync,
11
11
  writeFileSync,
12
12
  } from 'node:fs';
13
+ import { readdir } from 'node:fs/promises';
13
14
  import { basename, isAbsolute, join } from 'node:path';
15
+ import { checkWorcaInstalled, readProjectWorcaVersion } from './worca-setup.js';
14
16
 
15
17
  export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
16
18
  const DEFAULT_MAX_PROJECTS = 20;
@@ -24,6 +26,7 @@ export function slugify(name) {
24
26
  .toLowerCase()
25
27
  .replace(/[^a-z0-9_-]/g, '-')
26
28
  .replace(/-{2,}/g, '-')
29
+ .replace(/^-+|-+$/g, '')
27
30
  .slice(0, 64);
28
31
  }
29
32
 
@@ -130,6 +133,40 @@ export function synthesizeDefaultProject(projectRoot) {
130
133
  };
131
134
  }
132
135
 
136
+ const SCAN_MAX_RESULTS = 200;
137
+
138
+ /**
139
+ * Scan a directory for immediate child folders that contain a .git subdirectory.
140
+ * Skips dotfiles (names starting with ".") and "node_modules".
141
+ * Returns entries sorted alphabetically by name, capped at SCAN_MAX_RESULTS.
142
+ *
143
+ * @param {string} dirPath - Absolute path to the parent directory
144
+ * @returns {Promise<{ name: string, path: string }[]>}
145
+ */
146
+ export async function scanDirectory(dirPath) {
147
+ const entries = await readdir(dirPath, { withFileTypes: true });
148
+ const results = [];
149
+ for (const entry of entries) {
150
+ if (!entry.isDirectory()) continue;
151
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
152
+ const childPath = join(dirPath, entry.name);
153
+ if (existsSync(join(childPath, '.git'))) {
154
+ const installed = checkWorcaInstalled(childPath);
155
+ const worcaVersion = installed
156
+ ? readProjectWorcaVersion(childPath)
157
+ : null;
158
+ results.push({
159
+ name: entry.name,
160
+ path: childPath,
161
+ installed,
162
+ worcaVersion,
163
+ });
164
+ if (results.length >= SCAN_MAX_RESULTS) break;
165
+ }
166
+ }
167
+ return results.sort((a, b) => a.name.localeCompare(b.name));
168
+ }
169
+
133
170
  /**
134
171
  * Read max projects from {prefsDir}/config.json. Defaults to 20.
135
172
  */