@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,394 @@
1
+ import { statusEmoji } from './global.js';
2
+
3
+ const NO_ACTIVE_PROJECT =
4
+ 'No active project. Use `/projects` to list, `/use <name>` to select.';
5
+
6
+ /**
7
+ * Match a run ID that may be a wildcard suffix (e.g. "*2db5", "*658-1111").
8
+ * Returns { runId } for exact or unique suffix match, { disambig } for
9
+ * multiple matches, or null for no match (falls through to active-run logic).
10
+ */
11
+ function matchRunIdPattern(pattern, runs, command) {
12
+ if (!pattern) return null;
13
+
14
+ // Strip leading * if present
15
+ const isWildcard = pattern.startsWith('*');
16
+ const suffix = isWildcard ? pattern.slice(1) : null;
17
+
18
+ if (!isWildcard) {
19
+ // Exact match — return as-is (may not exist, caller handles)
20
+ return { runId: pattern };
21
+ }
22
+
23
+ if (!suffix) return null;
24
+
25
+ const matches = runs.filter((r) => {
26
+ const id = r.id ?? r.run_id ?? '';
27
+ return id.endsWith(suffix);
28
+ });
29
+
30
+ if (matches.length === 1) {
31
+ return { runId: matches[0].id ?? matches[0].run_id };
32
+ }
33
+
34
+ if (matches.length > 1) {
35
+ const lines = matches.map((r) => {
36
+ const id = r.id ?? r.run_id;
37
+ const ps = r.pipeline_status || (r.active ? 'running' : 'unknown');
38
+ const title = r.work_request?.title;
39
+ const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
40
+ if (title) parts.push(` **Title:** ${title}`);
41
+ return parts.join('\n');
42
+ });
43
+ const cmd = command || 'status';
44
+ return {
45
+ disambig: `Multiple runs match \`*${suffix}\`:\n\n${lines.join('\n')}\n\nUsage: /${cmd} <run_id>`,
46
+ };
47
+ }
48
+
49
+ // No match — return the pattern as-is so caller shows "not found"
50
+ return { runId: pattern };
51
+ }
52
+
53
+ /**
54
+ * Resolve run_id when the caller omits it:
55
+ * - exactly one active run -> return its id
56
+ * - zero active runs -> return null (caller handles)
57
+ * - multiple active runs -> return disambiguation message string
58
+ *
59
+ * Supports wildcard suffix: *2db5 matches any run ending in "2db5".
60
+ */
61
+ async function resolveRunId(restClient, projectId, args, command) {
62
+ const resp = await restClient.get(
63
+ `/api/projects/${encodeURIComponent(projectId)}/runs`,
64
+ );
65
+ const runs = resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
66
+
67
+ // If an arg is provided, try wildcard/exact match
68
+ if (args[0]) {
69
+ const matched = matchRunIdPattern(args[0], runs, command);
70
+ if (matched) return matched;
71
+ }
72
+ const active = runs.filter((r) => {
73
+ const ps = r.pipeline_status || (r.active ? 'running' : null);
74
+ return ps === 'running' || ps === 'paused' || ps === 'resuming';
75
+ });
76
+ if (active.length === 1) return { runId: active[0].id ?? active[0].run_id };
77
+ if (active.length > 1) {
78
+ const lines = active.map((r) => {
79
+ const id = r.id ?? r.run_id;
80
+ const ps = r.pipeline_status || (r.active ? 'running' : 'unknown');
81
+ const title = r.work_request?.title;
82
+ const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
83
+ if (title) parts.push(` **Title:** ${title}`);
84
+ return parts.join('\n');
85
+ });
86
+ const cmd = command || 'status';
87
+ return {
88
+ disambig: `Multiple active runs \u2014 specify a run ID:\n\n${lines.join('\n')}\n\nUsage: /${cmd} <run_id>`,
89
+ };
90
+ }
91
+ return { runId: null };
92
+ }
93
+
94
+ const TERMINAL_STATUSES = new Set([
95
+ 'completed',
96
+ 'failed',
97
+ 'interrupted',
98
+ 'stopped',
99
+ 'cancelled',
100
+ ]);
101
+
102
+ function fmtMs(ms) {
103
+ const totalSec = Math.floor(ms / 1000);
104
+ const m = Math.floor(totalSec / 60);
105
+ const s = totalSec % 60;
106
+ return m > 0 ? `${m}m${String(s).padStart(2, '0')}s` : `${s}s`;
107
+ }
108
+
109
+ /**
110
+ * Compute run duration. Uses run-level started_at → completed_at when available.
111
+ * For terminal runs missing completed_at, falls back to stage iteration timestamps
112
+ * (first started_at → last completed_at). For running pipelines, shows live elapsed.
113
+ */
114
+ function fmtElapsedFromRun(run) {
115
+ const startedAt = run.started_at;
116
+ if (!startedAt) return null;
117
+
118
+ const completedAt = run.completed_at;
119
+ if (completedAt) {
120
+ const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
121
+ return ms >= 0 ? fmtMs(ms) : null;
122
+ }
123
+
124
+ const ps = run.pipeline_status || (run.active ? 'running' : 'unknown');
125
+ if (TERMINAL_STATUSES.has(ps)) {
126
+ // Derive from stage iterations: find the latest completed_at across all iterations
127
+ let lastEnd = null;
128
+ for (const stage of Object.values(run.stages || {})) {
129
+ for (const iter of stage.iterations || []) {
130
+ if (iter.completed_at) {
131
+ const t = new Date(iter.completed_at).getTime();
132
+ if (!lastEnd || t > lastEnd) lastEnd = t;
133
+ }
134
+ }
135
+ }
136
+ if (lastEnd) {
137
+ const ms = lastEnd - new Date(startedAt).getTime();
138
+ return ms >= 0 ? fmtMs(ms) : null;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ // Running — show live elapsed
144
+ const ms = Date.now() - new Date(startedAt).getTime();
145
+ return ms >= 0 ? fmtMs(ms) : null;
146
+ }
147
+
148
+ function fmtCostFromStages(stages) {
149
+ let totalCost = 0;
150
+ for (const stage of Object.values(stages || {})) {
151
+ for (const iter of stage.iterations || []) {
152
+ totalCost += iter.cost_usd || 0;
153
+ }
154
+ }
155
+ return totalCost > 0 ? `$${totalCost.toFixed(2)}` : null;
156
+ }
157
+
158
+ function rawCostFromStages(stages) {
159
+ let totalCost = 0;
160
+ for (const stage of Object.values(stages || {})) {
161
+ for (const iter of stage.iterations || []) {
162
+ totalCost += iter.cost_usd || 0;
163
+ }
164
+ }
165
+ return totalCost;
166
+ }
167
+
168
+ function fmtStatusBlock(run) {
169
+ const id = run.id ?? run.run_id;
170
+ const ps = run.pipeline_status || (run.active ? 'running' : 'unknown');
171
+ const title = run.work_request?.title;
172
+ const elapsed = fmtElapsedFromRun(run);
173
+ const cost = fmtCostFromStages(run.stages);
174
+ const stage = run.stage;
175
+ const iteration = run.iteration ?? run.stages?.[stage]?.iterations?.length;
176
+
177
+ const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
178
+ if (title) parts.push(` **Title:** ${title}`);
179
+ parts.push(` **Status:** ${ps}`);
180
+ if (stage) {
181
+ const iterPart = iteration ? ` (iteration ${iteration})` : '';
182
+ parts.push(` **Stage:** ${stage}${iterPart}`);
183
+ }
184
+ if (elapsed) parts.push(` **Duration:** ${elapsed}`);
185
+ if (cost) parts.push(` **Cost:** ${cost}`);
186
+ if (ps === 'completed' && run.pr_url)
187
+ parts.push(` **PR:** [${run.pr_url}](${run.pr_url})`);
188
+ return parts.join('\n');
189
+ }
190
+
191
+ /**
192
+ * Creates handlers for project-scoped commands.
193
+ *
194
+ * @param {{ chatContext, restClient }} deps
195
+ * @returns {Record<string, (chatKey: string, args: string[]) => Promise<string>>}
196
+ */
197
+ export function createProjectHandlers({ chatContext, restClient }) {
198
+ function requireProject(chatKey) {
199
+ const { active_project } = chatContext.get(chatKey);
200
+ return active_project ?? null;
201
+ }
202
+
203
+ async function status(chatKey, args) {
204
+ const project = requireProject(chatKey);
205
+ if (!project) return NO_ACTIVE_PROJECT;
206
+
207
+ const resolved = await resolveRunId(restClient, project, args, 'status');
208
+ if (resolved.disambig) return resolved.disambig;
209
+ const runId = resolved.runId;
210
+ if (!runId)
211
+ return 'No active run found.\nUse /runs to see recent runs, or specify a run ID: /status <run_id>';
212
+
213
+ // Fetch full run data for title, cost, duration
214
+ const runsResp = await restClient.get(
215
+ `/api/projects/${encodeURIComponent(project)}/runs`,
216
+ );
217
+ const allRuns =
218
+ runsResp.data?.runs ??
219
+ (Array.isArray(runsResp.data) ? runsResp.data : []);
220
+ const run = allRuns.find((r) => (r.id ?? r.run_id) === runId);
221
+
222
+ if (!run) {
223
+ return `${statusEmoji('unknown')} Run: ${runId}\n Status: unknown`;
224
+ }
225
+
226
+ return fmtStatusBlock(run);
227
+ }
228
+
229
+ async function runs(chatKey, args) {
230
+ const project = requireProject(chatKey);
231
+ if (!project) return NO_ACTIVE_PROJECT;
232
+
233
+ const limit = args[0]
234
+ ? Math.max(1, Number.parseInt(args[0], 10) || 10)
235
+ : 10;
236
+ const resp = await restClient.get(
237
+ `/api/projects/${encodeURIComponent(project)}/runs`,
238
+ );
239
+ const all = resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
240
+ const slice = all.slice(0, limit);
241
+ if (slice.length === 0) return `No runs found for ${project}.`;
242
+
243
+ const lines = slice.map((r) => {
244
+ const id = r.id ?? r.run_id;
245
+ const ps = r.pipeline_status || (r.active ? 'running' : 'unknown');
246
+ const title = r.work_request?.title;
247
+ const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
248
+ if (title) parts.push(` **Title:** ${title}`);
249
+ parts.push(` **Status:** ${ps}`);
250
+ return parts.join('\n');
251
+ });
252
+ return `Recent runs (${project}):\n\n${lines.join('\n')}`;
253
+ }
254
+
255
+ async function last(chatKey, _args) {
256
+ const project = requireProject(chatKey);
257
+ if (!project) return NO_ACTIVE_PROJECT;
258
+
259
+ const resp = await restClient.get(
260
+ `/api/projects/${encodeURIComponent(project)}/runs`,
261
+ );
262
+ const all = resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
263
+ if (all.length === 0) return `No runs found for ${project}.`;
264
+
265
+ return fmtStatusBlock(all[0]);
266
+ }
267
+
268
+ async function cost(chatKey, args) {
269
+ const project = requireProject(chatKey);
270
+ if (!project) return NO_ACTIVE_PROJECT;
271
+
272
+ // Use runs data for cost (has stages with iterations)
273
+ const resp = await restClient.get(
274
+ `/api/projects/${encodeURIComponent(project)}/runs`,
275
+ );
276
+ const all = resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
277
+ const filter = args[0] ?? null;
278
+ let filtered;
279
+ if (filter?.startsWith('*')) {
280
+ const suffix = filter.slice(1);
281
+ filtered = all.filter((r) => (r.id ?? r.run_id ?? '').endsWith(suffix));
282
+ } else if (filter) {
283
+ filtered = all.filter((r) => (r.id ?? r.run_id) === filter);
284
+ } else {
285
+ filtered = all.slice(0, 5);
286
+ }
287
+ if (filtered.length === 0) return `No runs found for ${project}.`;
288
+
289
+ let grandTotal = 0;
290
+ const lines = filtered.map((r) => {
291
+ const id = r.id ?? r.run_id;
292
+ const ps = r.pipeline_status || (r.active ? 'running' : 'unknown');
293
+ const title = r.work_request?.title;
294
+ const costVal = rawCostFromStages(r.stages);
295
+ grandTotal += costVal;
296
+ const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
297
+ if (title) parts.push(` **Title:** ${title}`);
298
+ parts.push(` **Cost:** $${costVal.toFixed(2)}`);
299
+ return parts.join('\n');
300
+ });
301
+
302
+ const header = `Cost summary (${project}):\n\n`;
303
+ if (filtered.length > 1) {
304
+ lines.push(`\nTotal: $${grandTotal.toFixed(2)}`);
305
+ }
306
+ return header + lines.join('\n');
307
+ }
308
+
309
+ async function pr(chatKey, args) {
310
+ const project = requireProject(chatKey);
311
+ if (!project) return NO_ACTIVE_PROJECT;
312
+
313
+ const resolved = await resolveRunId(restClient, project, args, 'pr');
314
+ if (resolved.disambig) return resolved.disambig;
315
+ const runId = resolved.runId;
316
+ if (!runId) return 'No active run found.';
317
+
318
+ const resp = await restClient.get(
319
+ `/api/projects/${encodeURIComponent(project)}/runs/${encodeURIComponent(runId)}/status`,
320
+ );
321
+ if (!resp.data?.ok) return `Run "${runId}" not found (404).`;
322
+ const { pr_url } = resp.data;
323
+ if (!pr_url) return `**Run:** \`${runId}\`\nNo PR created yet.`;
324
+ return `\u{1F517} **Run:** \`${runId}\`\n **PR:** [${pr_url}](${pr_url})`;
325
+ }
326
+
327
+ async function error(chatKey, args) {
328
+ const project = requireProject(chatKey);
329
+ if (!project) return NO_ACTIVE_PROJECT;
330
+
331
+ const resp = await restClient.get(
332
+ `/api/projects/${encodeURIComponent(project)}/runs`,
333
+ );
334
+ const all = resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
335
+
336
+ let runId = null;
337
+ if (args[0]) {
338
+ const matched = matchRunIdPattern(args[0], all, 'error');
339
+ if (matched?.disambig) return matched.disambig;
340
+ runId = matched?.runId ?? null;
341
+ } else {
342
+ // Find the most recent failed run
343
+ const failed = all.find(
344
+ (r) =>
345
+ r.pipeline_status === 'failed' || r.pipeline_status === 'interrupted',
346
+ );
347
+ runId = failed ? (failed.id ?? failed.run_id) : null;
348
+ }
349
+ if (!runId)
350
+ return 'No failed run found.\nUse /error <run_id> to check a specific run.';
351
+
352
+ const run = all.find((r) => (r.id ?? r.run_id) === runId);
353
+ if (!run) return `Run "${runId}" not found.`;
354
+
355
+ const ps = run.pipeline_status || 'unknown';
356
+ const title = run.work_request?.title;
357
+ const stopReason = run.stop_reason;
358
+
359
+ // Find the failed stage and its error
360
+ let failedStage = null;
361
+ let failedIter = null;
362
+ let errorMsg = null;
363
+ for (const [sname, sdata] of Object.entries(run.stages || {})) {
364
+ for (const iter of sdata.iterations || []) {
365
+ if (iter.error || iter.status === 'error') {
366
+ failedStage = sname;
367
+ failedIter = iter.number;
368
+ errorMsg = iter.error;
369
+ break;
370
+ }
371
+ }
372
+ if (errorMsg) break;
373
+ }
374
+
375
+ const parts = [`${statusEmoji(ps)} **Run:** \`${runId}\``];
376
+ if (title) parts.push(` **Title:** ${title}`);
377
+ if (stopReason) parts.push(` **Stop reason:** ${stopReason}`);
378
+ if (failedStage) {
379
+ const iterLabel = failedIter ? ` (iteration ${failedIter})` : '';
380
+ parts.push(` **Failed stage:** ${failedStage}${iterLabel}`);
381
+ }
382
+ if (errorMsg) {
383
+ const truncated =
384
+ errorMsg.length > 300 ? `${errorMsg.slice(0, 300)}\u2026` : errorMsg;
385
+ parts.push(` **Error:** ${truncated}`);
386
+ }
387
+ if (!stopReason && !errorMsg) {
388
+ parts.push(' No error details available.');
389
+ }
390
+ return parts.join('\n');
391
+ }
392
+
393
+ return { status, runs, last, cost, pr, error };
394
+ }
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { validateIntegrationsConfig } from '../settings-validator.js';
3
+
4
+ /**
5
+ * Loads and validates ~/.worca/integrations/config.json.
6
+ * Returns the parsed config object, or null if missing/invalid.
7
+ * Missing file → null (silent). Invalid JSON or validation failure → null + console.warn.
8
+ *
9
+ * @param {string} configPath
10
+ * @returns {object|null}
11
+ */
12
+ export function loadIntegrationsConfig(configPath) {
13
+ let raw;
14
+ try {
15
+ raw = readFileSync(configPath, 'utf8');
16
+ } catch (err) {
17
+ if (err.code === 'ENOENT') return null;
18
+ console.warn('[integrations] failed to read config', err.message);
19
+ return null;
20
+ }
21
+
22
+ let cfg;
23
+ try {
24
+ cfg = JSON.parse(raw);
25
+ } catch (err) {
26
+ console.warn('[integrations] config is not valid JSON', err.message);
27
+ return null;
28
+ }
29
+
30
+ const result = validateIntegrationsConfig(cfg);
31
+ if (!result.valid) {
32
+ console.warn(
33
+ '[integrations] config validation failed',
34
+ result.details.join('; '),
35
+ );
36
+ return null;
37
+ }
38
+
39
+ return cfg;
40
+ }