@worca/ui 0.1.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.
@@ -0,0 +1,870 @@
1
+ /**
2
+ * WebSocket message router — handles all 24 request types.
3
+ * Delegates to other modules for state and side effects.
4
+ *
5
+ * Supports multi-project mode: each handler resolves the target project
6
+ * via resolveProject() before accessing watchers or filesystem paths.
7
+ */
8
+
9
+ import { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { isRequest, makeError, makeOk } from '../app/protocol.js';
12
+ import {
13
+ dbExists as beadsDbExists,
14
+ countIssuesByRunLabel,
15
+ getIssue,
16
+ listDistinctRunLabels,
17
+ listIssues,
18
+ listIssuesByLabel,
19
+ listUnlinkedIssues,
20
+ } from './beads-reader.js';
21
+ import {
22
+ listIterationFiles,
23
+ listLogFiles,
24
+ readLastLines,
25
+ resolveIterationLogPath,
26
+ resolveLogPath,
27
+ } from './log-tailer.js';
28
+ import { readPreferences, writePreferences } from './preferences.js';
29
+ import {
30
+ pausePipeline as pmPausePipeline,
31
+ startPipeline as pmStartPipeline,
32
+ stopPipeline as pmStopPipeline,
33
+ reconcileStatus,
34
+ } from './process-manager.js';
35
+ import { readSettings } from './settings-reader.js';
36
+ import { discoverRuns } from './watcher.js';
37
+
38
+ // Legacy fallback prefixes — only used when status.json lacks a stored prompt
39
+ const STAGE_PROMPT_PREFIX = {
40
+ plan: 'Create a detailed implementation plan for the following work request. Write the plan to the designated plan file.\n\nWork request: ',
41
+ coordinate:
42
+ 'Decompose the following work request into Beads tasks with dependencies. Do NOT implement anything — only create tasks using `bd create`.\n\nWork request: ',
43
+ implement:
44
+ 'Implement the code changes described in the work request. Follow the plan and complete the tasks assigned to you.\n\nWork request: ',
45
+ test: 'Review and test the implementation for the following work request. Run tests and report results. Do NOT modify code.\n\nWork request: ',
46
+ review:
47
+ 'Review the code changes for the following work request. Check for correctness, style, and adherence to the plan. Do NOT modify code.\n\nWork request: ',
48
+ pr: 'Create a pull request for the following work request. Summarize the changes and ensure the commit history is clean.\n\nWork request: ',
49
+ };
50
+
51
+ function _buildStagePrompt(stage, rawPrompt) {
52
+ const prefix = STAGE_PROMPT_PREFIX[stage];
53
+ return prefix ? prefix + rawPrompt : rawPrompt;
54
+ }
55
+
56
+ /**
57
+ * @param {{
58
+ * watcherSets: Map<string, import('./watcher-set.js').WatcherSet>,
59
+ * defaultWs: import('./watcher-set.js').WatcherSet,
60
+ * prefsPath: string,
61
+ * webhookInbox: object,
62
+ * clientManager: { ensureSubs: Function, getSubs: Function, setProtocol: Function },
63
+ * broadcaster: { broadcast: Function, broadcastToSubscribers: Function },
64
+ * }} deps
65
+ */
66
+ export function createMessageRouter({
67
+ watcherSets,
68
+ getDefaultWs,
69
+ prefsPath,
70
+ webhookInbox,
71
+ clientManager,
72
+ broadcaster,
73
+ }) {
74
+ /**
75
+ * Resolve the target project's WatcherSet for a given client + payload.
76
+ * Priority: payload.projectId > subs.projectId > defaultWs
77
+ */
78
+ function resolveProject(ws, payload) {
79
+ const projectId =
80
+ payload?.projectId || clientManager.getSubs(ws)?.projectId || null;
81
+ if (projectId && watcherSets.has(projectId)) {
82
+ const wset = watcherSets.get(projectId);
83
+ return {
84
+ wset,
85
+ worcaDir: wset.worcaDir,
86
+ settingsPath: wset.settingsPath,
87
+ projectRoot: wset.projectRoot,
88
+ };
89
+ }
90
+ const dws = getDefaultWs();
91
+ if (!dws) return null;
92
+ return {
93
+ wset: dws,
94
+ worcaDir: dws.worcaDir,
95
+ settingsPath: dws.settingsPath,
96
+ projectRoot: dws.projectRoot,
97
+ };
98
+ }
99
+
100
+ async function handleMessage(ws, data) {
101
+ let json;
102
+ try {
103
+ json = JSON.parse(data.toString());
104
+ } catch {
105
+ ws.send(
106
+ JSON.stringify({
107
+ id: 'unknown',
108
+ ok: false,
109
+ type: 'bad-json',
110
+ error: { code: 'bad_json', message: 'Invalid JSON' },
111
+ }),
112
+ );
113
+ return;
114
+ }
115
+
116
+ // hello-ack — protocol handshake response (not a standard request envelope)
117
+ if (json.type === 'hello-ack') {
118
+ const protocol = json.payload?.protocol || 1;
119
+ const projectId = json.payload?.projectId || null;
120
+ clientManager.setProtocol(ws, protocol, projectId);
121
+ // Clear hello timeout if set
122
+ if (ws._helloTimeout) {
123
+ clearTimeout(ws._helloTimeout);
124
+ ws._helloTimeout = null;
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (!isRequest(json)) {
130
+ ws.send(
131
+ JSON.stringify({
132
+ id: 'unknown',
133
+ ok: false,
134
+ type: 'bad-request',
135
+ error: { code: 'bad_request', message: 'Invalid request envelope' },
136
+ }),
137
+ );
138
+ return;
139
+ }
140
+
141
+ const req = json;
142
+
143
+ // list-runs
144
+ if (req.type === 'list-runs') {
145
+ const proj = resolveProject(ws, req.payload);
146
+ const runs = discoverRuns(proj.worcaDir);
147
+ const settings = readSettings(proj.settingsPath);
148
+ ws.send(JSON.stringify(makeOk(req, { runs, settings })));
149
+ return;
150
+ }
151
+
152
+ // get-agent-prompt
153
+ if (req.type === 'get-agent-prompt') {
154
+ const { runId, stage } = req.payload || {};
155
+ if (!runId || !stage) {
156
+ ws.send(
157
+ JSON.stringify(
158
+ makeError(
159
+ req,
160
+ 'bad_request',
161
+ 'payload.runId and payload.stage required',
162
+ ),
163
+ ),
164
+ );
165
+ return;
166
+ }
167
+ const proj = resolveProject(ws, req.payload);
168
+ const runs = discoverRuns(proj.worcaDir);
169
+ const run = runs.find((r) => r.id === runId);
170
+ if (!run) {
171
+ ws.send(
172
+ JSON.stringify(makeError(req, 'NOT_FOUND', `Run ${runId} not found`)),
173
+ );
174
+ return;
175
+ }
176
+ const agentName = run.stages?.[stage]?.agent || stage;
177
+
178
+ const iterations = run.stages?.[stage]?.iterations || [];
179
+ const iterationPrompts = iterations.map((iter, idx) => {
180
+ const prompt = iter.prompt || null;
181
+ return { iteration: iter.number ?? idx, prompt };
182
+ });
183
+
184
+ const storedPrompt = run.stages?.[stage]?.prompt;
185
+ let fallbackPrompt;
186
+ let promptSource;
187
+ if (storedPrompt) {
188
+ fallbackPrompt = storedPrompt;
189
+ promptSource = 'actual';
190
+ } else {
191
+ const rawPrompt =
192
+ run.work_request?.description || run.work_request?.title || '';
193
+ fallbackPrompt = _buildStagePrompt(stage, rawPrompt);
194
+ promptSource = 'reconstructed';
195
+ }
196
+
197
+ const hasIterationPrompts = iterationPrompts.some(
198
+ (ip) => ip.prompt != null,
199
+ );
200
+ if (!hasIterationPrompts) {
201
+ for (const ip of iterationPrompts) {
202
+ ip.prompt = fallbackPrompt;
203
+ }
204
+ }
205
+
206
+ let agentInstructions = null;
207
+ const candidates = [
208
+ join(
209
+ proj.worcaDir,
210
+ 'runs',
211
+ run.run_id || runId,
212
+ 'agents',
213
+ `${agentName}.md`,
214
+ ),
215
+ join(
216
+ proj.worcaDir,
217
+ 'results',
218
+ run.run_id || runId,
219
+ 'agents',
220
+ `${agentName}.md`,
221
+ ),
222
+ ];
223
+ for (const p of candidates) {
224
+ if (existsSync(p)) {
225
+ try {
226
+ agentInstructions = readFileSync(p, 'utf8');
227
+ } catch {
228
+ /* ignore */
229
+ }
230
+ break;
231
+ }
232
+ }
233
+ ws.send(
234
+ JSON.stringify(
235
+ makeOk(req, {
236
+ agentInstructions,
237
+ userPrompt: fallbackPrompt,
238
+ iterationPrompts,
239
+ promptSource,
240
+ agent: agentName,
241
+ }),
242
+ ),
243
+ );
244
+ return;
245
+ }
246
+
247
+ // subscribe-run
248
+ if (req.type === 'subscribe-run') {
249
+ const { runId } = req.payload || {};
250
+ if (typeof runId !== 'string') {
251
+ ws.send(
252
+ JSON.stringify(
253
+ makeError(req, 'bad_request', 'payload.runId required'),
254
+ ),
255
+ );
256
+ return;
257
+ }
258
+ const proj = resolveProject(ws, req.payload);
259
+ if (!proj) {
260
+ ws.send(
261
+ JSON.stringify(makeError(req, 'no_project', 'No project available')),
262
+ );
263
+ return;
264
+ }
265
+ const s = clientManager.ensureSubs(ws);
266
+ s.runId = runId;
267
+ const runs = discoverRuns(proj.worcaDir);
268
+ const run = runs.find((r) => r.id === runId);
269
+ if (run) {
270
+ if (
271
+ run.pipeline_status !== undefined &&
272
+ proj.wset.statusWatcher?.lastPipelineStatus &&
273
+ !proj.wset.statusWatcher.lastPipelineStatus.has(runId)
274
+ ) {
275
+ proj.wset.statusWatcher.lastPipelineStatus.set(
276
+ runId,
277
+ run.pipeline_status,
278
+ );
279
+ }
280
+ ws.send(JSON.stringify(makeOk(req, run)));
281
+ } else {
282
+ ws.send(
283
+ JSON.stringify(makeError(req, 'NOT_FOUND', `Run ${runId} not found`)),
284
+ );
285
+ }
286
+ return;
287
+ }
288
+
289
+ // unsubscribe-run
290
+ if (req.type === 'unsubscribe-run') {
291
+ const s = clientManager.ensureSubs(ws);
292
+ s.runId = null;
293
+ ws.send(JSON.stringify(makeOk(req, { unsubscribed: true })));
294
+ return;
295
+ }
296
+
297
+ // subscribe-log
298
+ if (req.type === 'subscribe-log') {
299
+ const { stage, runId, iteration } = req.payload || {};
300
+ const proj = resolveProject(ws, req.payload);
301
+ const s = clientManager.ensureSubs(ws);
302
+ s.logStage = stage || '*';
303
+ s.logRunId = runId || null;
304
+ ws.send(JSON.stringify(makeOk(req, { subscribed: true })));
305
+
306
+ if (!proj.wset.logWatcher) return;
307
+
308
+ const archivedRunDir = runId
309
+ ? join(proj.worcaDir, 'results', runId)
310
+ : null;
311
+ const archivedLogDir = archivedRunDir
312
+ ? join(archivedRunDir, 'logs')
313
+ : null;
314
+ const isArchived = archivedLogDir && existsSync(archivedLogDir);
315
+
316
+ if (isArchived) {
317
+ proj.wset.logWatcher.sendArchivedLogs(
318
+ ws,
319
+ archivedLogDir,
320
+ stage,
321
+ iteration,
322
+ );
323
+ } else {
324
+ const logsBase = proj.wset.logWatcher.resolveLogsBaseDir();
325
+ if (stage) {
326
+ if (iteration != null) {
327
+ const logPath = resolveIterationLogPath(logsBase, stage, iteration);
328
+ const lines = readLastLines(logPath, 200);
329
+ if (lines.length > 0) {
330
+ ws.send(
331
+ JSON.stringify({
332
+ id: `evt-${Date.now()}`,
333
+ ok: true,
334
+ type: 'log-bulk',
335
+ payload: { stage, iteration, lines },
336
+ }),
337
+ );
338
+ }
339
+ } else {
340
+ const stageDir = resolveLogPath(logsBase, stage);
341
+ if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
342
+ const iters = listIterationFiles(logsBase, stage);
343
+ for (const { iteration: iterNum, path } of iters) {
344
+ const lines = readLastLines(path, 200);
345
+ if (lines.length > 0) {
346
+ ws.send(
347
+ JSON.stringify({
348
+ id: `evt-${Date.now()}-iter${iterNum}`,
349
+ ok: true,
350
+ type: 'log-bulk',
351
+ payload: { stage, iteration: iterNum, lines },
352
+ }),
353
+ );
354
+ }
355
+ }
356
+ } else {
357
+ const logPath = join(logsBase, 'logs', `${stage}.log`);
358
+ const lines = readLastLines(logPath, 200);
359
+ if (lines.length > 0) {
360
+ ws.send(
361
+ JSON.stringify({
362
+ id: `evt-${Date.now()}`,
363
+ ok: true,
364
+ type: 'log-bulk',
365
+ payload: { stage, lines },
366
+ }),
367
+ );
368
+ }
369
+ }
370
+ }
371
+ proj.wset.logWatcher.watchLogFile(stage);
372
+ } else {
373
+ const logFiles = listLogFiles(logsBase);
374
+ for (const { stage: s2, iteration: iterNum, path } of logFiles) {
375
+ const lines = readLastLines(path, 200);
376
+ if (lines.length > 0) {
377
+ ws.send(
378
+ JSON.stringify({
379
+ id: `evt-${Date.now()}-${s2}-${iterNum || 0}`,
380
+ ok: true,
381
+ type: 'log-bulk',
382
+ payload: {
383
+ stage: s2,
384
+ iteration: iterNum ?? undefined,
385
+ lines,
386
+ },
387
+ }),
388
+ );
389
+ }
390
+ }
391
+ proj.wset.logWatcher.watchAllLogFiles();
392
+ }
393
+ }
394
+ return;
395
+ }
396
+
397
+ // unsubscribe-log
398
+ if (req.type === 'unsubscribe-log') {
399
+ const s = clientManager.ensureSubs(ws);
400
+ s.logStage = null;
401
+ s.logRunId = null;
402
+ ws.send(JSON.stringify(makeOk(req, { unsubscribed: true })));
403
+ return;
404
+ }
405
+
406
+ // get-preferences (user-scoped, not project-scoped)
407
+ if (req.type === 'get-preferences') {
408
+ const prefs = readPreferences(prefsPath);
409
+ ws.send(JSON.stringify(makeOk(req, prefs)));
410
+ return;
411
+ }
412
+
413
+ // set-preferences (user-scoped, not project-scoped)
414
+ if (req.type === 'set-preferences') {
415
+ const prefs = req.payload || {};
416
+ const current = readPreferences(prefsPath);
417
+ const merged = { ...current, ...prefs };
418
+ writePreferences(merged, prefsPath);
419
+ broadcaster.broadcast('preferences', merged);
420
+ ws.send(JSON.stringify(makeOk(req, merged)));
421
+ return;
422
+ }
423
+
424
+ // pause-run
425
+ if (req.type === 'pause-run') {
426
+ const { runId } = req.payload || {};
427
+ if (typeof runId !== 'string') {
428
+ ws.send(
429
+ JSON.stringify(
430
+ makeError(req, 'bad_request', 'payload.runId required'),
431
+ ),
432
+ );
433
+ return;
434
+ }
435
+ const proj = resolveProject(ws, req.payload);
436
+ try {
437
+ const result = pmPausePipeline(proj.worcaDir, runId);
438
+ ws.send(JSON.stringify(makeOk(req, result)));
439
+ } catch (e) {
440
+ ws.send(JSON.stringify(makeError(req, e.code || 'error', e.message)));
441
+ }
442
+ return;
443
+ }
444
+
445
+ // stop-run
446
+ if (req.type === 'stop-run') {
447
+ const proj = resolveProject(ws, req.payload);
448
+ if (!proj) {
449
+ ws.send(
450
+ JSON.stringify(makeError(req, 'no_project', 'No project available')),
451
+ );
452
+ return;
453
+ }
454
+ try {
455
+ const result = pmStopPipeline(proj.worcaDir);
456
+ ws.send(JSON.stringify(makeOk(req, result)));
457
+ let checks = 0;
458
+ const maxChecks = 20;
459
+ const pollInterval = setInterval(() => {
460
+ checks++;
461
+ let alive = false;
462
+ try {
463
+ process.kill(result.pid, 0);
464
+ alive = true;
465
+ } catch {
466
+ /* dead */
467
+ }
468
+ if (!alive || checks >= maxChecks) {
469
+ clearInterval(pollInterval);
470
+ reconcileStatus(proj.worcaDir);
471
+ proj.wset.statusWatcher?.scheduleRefresh();
472
+ }
473
+ }, 500);
474
+ pollInterval.unref?.();
475
+ } catch (e) {
476
+ proj.wset.statusWatcher?.scheduleRefresh();
477
+ ws.send(
478
+ JSON.stringify(makeError(req, e.code || 'not_running', e.message)),
479
+ );
480
+ }
481
+ return;
482
+ }
483
+
484
+ // resume-run
485
+ if (req.type === 'resume-run') {
486
+ const { runId } = req.payload || {};
487
+ const proj = resolveProject(ws, req.payload);
488
+ try {
489
+ const result = await pmStartPipeline(proj.worcaDir, {
490
+ resume: true,
491
+ runId,
492
+ projectRoot: proj.projectRoot,
493
+ });
494
+ ws.send(
495
+ JSON.stringify(makeOk(req, { resumed: true, pid: result.pid })),
496
+ );
497
+ // Give the pipeline process time to write its first status update,
498
+ // then force a refresh so the UI picks up the running state.
499
+ setTimeout(() => {
500
+ if (proj.wset.statusWatcher) {
501
+ proj.wset.statusWatcher.scheduleRefresh();
502
+ }
503
+ }, 500);
504
+ } catch (e) {
505
+ ws.send(JSON.stringify(makeError(req, e.code || 'error', e.message)));
506
+ }
507
+ return;
508
+ }
509
+
510
+ // list-beads-issues
511
+ if (req.type === 'list-beads-issues') {
512
+ const proj = resolveProject(ws, req.payload);
513
+ if (!proj.wset.beadsWatcher) {
514
+ ws.send(
515
+ JSON.stringify(
516
+ makeOk(req, { issues: [], dbExists: false, dbPath: null }),
517
+ ),
518
+ );
519
+ return;
520
+ }
521
+ const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
522
+ if (!beadsDbExists(beadsDbPath)) {
523
+ ws.send(
524
+ JSON.stringify(
525
+ makeOk(req, {
526
+ issues: [],
527
+ dbExists: false,
528
+ dbPath: beadsDbPath,
529
+ }),
530
+ ),
531
+ );
532
+ return;
533
+ }
534
+ const issues = listIssues(beadsDbPath);
535
+ ws.send(
536
+ JSON.stringify(
537
+ makeOk(req, { issues, dbExists: true, dbPath: beadsDbPath }),
538
+ ),
539
+ );
540
+ return;
541
+ }
542
+
543
+ // list-beads-unlinked
544
+ if (req.type === 'list-beads-unlinked') {
545
+ const proj = resolveProject(ws, req.payload);
546
+ if (!proj.wset.beadsWatcher) {
547
+ ws.send(JSON.stringify(makeOk(req, { issues: [], dbExists: false })));
548
+ return;
549
+ }
550
+ const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
551
+ if (!beadsDbExists(beadsDbPath)) {
552
+ ws.send(JSON.stringify(makeOk(req, { issues: [], dbExists: false })));
553
+ return;
554
+ }
555
+ const issues = listUnlinkedIssues(beadsDbPath);
556
+ ws.send(JSON.stringify(makeOk(req, { issues, dbExists: true })));
557
+ return;
558
+ }
559
+
560
+ // list-beads-refs
561
+ if (req.type === 'list-beads-refs') {
562
+ const proj = resolveProject(ws, req.payload);
563
+ if (!proj.wset.beadsWatcher) {
564
+ ws.send(JSON.stringify(makeOk(req, { refs: [] })));
565
+ return;
566
+ }
567
+ const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
568
+ if (!beadsDbExists(beadsDbPath)) {
569
+ ws.send(JSON.stringify(makeOk(req, { refs: [] })));
570
+ return;
571
+ }
572
+ const refs = listDistinctRunLabels(beadsDbPath);
573
+ ws.send(JSON.stringify(makeOk(req, { refs })));
574
+ return;
575
+ }
576
+
577
+ // list-beads-counts
578
+ if (req.type === 'list-beads-counts') {
579
+ const proj = resolveProject(ws, req.payload);
580
+ if (!proj.wset.beadsWatcher) {
581
+ ws.send(JSON.stringify(makeOk(req, { counts: {} })));
582
+ return;
583
+ }
584
+ const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
585
+ if (!beadsDbExists(beadsDbPath)) {
586
+ ws.send(JSON.stringify(makeOk(req, { counts: {} })));
587
+ return;
588
+ }
589
+ const counts = countIssuesByRunLabel(beadsDbPath);
590
+ ws.send(JSON.stringify(makeOk(req, { counts })));
591
+ return;
592
+ }
593
+
594
+ // list-beads-by-run
595
+ if (req.type === 'list-beads-by-run') {
596
+ const { runId } = req.payload || {};
597
+ if (!runId) {
598
+ ws.send(
599
+ JSON.stringify(
600
+ makeError(req, 'bad_request', 'payload.runId required'),
601
+ ),
602
+ );
603
+ return;
604
+ }
605
+ const proj = resolveProject(ws, req.payload);
606
+ if (!proj.wset.beadsWatcher) {
607
+ ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
608
+ return;
609
+ }
610
+ const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
611
+ if (!beadsDbExists(beadsDbPath)) {
612
+ ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
613
+ return;
614
+ }
615
+ const issues = listIssuesByLabel(beadsDbPath, `run:${runId}`);
616
+ ws.send(JSON.stringify(makeOk(req, { issues, runId })));
617
+ return;
618
+ }
619
+
620
+ // start-beads-issue
621
+ if (req.type === 'start-beads-issue') {
622
+ const { issueId } = req.payload || {};
623
+ if (!Number.isInteger(issueId) || issueId <= 0) {
624
+ ws.send(
625
+ JSON.stringify(
626
+ makeError(
627
+ req,
628
+ 'bad_request',
629
+ 'payload.issueId (positive integer) required',
630
+ ),
631
+ ),
632
+ );
633
+ return;
634
+ }
635
+ const proj = resolveProject(ws, req.payload);
636
+ if (!proj.wset.beadsWatcher) {
637
+ ws.send(
638
+ JSON.stringify(
639
+ makeError(
640
+ req,
641
+ 'not_available',
642
+ 'Beads not available in polling mode',
643
+ ),
644
+ ),
645
+ );
646
+ return;
647
+ }
648
+ const beadsDbPath = proj.wset.beadsWatcher.getBeadsDbPath();
649
+ const issue = getIssue(beadsDbPath, issueId);
650
+ if (!issue) {
651
+ ws.send(
652
+ JSON.stringify(
653
+ makeError(req, 'not_found', `Issue ${issueId} not found`),
654
+ ),
655
+ );
656
+ return;
657
+ }
658
+ if (issue.status !== 'open') {
659
+ ws.send(
660
+ JSON.stringify(
661
+ makeError(
662
+ req,
663
+ 'not_ready',
664
+ `Issue ${issueId} is not open (status: ${issue.status})`,
665
+ ),
666
+ ),
667
+ );
668
+ return;
669
+ }
670
+ if (issue.blocked_by.length > 0) {
671
+ ws.send(
672
+ JSON.stringify(
673
+ makeError(
674
+ req,
675
+ 'blocked',
676
+ `Issue ${issueId} is blocked by: ${issue.blocked_by.join(', ')}`,
677
+ ),
678
+ ),
679
+ );
680
+ return;
681
+ }
682
+ try {
683
+ const prompt =
684
+ `[Beads #${issue.id}] ${issue.title}\n\n${(issue.body || '').trim()}`.trim();
685
+ const result = await pmStartPipeline(proj.worcaDir, {
686
+ inputType: 'prompt',
687
+ inputValue: prompt,
688
+ msize: 1,
689
+ mloops: 1,
690
+ projectRoot: proj.projectRoot,
691
+ });
692
+ broadcaster.broadcast('run-started', { pid: result.pid });
693
+ ws.send(JSON.stringify(makeOk(req, { pid: result.pid, issueId })));
694
+ } catch (e) {
695
+ ws.send(JSON.stringify(makeError(req, 'start_failed', e.message)));
696
+ }
697
+ return;
698
+ }
699
+
700
+ // get-events
701
+ if (req.type === 'get-events') {
702
+ const { runId, since_event_id, event_types, limit } = req.payload || {};
703
+ if (typeof runId !== 'string') {
704
+ ws.send(
705
+ JSON.stringify(
706
+ makeError(req, 'bad_request', 'payload.runId required'),
707
+ ),
708
+ );
709
+ return;
710
+ }
711
+ const proj = resolveProject(ws, req.payload);
712
+ if (!proj.wset.eventWatcher) {
713
+ ws.send(JSON.stringify(makeOk(req, { events: [] })));
714
+ return;
715
+ }
716
+ const events = proj.wset.eventWatcher.readEventsFromFile(runId, {
717
+ since_event_id,
718
+ event_types,
719
+ limit,
720
+ });
721
+ ws.send(JSON.stringify(makeOk(req, { events })));
722
+ return;
723
+ }
724
+
725
+ // subscribe-events
726
+ if (req.type === 'subscribe-events') {
727
+ const { runId } = req.payload || {};
728
+ if (typeof runId !== 'string') {
729
+ ws.send(
730
+ JSON.stringify(
731
+ makeError(req, 'bad_request', 'payload.runId required'),
732
+ ),
733
+ );
734
+ return;
735
+ }
736
+ const proj = resolveProject(ws, req.payload);
737
+ const s = clientManager.ensureSubs(ws);
738
+ s.eventsRunId = runId;
739
+ if (proj.wset.eventWatcher) {
740
+ proj.wset.eventWatcher.subscribeEvents(runId);
741
+ }
742
+ ws.send(JSON.stringify(makeOk(req, { subscribed: true })));
743
+ return;
744
+ }
745
+
746
+ // unsubscribe-events
747
+ if (req.type === 'unsubscribe-events') {
748
+ const proj = resolveProject(ws, req.payload);
749
+ const s = clientManager.ensureSubs(ws);
750
+ const prevRunId = s.eventsRunId;
751
+ s.eventsRunId = null;
752
+ if (prevRunId && proj.wset.eventWatcher) {
753
+ proj.wset.eventWatcher.maybeCloseEventWatcher(prevRunId);
754
+ }
755
+ ws.send(JSON.stringify(makeOk(req, { unsubscribed: true })));
756
+ return;
757
+ }
758
+
759
+ // get-webhook-inbox
760
+ if (req.type === 'get-webhook-inbox') {
761
+ if (!webhookInbox) {
762
+ ws.send(
763
+ JSON.stringify(
764
+ makeOk(req, { events: [], controlAction: 'continue' }),
765
+ ),
766
+ );
767
+ return;
768
+ }
769
+ const subs = clientManager.getSubs(ws);
770
+ const projectId = subs?.projectId || null;
771
+ ws.send(
772
+ JSON.stringify(
773
+ makeOk(req, {
774
+ events: webhookInbox.list(undefined, projectId),
775
+ controlAction: webhookInbox.getControlAction(),
776
+ }),
777
+ ),
778
+ );
779
+ return;
780
+ }
781
+
782
+ // set-webhook-control
783
+ if (req.type === 'set-webhook-control') {
784
+ const { action } = req.payload || {};
785
+ if (!webhookInbox || !['continue', 'pause', 'abort'].includes(action)) {
786
+ ws.send(
787
+ JSON.stringify(
788
+ makeError(
789
+ req,
790
+ 'bad_request',
791
+ 'action must be "continue", "pause", or "abort"',
792
+ ),
793
+ ),
794
+ );
795
+ return;
796
+ }
797
+ webhookInbox.setControlAction(action);
798
+ broadcaster.broadcast('webhook-control-changed', { action });
799
+ ws.send(JSON.stringify(makeOk(req, { action })));
800
+ return;
801
+ }
802
+
803
+ // clear-webhook-inbox
804
+ if (req.type === 'clear-webhook-inbox') {
805
+ if (webhookInbox) webhookInbox.clear();
806
+ broadcaster.broadcast('webhook-inbox-cleared', {});
807
+ ws.send(JSON.stringify(makeOk(req, { cleared: true })));
808
+ return;
809
+ }
810
+
811
+ // list-pipelines — return parallel pipeline entries for a project
812
+ if (req.type === 'list-pipelines') {
813
+ const proj = resolveProject(ws, req.payload);
814
+ const multiWatcher = proj.wset.getMultiWatcher?.();
815
+ const pipelines = multiWatcher ? multiWatcher.listPipelines() : [];
816
+ ws.send(JSON.stringify(makeOk(req, { pipelines })));
817
+ return;
818
+ }
819
+
820
+ // subscribe-pipeline — subscribe to a specific parallel pipeline's events
821
+ if (req.type === 'subscribe-pipeline') {
822
+ const { runId } = req.payload || {};
823
+ if (typeof runId !== 'string') {
824
+ ws.send(
825
+ JSON.stringify(
826
+ makeError(req, 'bad_request', 'payload.runId required'),
827
+ ),
828
+ );
829
+ return;
830
+ }
831
+ const proj = resolveProject(ws, req.payload);
832
+ const multiWatcher = proj.wset.getMultiWatcher?.();
833
+ if (multiWatcher) {
834
+ multiWatcher.promotePipeline(runId);
835
+ }
836
+ const s = clientManager.ensureSubs(ws);
837
+ s.pipelineRunId = runId;
838
+ s.pipelineProjectId = proj.wset.projectId;
839
+ ws.send(JSON.stringify(makeOk(req, { subscribed: true })));
840
+ return;
841
+ }
842
+
843
+ // unsubscribe-pipeline — clear pipeline subscription and demote watcher
844
+ if (req.type === 'unsubscribe-pipeline') {
845
+ const s = clientManager.ensureSubs(ws);
846
+ const prevRunId = s.pipelineRunId;
847
+ const prevProjectId = s.pipelineProjectId;
848
+ s.pipelineRunId = null;
849
+ s.pipelineProjectId = null;
850
+ if (prevRunId && prevProjectId) {
851
+ const wset = watcherSets.get(prevProjectId) || getDefaultWs();
852
+ const multiWatcher = wset?.getMultiWatcher?.();
853
+ if (multiWatcher) {
854
+ multiWatcher.demotePipeline(prevRunId);
855
+ }
856
+ }
857
+ ws.send(JSON.stringify(makeOk(req, { unsubscribed: true })));
858
+ return;
859
+ }
860
+
861
+ // Unknown type
862
+ ws.send(
863
+ JSON.stringify(
864
+ makeError(req, 'unknown_type', `Unknown message type: ${req.type}`),
865
+ ),
866
+ );
867
+ }
868
+
869
+ return { handleMessage };
870
+ }