@worca/ui 0.9.0-rc.1 → 0.9.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.
@@ -4,9 +4,8 @@
4
4
  */
5
5
 
6
6
  import { spawn } from 'node:child_process';
7
- import { randomBytes, randomUUID } from 'node:crypto';
7
+ import { randomBytes } from 'node:crypto';
8
8
  import {
9
- appendFileSync,
10
9
  closeSync,
11
10
  existsSync,
12
11
  mkdirSync,
@@ -23,19 +22,6 @@ import { join } from 'node:path';
23
22
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
24
23
  const ARG_INLINE_LIMIT = 128 * 1024;
25
24
 
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
-
39
25
  /**
40
26
  * Write content to a temp file with restricted permissions (0o600) and return its path.
41
27
  * Used to avoid E2BIG when passing large prompts as CLI arguments.
@@ -177,11 +163,10 @@ export class ProcessManager {
177
163
 
178
164
  if (status.pipeline_status !== 'running') continue;
179
165
 
166
+ status.pipeline_status = 'failed';
180
167
  if (!status.stop_reason) {
181
168
  status.stop_reason = 'stale';
182
169
  }
183
- status.pipeline_status =
184
- status.stop_reason === 'stale' ? 'interrupted' : 'failed';
185
170
  try {
186
171
  writeFileSync(
187
172
  statusPath,
@@ -192,50 +177,6 @@ export class ProcessManager {
192
177
  } catch {
193
178
  /* ignore */
194
179
  }
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
- }
239
180
  }
240
181
 
241
182
  return fixed;
@@ -20,7 +20,6 @@ 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';
24
23
  import { readPreferences } from './preferences.js';
25
24
  import { ProcessManager } from './process-manager.js';
26
25
  import {
@@ -139,12 +138,7 @@ export function projectResolver({ prefsDir, projectRoot }) {
139
138
  /**
140
139
  * Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
141
140
  */
142
- export function createProjectRoutes({
143
- prefsDir,
144
- projectRoot,
145
- serverHost,
146
- serverPort,
147
- }) {
141
+ export function createProjectRoutes({ prefsDir, projectRoot }) {
148
142
  const router = Router();
149
143
 
150
144
  // GET /api/projects — list all projects (or synthesized default)
@@ -175,17 +169,6 @@ export function createProjectRoutes({
175
169
  }
176
170
  try {
177
171
  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
- }
189
172
  res.status(201).json({ ok: true, project: entry });
190
173
  } catch (err) {
191
174
  res.status(400).json({ ok: false, error: err.message });
@@ -284,16 +267,6 @@ export function createProjectRoutes({
284
267
  for (const entry of batch) {
285
268
  writeProject(prefsDir, entry);
286
269
  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
- }
297
270
  }
298
271
  res.status(201).json({ ok: true, projects: batch });
299
272
  } catch (err) {
@@ -317,11 +290,7 @@ export function createProjectRoutes({
317
290
  * @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
318
291
  * worca-cc version lookup for /worca-status' `outdated` flag.
319
292
  */
320
- export function createProjectScopedRoutes({
321
- prefsDir = null,
322
- serverHost,
323
- serverPort,
324
- } = {}) {
293
+ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
325
294
  const router = Router({ mergeParams: true });
326
295
  const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
327
296
 
@@ -1396,17 +1365,6 @@ export function createProjectScopedRoutes({
1396
1365
 
1397
1366
  try {
1398
1367
  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
- }
1410
1368
  res.json({ ok: true, pid });
1411
1369
  } catch (err) {
1412
1370
  res.status(500).json({ ok: false, error: err.message });
@@ -532,253 +532,3 @@ 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
- }
@@ -1,66 +0,0 @@
1
- // ensure-webhook.js — auto-configure a webhook pointing to this worca-ui instance
2
- // in a project's settings.local.json so the pipeline sends events to the UI.
3
-
4
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { localPathFor } from './settings-merge.js';
7
-
8
- /**
9
- * Ensure a webhook entry exists in the project's settings.local.json
10
- * pointing to the worca-ui inbox at the given host:port.
11
- *
12
- * Skips if a webhook for this host:port already exists.
13
- * Creates settings.local.json if it doesn't exist.
14
- *
15
- * @param {string} projectPath — absolute path to the project root
16
- * @param {{ host: string, port: number }} server — worca-ui server address
17
- */
18
- export function ensureWebhookForUi(projectPath, { host, port }) {
19
- const settingsPath = join(projectPath, '.claude', 'settings.json');
20
- const localPath = localPathFor(settingsPath);
21
- // Use localhost instead of 127.0.0.1 — the pipeline validator only allows
22
- // https:// or http://localhost for security.
23
- const displayHost =
24
- host === '127.0.0.1' || host === '::1' ? 'localhost' : host;
25
- const inboxUrl = `http://${displayHost}:${port}/api/webhooks/inbox`;
26
-
27
- // Read existing local settings (or start fresh)
28
- let local = {};
29
- if (existsSync(localPath)) {
30
- try {
31
- local = JSON.parse(readFileSync(localPath, 'utf8'));
32
- } catch {
33
- local = {};
34
- }
35
- }
36
-
37
- if (!local.worca) local.worca = {};
38
- if (!Array.isArray(local.worca.webhooks)) local.worca.webhooks = [];
39
-
40
- // Check if a webhook for this URL already exists
41
- const exists = local.worca.webhooks.some((wh) => wh.url === inboxUrl);
42
- if (exists) return false;
43
-
44
- // Also check base settings.json (in case it was manually configured there)
45
- try {
46
- const base = JSON.parse(readFileSync(settingsPath, 'utf8'));
47
- const baseWebhooks = base?.worca?.webhooks || [];
48
- if (baseWebhooks.some((wh) => wh.url === inboxUrl)) return false;
49
- } catch {
50
- // no base settings — proceed
51
- }
52
-
53
- local.worca.webhooks.push({
54
- url: inboxUrl,
55
- events: ['pipeline.*'],
56
- });
57
-
58
- // Ensure events are enabled
59
- if (!local.worca.events) local.worca.events = {};
60
- if (local.worca.events.enabled === undefined) {
61
- local.worca.events.enabled = true;
62
- }
63
-
64
- writeFileSync(localPath, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
65
- return true;
66
- }