@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.
- package/app/main.bundle.js +678 -570
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +205 -4
- package/package.json +1 -1
- package/server/app.js +285 -6
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/process-manager.js +61 -2
- package/server/project-routes.js +44 -2
- package/server/settings-validator.js +250 -0
|
@@ -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;
|
package/server/project-routes.js
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
+
}
|