@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.
- package/app/main.bundle.js +1424 -755
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +399 -23
- package/package.json +5 -4
- package/server/app.js +341 -6
- package/server/dispatch-events-aggregator.js +161 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +228 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +61 -2
- package/server/project-registry.js +37 -0
- package/server/project-routes.js +175 -6
- package/server/settings-validator.js +279 -2
- package/server/subagents-discovery.js +116 -0
- package/server/version-check.js +35 -0
- package/server/watcher.js +37 -10
- package/server/worca-setup.js +15 -1
- package/server/ws-modular.js +6 -2
package/server/app.js
CHANGED
|
@@ -2,26 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
6
8
|
import { fileURLToPath } from 'node:url';
|
|
7
9
|
import express from 'express';
|
|
8
10
|
|
|
9
11
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
12
|
+
import { RAW_BODY } from './integrations/index.js';
|
|
13
|
+
import { verify } from './integrations/verify.js';
|
|
10
14
|
import { ProcessManager } from './process-manager.js';
|
|
15
|
+
import { scanDirectory } from './project-registry.js';
|
|
11
16
|
import {
|
|
12
17
|
createProjectRoutes,
|
|
13
18
|
createProjectScopedRoutes,
|
|
14
19
|
projectResolver,
|
|
15
20
|
} from './project-routes.js';
|
|
21
|
+
import { validateIntegrationsConfig } from './settings-validator.js';
|
|
22
|
+
import { discoverSubagents } from './subagents-discovery.js';
|
|
16
23
|
import { getVersionInfo } from './versions.js';
|
|
17
24
|
import { createInbox } from './webhook-inbox.js';
|
|
18
25
|
|
|
19
26
|
export function createApp(options = {}) {
|
|
20
27
|
const app = express();
|
|
21
28
|
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
22
|
-
const {
|
|
29
|
+
const {
|
|
30
|
+
settingsPath,
|
|
31
|
+
worcaDir,
|
|
32
|
+
projectRoot,
|
|
33
|
+
prefsDir,
|
|
34
|
+
serverHost,
|
|
35
|
+
serverPort,
|
|
36
|
+
} = options;
|
|
37
|
+
// subagentDirs is a test-injection seam; production calls omit it and we
|
|
38
|
+
// resolve from homedir() + projectRoot.
|
|
39
|
+
const subagentDirs = options.subagentDirs || null;
|
|
23
40
|
|
|
24
|
-
app.use(
|
|
41
|
+
app.use(
|
|
42
|
+
express.json({
|
|
43
|
+
verify: (req, _res, buf) => {
|
|
44
|
+
req.rawBody = buf;
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
25
48
|
|
|
26
49
|
// ─── Security headers ──────────────────────────────────────────────────
|
|
27
50
|
app.use((_req, res, next) => {
|
|
@@ -87,11 +110,36 @@ export function createApp(options = {}) {
|
|
|
87
110
|
};
|
|
88
111
|
next();
|
|
89
112
|
},
|
|
90
|
-
createProjectScopedRoutes(),
|
|
113
|
+
createProjectScopedRoutes({ serverHost, serverPort }),
|
|
91
114
|
);
|
|
92
115
|
|
|
93
116
|
// ─── Unique routes (not in project-scoped router) ──────────────────────
|
|
94
117
|
|
|
118
|
+
// GET /api/subagents — list discoverable subagent types for the dispatch editor.
|
|
119
|
+
// Walks ~/.claude/agents/ (user-global), ~/.claude/plugins/cache/
|
|
120
|
+
// (plugin-cached), and the active project's .claude/agents/ (in single-project
|
|
121
|
+
// mode). Tests inject alternate dirs via createApp({ subagentDirs: {...} }).
|
|
122
|
+
app.get('/api/subagents', (_req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const userDir =
|
|
125
|
+
subagentDirs?.userDir ?? join(homedir(), '.claude', 'agents');
|
|
126
|
+
const pluginCacheDir =
|
|
127
|
+
subagentDirs?.pluginCacheDir ??
|
|
128
|
+
join(homedir(), '.claude', 'plugins', 'cache');
|
|
129
|
+
const projectAgentsDir =
|
|
130
|
+
subagentDirs?.projectAgentsDir ??
|
|
131
|
+
(projectRoot ? join(projectRoot, '.claude', 'agents') : undefined);
|
|
132
|
+
const subagents = discoverSubagents({
|
|
133
|
+
userDir,
|
|
134
|
+
pluginCacheDir,
|
|
135
|
+
projectAgentsDir,
|
|
136
|
+
});
|
|
137
|
+
res.json({ ok: true, subagents });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
95
143
|
// GET /api/beads/issues
|
|
96
144
|
app.get('/api/beads/issues', (_req, res) => {
|
|
97
145
|
if (!worcaDir)
|
|
@@ -250,6 +298,16 @@ export function createApp(options = {}) {
|
|
|
250
298
|
|
|
251
299
|
// POST /api/webhooks/inbox — receive webhook events
|
|
252
300
|
app.post('/api/webhooks/inbox', (req, res) => {
|
|
301
|
+
const integrations = app.locals.integrations;
|
|
302
|
+
if (integrations?.strictInboxVerification) {
|
|
303
|
+
const ok = verify(
|
|
304
|
+
req.rawBody || Buffer.alloc(0),
|
|
305
|
+
req.headers['x-worca-signature'],
|
|
306
|
+
integrations.secrets || [],
|
|
307
|
+
);
|
|
308
|
+
if (!ok)
|
|
309
|
+
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
310
|
+
}
|
|
253
311
|
const headers = {
|
|
254
312
|
'x-worca-event': req.headers['x-worca-event'] || '',
|
|
255
313
|
'x-worca-delivery': req.headers['x-worca-delivery'] || '',
|
|
@@ -266,9 +324,11 @@ export function createApp(options = {}) {
|
|
|
266
324
|
envelope: req.body || {},
|
|
267
325
|
projectId,
|
|
268
326
|
});
|
|
327
|
+
if (req.rawBody) stored[RAW_BODY] = req.rawBody;
|
|
269
328
|
if (app.locals.broadcast) {
|
|
270
329
|
app.locals.broadcast('webhook-inbox-event', stored);
|
|
271
330
|
}
|
|
331
|
+
app.locals.integrations?.onEvent(stored);
|
|
272
332
|
res.json({ control: { action: webhookInbox.getControlAction() } });
|
|
273
333
|
});
|
|
274
334
|
|
|
@@ -300,6 +360,16 @@ export function createApp(options = {}) {
|
|
|
300
360
|
|
|
301
361
|
// PUT /api/webhooks/inbox/control — set control action
|
|
302
362
|
app.put('/api/webhooks/inbox/control', (req, res) => {
|
|
363
|
+
const integrations = app.locals.integrations;
|
|
364
|
+
if (integrations?.strictInboxVerification) {
|
|
365
|
+
const ok = verify(
|
|
366
|
+
req.rawBody || Buffer.alloc(0),
|
|
367
|
+
req.headers['x-worca-signature'],
|
|
368
|
+
integrations.secrets || [],
|
|
369
|
+
);
|
|
370
|
+
if (!ok)
|
|
371
|
+
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
372
|
+
}
|
|
303
373
|
const { action } = req.body || {};
|
|
304
374
|
if (!['continue', 'pause', 'abort'].includes(action)) {
|
|
305
375
|
return res.status(400).json({
|
|
@@ -404,6 +474,30 @@ export function createApp(options = {}) {
|
|
|
404
474
|
}
|
|
405
475
|
});
|
|
406
476
|
|
|
477
|
+
// POST /api/scan-directory — scan parent folder for immediate git subdirectories
|
|
478
|
+
app.post('/api/scan-directory', async (req, res) => {
|
|
479
|
+
const { path: dirPath } = req.body || {};
|
|
480
|
+
if (!dirPath || typeof dirPath !== 'string') {
|
|
481
|
+
return res.status(400).json({ ok: false, error: 'path is required' });
|
|
482
|
+
}
|
|
483
|
+
if (!isAbsolute(dirPath)) {
|
|
484
|
+
return res
|
|
485
|
+
.status(400)
|
|
486
|
+
.json({ ok: false, error: 'path must be absolute' });
|
|
487
|
+
}
|
|
488
|
+
if (!existsSync(dirPath)) {
|
|
489
|
+
return res
|
|
490
|
+
.status(400)
|
|
491
|
+
.json({ ok: false, error: `directory does not exist: ${dirPath}` });
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const subfolders = await scanDirectory(dirPath);
|
|
495
|
+
res.json({ ok: true, subfolders });
|
|
496
|
+
} catch (err) {
|
|
497
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
407
501
|
// GET /api/versions — installed + registry version info
|
|
408
502
|
app.get('/api/versions', async (req, res) => {
|
|
409
503
|
const force = req.query.force === '1';
|
|
@@ -419,14 +513,255 @@ export function createApp(options = {}) {
|
|
|
419
513
|
|
|
420
514
|
// ─── Multi-project routes ──────────────────────────────────────────────
|
|
421
515
|
if (prefsDir) {
|
|
422
|
-
app.use(
|
|
516
|
+
app.use(
|
|
517
|
+
'/api/projects',
|
|
518
|
+
createProjectRoutes({ prefsDir, projectRoot, serverHost, serverPort }),
|
|
519
|
+
);
|
|
423
520
|
app.use(
|
|
424
521
|
'/api/projects/:projectId',
|
|
425
522
|
projectResolver({ prefsDir, projectRoot }),
|
|
426
|
-
createProjectScopedRoutes(),
|
|
523
|
+
createProjectScopedRoutes({ prefsDir, serverHost, serverPort }),
|
|
427
524
|
);
|
|
428
525
|
}
|
|
429
526
|
|
|
527
|
+
// POST /api/integrations/telegram/detect — find chat IDs from recent messages.
|
|
528
|
+
// If the Telegram adapter is running, temporarily pauses its poll loop so
|
|
529
|
+
// getUpdates returns results instead of being consumed by the long-poller.
|
|
530
|
+
app.post('/api/integrations/telegram/detect', async (req, res) => {
|
|
531
|
+
let token = req.body?.token;
|
|
532
|
+
if (!token) {
|
|
533
|
+
try {
|
|
534
|
+
const cfgRaw = readFileSync(
|
|
535
|
+
join(prefsDir, 'integrations', 'config.json'),
|
|
536
|
+
'utf8',
|
|
537
|
+
);
|
|
538
|
+
token = JSON.parse(cfgRaw).telegram?.bot_token;
|
|
539
|
+
} catch {
|
|
540
|
+
/* no config */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (!token) token = process.env.TELEGRAM_BOT_TOKEN;
|
|
544
|
+
if (!token) {
|
|
545
|
+
return res.status(400).json({ error: 'No bot token provided' });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Pause the running adapter so getUpdates isn't consumed by the poll loop
|
|
549
|
+
const integrations = app.locals.integrations;
|
|
550
|
+
const adapterEntry = integrations?._getAdapter?.('telegram');
|
|
551
|
+
let wasStopped = false;
|
|
552
|
+
if (adapterEntry) {
|
|
553
|
+
try {
|
|
554
|
+
await adapterEntry.adapter.stop();
|
|
555
|
+
wasStopped = true;
|
|
556
|
+
// Brief delay to let the in-flight long-poll request complete
|
|
557
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
558
|
+
} catch {
|
|
559
|
+
/* ignore */
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const meRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
|
565
|
+
const me = await meRes.json();
|
|
566
|
+
const botUsername = me.ok ? me.result.username : null;
|
|
567
|
+
|
|
568
|
+
const updRes = await fetch(
|
|
569
|
+
`https://api.telegram.org/bot${token}/getUpdates?timeout=0&limit=20`,
|
|
570
|
+
);
|
|
571
|
+
const upd = await updRes.json();
|
|
572
|
+
|
|
573
|
+
const chats = [];
|
|
574
|
+
if (upd.ok) {
|
|
575
|
+
for (const u of upd.result) {
|
|
576
|
+
const msg = u.message;
|
|
577
|
+
if (msg?.chat?.id) {
|
|
578
|
+
const existing = chats.find((c) => c.id === msg.chat.id);
|
|
579
|
+
if (!existing) {
|
|
580
|
+
chats.push({
|
|
581
|
+
id: msg.chat.id,
|
|
582
|
+
type: msg.chat.type,
|
|
583
|
+
title:
|
|
584
|
+
msg.chat.title || msg.chat.first_name || String(msg.chat.id),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
res.json({ ok: true, botUsername, chats });
|
|
592
|
+
} catch (err) {
|
|
593
|
+
res.status(500).json({ error: err.message });
|
|
594
|
+
} finally {
|
|
595
|
+
// Restart the adapter if we paused it
|
|
596
|
+
if (wasStopped && adapterEntry) {
|
|
597
|
+
adapterEntry.adapter.start().catch(() => {});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// GET /api/integrations/status — adapter states, chat states, counters
|
|
603
|
+
app.get('/api/integrations/status', (_req, res) => {
|
|
604
|
+
const integrations = app.locals.integrations;
|
|
605
|
+
if (!integrations) return res.json({ enabled: false });
|
|
606
|
+
res.json(integrations.status());
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// GET /api/integrations/config — return saved config (secrets redacted)
|
|
610
|
+
app.get('/api/integrations/config', (_req, res) => {
|
|
611
|
+
const configPath = join(prefsDir, 'integrations', 'config.json');
|
|
612
|
+
let cfg;
|
|
613
|
+
try {
|
|
614
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
615
|
+
} catch {
|
|
616
|
+
return res.json({});
|
|
617
|
+
}
|
|
618
|
+
res.json(cfg);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// DELETE /api/integrations/config/:adapter — remove an adapter
|
|
622
|
+
// PATCH /api/integrations/config/:adapter/enabled — toggle adapter on/off
|
|
623
|
+
app.patch('/api/integrations/config/:adapter/enabled', async (req, res) => {
|
|
624
|
+
const { adapter } = req.params;
|
|
625
|
+
const { enabled } = req.body;
|
|
626
|
+
if (typeof enabled !== 'boolean') {
|
|
627
|
+
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
628
|
+
}
|
|
629
|
+
const adapterKeys = ['telegram', 'discord', 'slack'];
|
|
630
|
+
if (!adapterKeys.includes(adapter)) {
|
|
631
|
+
return res.status(400).json({ error: `Invalid adapter: ${adapter}` });
|
|
632
|
+
}
|
|
633
|
+
const configPath = join(prefsDir, 'integrations', 'config.json');
|
|
634
|
+
let cfg;
|
|
635
|
+
try {
|
|
636
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
637
|
+
} catch {
|
|
638
|
+
return res.status(404).json({ error: 'No integrations config' });
|
|
639
|
+
}
|
|
640
|
+
if (!cfg[adapter]) {
|
|
641
|
+
return res
|
|
642
|
+
.status(404)
|
|
643
|
+
.json({ error: `Adapter ${adapter} not configured` });
|
|
644
|
+
}
|
|
645
|
+
cfg[adapter].enabled = enabled;
|
|
646
|
+
writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
647
|
+
|
|
648
|
+
// Hot-reload: if disabling, remove the adapter; if enabling, reload it
|
|
649
|
+
if (app.locals.ensureIntegrations) app.locals.ensureIntegrations();
|
|
650
|
+
if (enabled) {
|
|
651
|
+
if (app.locals.integrations?.reloadAdapter) {
|
|
652
|
+
await app.locals.integrations.reloadAdapter(adapter);
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
if (app.locals.integrations?.removeAdapter) {
|
|
656
|
+
await app.locals.integrations.removeAdapter(adapter);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
res.json({ ok: true, enabled });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
app.delete('/api/integrations/config/:adapter', async (req, res) => {
|
|
663
|
+
const { adapter } = req.params;
|
|
664
|
+
const adapterKeys = ['telegram', 'discord', 'slack'];
|
|
665
|
+
if (!adapterKeys.includes(adapter)) {
|
|
666
|
+
return res.status(400).json({ error: `Invalid adapter: ${adapter}` });
|
|
667
|
+
}
|
|
668
|
+
const configDir = join(prefsDir, 'integrations');
|
|
669
|
+
const configPath = join(configDir, 'config.json');
|
|
670
|
+
let cfg;
|
|
671
|
+
try {
|
|
672
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
673
|
+
} catch {
|
|
674
|
+
return res.json({ ok: true });
|
|
675
|
+
}
|
|
676
|
+
delete cfg[adapter];
|
|
677
|
+
const hasAdapters = adapterKeys.some((k) => cfg[k]?.enabled);
|
|
678
|
+
if (!hasAdapters) cfg.enabled = false;
|
|
679
|
+
try {
|
|
680
|
+
writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
return res
|
|
683
|
+
.status(500)
|
|
684
|
+
.json({ error: `Failed to write config: ${err.message}` });
|
|
685
|
+
}
|
|
686
|
+
if (app.locals.integrations?.removeAdapter) {
|
|
687
|
+
await app.locals.integrations.removeAdapter(adapter);
|
|
688
|
+
}
|
|
689
|
+
res.json({ ok: true });
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// POST /api/integrations/config — save adapter config
|
|
693
|
+
const ADAPTER_SCHEMA = {
|
|
694
|
+
telegram: { tokenKey: 'bot_token', idKey: 'chat_id' },
|
|
695
|
+
discord: { tokenKey: 'bot_token', idKey: 'channel_id' },
|
|
696
|
+
slack: { tokenKey: 'webhook_url', idKey: 'chat_id' },
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
app.post('/api/integrations/config', async (req, res) => {
|
|
700
|
+
const { adapter, token, chatId, events } = req.body;
|
|
701
|
+
if (
|
|
702
|
+
!adapter ||
|
|
703
|
+
!token ||
|
|
704
|
+
!chatId ||
|
|
705
|
+
!Array.isArray(events) ||
|
|
706
|
+
events.length === 0
|
|
707
|
+
) {
|
|
708
|
+
return res.status(400).json({
|
|
709
|
+
error: 'Missing required fields: adapter, token, chatId, events',
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const schema = ADAPTER_SCHEMA[adapter];
|
|
713
|
+
if (!schema) {
|
|
714
|
+
return res.status(400).json({
|
|
715
|
+
error: `Invalid adapter: ${adapter}. Must be one of: ${Object.keys(ADAPTER_SCHEMA).join(', ')}`,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const configDir = join(prefsDir, 'integrations');
|
|
720
|
+
const configPath = join(configDir, 'config.json');
|
|
721
|
+
|
|
722
|
+
// Load existing config or start fresh
|
|
723
|
+
let cfg = { schema_version: 1, enabled: true };
|
|
724
|
+
try {
|
|
725
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
726
|
+
cfg = JSON.parse(raw);
|
|
727
|
+
} catch {
|
|
728
|
+
/* start fresh */
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Build adapter block — store token directly in config
|
|
732
|
+
const adapterBlock = { enabled: true, events };
|
|
733
|
+
adapterBlock[schema.tokenKey] = token;
|
|
734
|
+
adapterBlock[schema.idKey] = chatId;
|
|
735
|
+
|
|
736
|
+
cfg[adapter] = adapterBlock;
|
|
737
|
+
cfg.enabled = true;
|
|
738
|
+
if (!cfg.schema_version) cfg.schema_version = 1;
|
|
739
|
+
|
|
740
|
+
const result = validateIntegrationsConfig(cfg);
|
|
741
|
+
if (!result.valid) {
|
|
742
|
+
return res
|
|
743
|
+
.status(400)
|
|
744
|
+
.json({ error: `Validation failed: ${result.details.join('; ')}` });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
mkdirSync(configDir, { recursive: true });
|
|
749
|
+
writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
750
|
+
} catch (err) {
|
|
751
|
+
return res
|
|
752
|
+
.status(500)
|
|
753
|
+
.json({ error: `Failed to write config: ${err.message}` });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Hot-reload just this adapter (no full restart)
|
|
757
|
+
if (app.locals.ensureIntegrations) app.locals.ensureIntegrations();
|
|
758
|
+
if (app.locals.integrations?.reloadAdapter) {
|
|
759
|
+
await app.locals.integrations.reloadAdapter(adapter);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
res.json({ ok: true, path: configPath });
|
|
763
|
+
});
|
|
764
|
+
|
|
430
765
|
// ─── Dynamic favicon ──────────────────────────────────────────────────
|
|
431
766
|
// Serve mode-specific favicon before express.static so it takes precedence.
|
|
432
767
|
app.get('/favicon.svg', (_req, res) => {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-event aggregator — reads pipeline.hook.dispatch_{allowed,blocked}
|
|
3
|
+
* events from a run's events.jsonl and assigns them to the matching iteration
|
|
4
|
+
* in status.json by timestamp range.
|
|
5
|
+
*
|
|
6
|
+
* Works for both live and completed runs because it reads only persisted data
|
|
7
|
+
* (events.jsonl is append-only and survives the pipeline process exiting).
|
|
8
|
+
*
|
|
9
|
+
* Aggregation: events are deduplicated per iteration by (type, subagent_type).
|
|
10
|
+
* A `count` field tracks how many times the same (type, subagent_type) fired
|
|
11
|
+
* in that iteration. The `reason` from the first occurrence is kept (reasons
|
|
12
|
+
* for the same key are deterministic — derived from the denylist/rule check).
|
|
13
|
+
*
|
|
14
|
+
* Output shape per iteration:
|
|
15
|
+
* dispatch_events: [
|
|
16
|
+
* { type, subagent_type, reason?, count }
|
|
17
|
+
* ]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
const DISPATCH_EVENT_TYPES = new Set([
|
|
23
|
+
'pipeline.hook.dispatch_allowed',
|
|
24
|
+
'pipeline.hook.dispatch_blocked',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse events.jsonl and return only the dispatch events, with normalised shape.
|
|
29
|
+
* Malformed lines are silently skipped so a corrupt event doesn't break the run view.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} eventsPath — absolute path to events.jsonl
|
|
32
|
+
* @returns {Array<{type, subagent_type, reason?, timestamp}>}
|
|
33
|
+
*/
|
|
34
|
+
export function readDispatchEventsFromJsonl(eventsPath) {
|
|
35
|
+
if (!eventsPath || !existsSync(eventsPath)) return [];
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = readFileSync(eventsPath, 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of content.split('\n')) {
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
let e;
|
|
46
|
+
try {
|
|
47
|
+
e = JSON.parse(line);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!DISPATCH_EVENT_TYPES.has(e.event_type)) continue;
|
|
52
|
+
const payload = e.payload || {};
|
|
53
|
+
if (!payload.subagent_type) continue;
|
|
54
|
+
out.push({
|
|
55
|
+
type: e.event_type,
|
|
56
|
+
subagent_type: payload.subagent_type,
|
|
57
|
+
reason: payload.reason,
|
|
58
|
+
timestamp: e.timestamp,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Given a list of dispatch events and a stages map from status.json, return
|
|
66
|
+
* a new stages map where each iteration that overlaps an event's timestamp
|
|
67
|
+
* is enriched with a `dispatch_events` array (deduplicated by type+subagent_type
|
|
68
|
+
* with a count).
|
|
69
|
+
*
|
|
70
|
+
* Non-destructive: input stages object is shallow-copied; iterations get new
|
|
71
|
+
* objects with the extra field. Existing iteration fields are preserved.
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{type, subagent_type, reason?, timestamp}>} events
|
|
74
|
+
* @param {object} stages — status.stages
|
|
75
|
+
* @returns {object} enriched stages
|
|
76
|
+
*/
|
|
77
|
+
export function assignEventsToIterations(events, stages) {
|
|
78
|
+
if (!stages || typeof stages !== 'object') return stages;
|
|
79
|
+
if (!events || events.length === 0) {
|
|
80
|
+
// Nothing to add — return input unchanged to avoid unnecessary allocation.
|
|
81
|
+
return stages;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bucket events into iterations first, then aggregate per bucket.
|
|
85
|
+
// Bucket key: `${stageKey}|${iterationNumber}`.
|
|
86
|
+
const buckets = new Map();
|
|
87
|
+
|
|
88
|
+
for (const ev of events) {
|
|
89
|
+
if (!ev.timestamp) continue;
|
|
90
|
+
const eventTime = Date.parse(ev.timestamp);
|
|
91
|
+
if (Number.isNaN(eventTime)) continue;
|
|
92
|
+
|
|
93
|
+
let matched = false;
|
|
94
|
+
for (const [stageKey, stage] of Object.entries(stages)) {
|
|
95
|
+
const iterations = stage?.iterations;
|
|
96
|
+
if (!Array.isArray(iterations)) continue;
|
|
97
|
+
for (const iter of iterations) {
|
|
98
|
+
const start = iter.started_at ? Date.parse(iter.started_at) : NaN;
|
|
99
|
+
if (Number.isNaN(start)) continue;
|
|
100
|
+
// If the iteration hasn't completed, treat end as +infinity so live events land here.
|
|
101
|
+
const end = iter.completed_at
|
|
102
|
+
? Date.parse(iter.completed_at)
|
|
103
|
+
: Number.POSITIVE_INFINITY;
|
|
104
|
+
if (eventTime >= start && eventTime <= end) {
|
|
105
|
+
const key = `${stageKey}|${iter.number}`;
|
|
106
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
107
|
+
buckets.get(key).push(ev);
|
|
108
|
+
matched = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (matched) break;
|
|
113
|
+
}
|
|
114
|
+
// If no iteration matched, the event is silently dropped — it falls
|
|
115
|
+
// outside any recorded iteration window (e.g. during stage transitions).
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (buckets.size === 0) return stages;
|
|
119
|
+
|
|
120
|
+
// Aggregate each bucket and build the enriched stages map.
|
|
121
|
+
const enrichedStages = { ...stages };
|
|
122
|
+
for (const [key, bucketEvents] of buckets) {
|
|
123
|
+
const [stageKey, iterNumStr] = key.split('|');
|
|
124
|
+
const iterNum = Number(iterNumStr);
|
|
125
|
+
const stage = enrichedStages[stageKey];
|
|
126
|
+
if (!stage) continue;
|
|
127
|
+
const aggregated = aggregate(bucketEvents);
|
|
128
|
+
const newIterations = stage.iterations.map((iter) =>
|
|
129
|
+
iter.number === iterNum ? { ...iter, dispatch_events: aggregated } : iter,
|
|
130
|
+
);
|
|
131
|
+
enrichedStages[stageKey] = { ...stage, iterations: newIterations };
|
|
132
|
+
}
|
|
133
|
+
return enrichedStages;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Deduplicate an array of dispatch events by (type, subagent_type) and count
|
|
138
|
+
* occurrences. First reason wins for blocked events.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<{type, subagent_type, reason?}>} events
|
|
141
|
+
* @returns {Array<{type, subagent_type, reason?, count}>}
|
|
142
|
+
*/
|
|
143
|
+
function aggregate(events) {
|
|
144
|
+
const map = new Map();
|
|
145
|
+
for (const ev of events) {
|
|
146
|
+
const key = `${ev.type}|${ev.subagent_type}`;
|
|
147
|
+
const existing = map.get(key);
|
|
148
|
+
if (existing) {
|
|
149
|
+
existing.count += 1;
|
|
150
|
+
} else {
|
|
151
|
+
const entry = {
|
|
152
|
+
type: ev.type,
|
|
153
|
+
subagent_type: ev.subagent_type,
|
|
154
|
+
count: 1,
|
|
155
|
+
};
|
|
156
|
+
if (ev.reason) entry.reason = ev.reason;
|
|
157
|
+
map.set(key, entry);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...map.values()];
|
|
161
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
package/server/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
|
|
|
4
4
|
import { homedir, platform } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { createApp } from './app.js';
|
|
7
|
+
import { createIntegrations } from './integrations/index.js';
|
|
7
8
|
import { attachWsServer } from './ws.js';
|
|
8
9
|
|
|
9
10
|
// Parse argv
|
|
@@ -57,6 +58,8 @@ const app = createApp({
|
|
|
57
58
|
projectRoot,
|
|
58
59
|
webhookInbox,
|
|
59
60
|
prefsDir,
|
|
61
|
+
serverHost: host,
|
|
62
|
+
serverPort: port,
|
|
60
63
|
});
|
|
61
64
|
const server = createServer(app);
|
|
62
65
|
|
|
@@ -104,6 +107,25 @@ app.locals.broadcast = broadcast;
|
|
|
104
107
|
app.locals.scheduleRefresh = scheduleRefresh;
|
|
105
108
|
app.locals.resolveRunProject = resolveRunProject;
|
|
106
109
|
|
|
110
|
+
// Boot chat integrations only in global mode — project-scoped instances skip
|
|
111
|
+
// integrations to avoid duplicate Telegram long-poll connections on the same bot.
|
|
112
|
+
if (isGlobal) {
|
|
113
|
+
const integrationsOpts = {
|
|
114
|
+
port,
|
|
115
|
+
host,
|
|
116
|
+
prefsDir,
|
|
117
|
+
configPath: join(prefsDir, 'integrations', 'config.json'),
|
|
118
|
+
};
|
|
119
|
+
app.locals.integrations = createIntegrations(integrationsOpts);
|
|
120
|
+
app.locals.ensureIntegrations = () => {
|
|
121
|
+
if (!app.locals.integrations?.reloadAdapter) {
|
|
122
|
+
app.locals.integrations = createIntegrations(integrationsOpts);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
console.log('[integrations] Skipped — integrations only run in global mode');
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
// ─── worca-cc version check (non-blocking) ─────────────────────────────
|
|
108
130
|
checkWorcaVersion().then((result) => {
|
|
109
131
|
app.locals.worcaVersion = result;
|