@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.
- package/app/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
|
@@ -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
|
+
}
|