@worca/ui 0.8.1-rc.1 → 0.9.0-rc.1

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.
@@ -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;
@@ -20,6 +20,7 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { Router } from 'express';
22
22
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
23
+ import { ensureWebhookForUi } from './ensure-webhook.js';
23
24
  import { readPreferences } from './preferences.js';
24
25
  import { ProcessManager } from './process-manager.js';
25
26
  import {
@@ -138,7 +139,12 @@ export function projectResolver({ prefsDir, projectRoot }) {
138
139
  /**
139
140
  * Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
140
141
  */
141
- export function createProjectRoutes({ prefsDir, projectRoot }) {
142
+ export function createProjectRoutes({
143
+ prefsDir,
144
+ projectRoot,
145
+ serverHost,
146
+ serverPort,
147
+ }) {
142
148
  const router = Router();
143
149
 
144
150
  // GET /api/projects — list all projects (or synthesized default)
@@ -169,6 +175,17 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
169
175
  }
170
176
  try {
171
177
  writeProject(prefsDir, entry);
178
+ // Auto-configure webhook so pipeline events reach this UI server
179
+ if (serverHost && serverPort) {
180
+ try {
181
+ ensureWebhookForUi(entry.path, {
182
+ host: serverHost,
183
+ port: serverPort,
184
+ });
185
+ } catch {
186
+ /* best-effort — don't fail project creation */
187
+ }
188
+ }
172
189
  res.status(201).json({ ok: true, project: entry });
173
190
  } catch (err) {
174
191
  res.status(400).json({ ok: false, error: err.message });
@@ -267,6 +284,16 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
267
284
  for (const entry of batch) {
268
285
  writeProject(prefsDir, entry);
269
286
  written.push(entry.name);
287
+ if (serverHost && serverPort) {
288
+ try {
289
+ ensureWebhookForUi(entry.path, {
290
+ host: serverHost,
291
+ port: serverPort,
292
+ });
293
+ } catch {
294
+ /* best-effort */
295
+ }
296
+ }
270
297
  }
271
298
  res.status(201).json({ ok: true, projects: batch });
272
299
  } catch (err) {
@@ -290,7 +317,11 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
290
317
  * @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
291
318
  * worca-cc version lookup for /worca-status' `outdated` flag.
292
319
  */
293
- export function createProjectScopedRoutes({ prefsDir = null } = {}) {
320
+ export function createProjectScopedRoutes({
321
+ prefsDir = null,
322
+ serverHost,
323
+ serverPort,
324
+ } = {}) {
294
325
  const router = Router({ mergeParams: true });
295
326
  const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
296
327
 
@@ -1365,6 +1396,17 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
1365
1396
 
1366
1397
  try {
1367
1398
  const { pid } = runWorcaSetup(projectRoot, { source });
1399
+ // Auto-configure webhook so pipeline events reach this UI server
1400
+ if (serverHost && serverPort) {
1401
+ try {
1402
+ ensureWebhookForUi(projectRoot, {
1403
+ host: serverHost,
1404
+ port: serverPort,
1405
+ });
1406
+ } catch {
1407
+ /* best-effort */
1408
+ }
1409
+ }
1368
1410
  res.json({ ok: true, pid });
1369
1411
  } catch (err) {
1370
1412
  res.status(500).json({ ok: false, error: err.message });
@@ -532,3 +532,253 @@ export function validateSettingsPayload(body) {
532
532
 
533
533
  return details.length ? { valid: false, details } : { valid: true };
534
534
  }
535
+
536
+ const VALID_WEBHOOK_OUT_FORMATS = [
537
+ 'generic-json',
538
+ 'slack-compatible',
539
+ 'discord-compatible',
540
+ 'teams-card',
541
+ 'ntfy',
542
+ 'plain-text',
543
+ ];
544
+
545
+ export function validateIntegrationsConfig(cfg) {
546
+ const details = [];
547
+
548
+ if (cfg.schema_version === undefined || cfg.schema_version !== 1) {
549
+ details.push('schema_version must be present and equal to 1');
550
+ }
551
+
552
+ if (cfg.enabled !== undefined && typeof cfg.enabled !== 'boolean') {
553
+ details.push('enabled must be a boolean');
554
+ }
555
+
556
+ if (cfg.webhook_secret_env !== undefined) {
557
+ if (
558
+ typeof cfg.webhook_secret_env !== 'string' ||
559
+ cfg.webhook_secret_env.length === 0
560
+ ) {
561
+ details.push('webhook_secret_env must be a non-empty string');
562
+ }
563
+ }
564
+
565
+ if (cfg.webhook_secrets_env !== undefined) {
566
+ if (
567
+ typeof cfg.webhook_secrets_env !== 'string' ||
568
+ cfg.webhook_secrets_env.length === 0
569
+ ) {
570
+ details.push('webhook_secrets_env must be a non-empty string');
571
+ }
572
+ }
573
+
574
+ if (
575
+ cfg.strict_inbox_verification !== undefined &&
576
+ typeof cfg.strict_inbox_verification !== 'boolean'
577
+ ) {
578
+ details.push('strict_inbox_verification must be a boolean');
579
+ }
580
+
581
+ // telegram
582
+ if (cfg.telegram !== undefined) {
583
+ if (
584
+ typeof cfg.telegram !== 'object' ||
585
+ cfg.telegram === null ||
586
+ Array.isArray(cfg.telegram)
587
+ ) {
588
+ details.push('telegram must be an object');
589
+ } else {
590
+ const tg = cfg.telegram;
591
+ if (tg.enabled !== undefined && typeof tg.enabled !== 'boolean') {
592
+ details.push('telegram.enabled must be a boolean');
593
+ }
594
+ const hasTgToken =
595
+ (typeof tg.bot_token === 'string' && tg.bot_token.length > 0) ||
596
+ (typeof tg.bot_token_env === 'string' && tg.bot_token_env.length > 0);
597
+ if (!hasTgToken) {
598
+ details.push(
599
+ 'telegram requires bot_token or bot_token_env (non-empty string)',
600
+ );
601
+ }
602
+ if (
603
+ tg.chat_id === undefined ||
604
+ (typeof tg.chat_id !== 'string' && typeof tg.chat_id !== 'number')
605
+ ) {
606
+ details.push('telegram.chat_id must be a string or number');
607
+ }
608
+ if (tg.events === undefined || !Array.isArray(tg.events)) {
609
+ details.push('telegram.events must be an array');
610
+ } else {
611
+ for (let i = 0; i < tg.events.length; i++) {
612
+ if (typeof tg.events[i] !== 'string' || tg.events[i].length === 0) {
613
+ details.push(`telegram.events[${i}] must be a non-empty string`);
614
+ }
615
+ }
616
+ }
617
+ if (tg.rate_limit_per_min !== undefined) {
618
+ if (
619
+ !Number.isInteger(tg.rate_limit_per_min) ||
620
+ tg.rate_limit_per_min < 1
621
+ ) {
622
+ details.push(
623
+ 'telegram.rate_limit_per_min must be a positive integer',
624
+ );
625
+ }
626
+ }
627
+ }
628
+ }
629
+
630
+ // discord
631
+ if (cfg.discord !== undefined) {
632
+ if (
633
+ typeof cfg.discord !== 'object' ||
634
+ cfg.discord === null ||
635
+ Array.isArray(cfg.discord)
636
+ ) {
637
+ details.push('discord must be an object');
638
+ } else {
639
+ const dc = cfg.discord;
640
+ if (dc.enabled !== undefined && typeof dc.enabled !== 'boolean') {
641
+ details.push('discord.enabled must be a boolean');
642
+ }
643
+ const hasDcToken =
644
+ (typeof dc.bot_token === 'string' && dc.bot_token.length > 0) ||
645
+ (typeof dc.bot_token_env === 'string' && dc.bot_token_env.length > 0);
646
+ if (!hasDcToken) {
647
+ details.push(
648
+ 'discord requires bot_token or bot_token_env (non-empty string)',
649
+ );
650
+ }
651
+ if (
652
+ dc.channel_id === undefined ||
653
+ typeof dc.channel_id !== 'string' ||
654
+ dc.channel_id.length === 0
655
+ ) {
656
+ details.push('discord.channel_id must be a non-empty string');
657
+ }
658
+ if (dc.events !== undefined && !Array.isArray(dc.events)) {
659
+ details.push('discord.events must be an array');
660
+ } else if (Array.isArray(dc.events)) {
661
+ for (let i = 0; i < dc.events.length; i++) {
662
+ if (typeof dc.events[i] !== 'string' || dc.events[i].length === 0) {
663
+ details.push(`discord.events[${i}] must be a non-empty string`);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ }
669
+
670
+ // slack
671
+ if (cfg.slack !== undefined) {
672
+ if (
673
+ typeof cfg.slack !== 'object' ||
674
+ cfg.slack === null ||
675
+ Array.isArray(cfg.slack)
676
+ ) {
677
+ details.push('slack must be an object');
678
+ } else {
679
+ const sl = cfg.slack;
680
+ if (sl.enabled !== undefined && typeof sl.enabled !== 'boolean') {
681
+ details.push('slack.enabled must be a boolean');
682
+ }
683
+ const hasSlUrl =
684
+ (typeof sl.webhook_url === 'string' && sl.webhook_url.length > 0) ||
685
+ (typeof sl.webhook_url_env === 'string' &&
686
+ sl.webhook_url_env.length > 0);
687
+ if (!hasSlUrl) {
688
+ details.push(
689
+ 'slack requires webhook_url or webhook_url_env (non-empty string)',
690
+ );
691
+ }
692
+ if (sl.events !== undefined && !Array.isArray(sl.events)) {
693
+ details.push('slack.events must be an array');
694
+ } else if (Array.isArray(sl.events)) {
695
+ for (let i = 0; i < sl.events.length; i++) {
696
+ if (typeof sl.events[i] !== 'string' || sl.events[i].length === 0) {
697
+ details.push(`slack.events[${i}] must be a non-empty string`);
698
+ }
699
+ }
700
+ }
701
+ }
702
+ }
703
+
704
+ // webhook_out
705
+ if (cfg.webhook_out !== undefined) {
706
+ if (
707
+ typeof cfg.webhook_out !== 'object' ||
708
+ cfg.webhook_out === null ||
709
+ Array.isArray(cfg.webhook_out)
710
+ ) {
711
+ details.push('webhook_out must be an object');
712
+ } else {
713
+ const wo = cfg.webhook_out;
714
+ if (wo.enabled !== undefined && typeof wo.enabled !== 'boolean') {
715
+ details.push('webhook_out.enabled must be a boolean');
716
+ }
717
+ if (wo.endpoints !== undefined) {
718
+ if (!Array.isArray(wo.endpoints)) {
719
+ details.push('webhook_out.endpoints must be an array');
720
+ } else {
721
+ for (let i = 0; i < wo.endpoints.length; i++) {
722
+ const ep = wo.endpoints[i];
723
+ const pfx = `webhook_out.endpoints[${i}]`;
724
+ if (typeof ep !== 'object' || ep === null || Array.isArray(ep)) {
725
+ details.push(`${pfx} must be an object`);
726
+ continue;
727
+ }
728
+ if (
729
+ ep.url === undefined ||
730
+ typeof ep.url !== 'string' ||
731
+ ep.url.trim().length === 0
732
+ ) {
733
+ details.push(`${pfx}.url must be a non-empty string`);
734
+ } else {
735
+ try {
736
+ const parsed = new URL(ep.url);
737
+ if (
738
+ parsed.protocol !== 'http:' &&
739
+ parsed.protocol !== 'https:'
740
+ ) {
741
+ details.push(`${pfx}.url must use http or https protocol`);
742
+ }
743
+ } catch {
744
+ details.push(`${pfx}.url is not a valid URL`);
745
+ }
746
+ }
747
+ if (ep.format !== undefined) {
748
+ if (!VALID_WEBHOOK_OUT_FORMATS.includes(ep.format)) {
749
+ details.push(
750
+ `${pfx}.format must be one of: ${VALID_WEBHOOK_OUT_FORMATS.join(', ')}`,
751
+ );
752
+ }
753
+ }
754
+ if (ep.headers !== undefined) {
755
+ if (
756
+ typeof ep.headers !== 'object' ||
757
+ ep.headers === null ||
758
+ Array.isArray(ep.headers)
759
+ ) {
760
+ details.push(`${pfx}.headers must be an object`);
761
+ }
762
+ }
763
+ if (ep.events !== undefined && !Array.isArray(ep.events)) {
764
+ details.push(`${pfx}.events must be an array`);
765
+ } else if (Array.isArray(ep.events)) {
766
+ for (let j = 0; j < ep.events.length; j++) {
767
+ if (
768
+ typeof ep.events[j] !== 'string' ||
769
+ ep.events[j].length === 0
770
+ ) {
771
+ details.push(
772
+ `${pfx}.events[${j}] must be a non-empty string`,
773
+ );
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ return details.length ? { valid: false, details } : { valid: true };
784
+ }