@femtomc/mu-server 26.2.41 → 26.2.43
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/README.md +2 -2
- package/dist/activity_supervisor.d.ts +81 -0
- package/dist/activity_supervisor.js +306 -0
- package/dist/control_plane.d.ts +30 -0
- package/dist/control_plane.js +282 -11
- package/dist/heartbeat_programs.d.ts +93 -0
- package/dist/heartbeat_programs.js +415 -0
- package/dist/heartbeat_scheduler.d.ts +38 -0
- package/dist/heartbeat_scheduler.js +238 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/run_supervisor.d.ts +101 -0
- package/dist/run_supervisor.js +480 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +628 -3
- package/package.json +6 -6
package/dist/server.js
CHANGED
|
@@ -6,7 +6,10 @@ import { eventRoutes } from "./api/events.js";
|
|
|
6
6
|
import { forumRoutes } from "./api/forum.js";
|
|
7
7
|
import { issueRoutes } from "./api/issues.js";
|
|
8
8
|
import { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
|
|
9
|
+
import { ControlPlaneActivitySupervisor, } from "./activity_supervisor.js";
|
|
9
10
|
import { bootstrapControlPlane } from "./control_plane.js";
|
|
11
|
+
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
12
|
+
import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
10
13
|
const MIME_TYPES = {
|
|
11
14
|
".html": "text/html; charset=utf-8",
|
|
12
15
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -52,11 +55,30 @@ export function createServer(options = {}) {
|
|
|
52
55
|
const readConfig = options.configReader ?? readMuConfigFile;
|
|
53
56
|
const writeConfig = options.configWriter ?? writeMuConfigFile;
|
|
54
57
|
const fallbackConfig = options.config ?? DEFAULT_MU_CONFIG;
|
|
58
|
+
const heartbeatScheduler = options.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
|
|
59
|
+
const activitySupervisor = options.activitySupervisor ??
|
|
60
|
+
new ControlPlaneActivitySupervisor({
|
|
61
|
+
heartbeatScheduler,
|
|
62
|
+
onEvent: async (event) => {
|
|
63
|
+
await context.eventLog.emit(`activity.${event.kind}`, {
|
|
64
|
+
source: "mu-server.activity-supervisor",
|
|
65
|
+
payload: {
|
|
66
|
+
seq: event.seq,
|
|
67
|
+
message: event.message,
|
|
68
|
+
activity_id: event.activity.activity_id,
|
|
69
|
+
kind: event.activity.kind,
|
|
70
|
+
status: event.activity.status,
|
|
71
|
+
heartbeat_count: event.activity.heartbeat_count,
|
|
72
|
+
last_progress: event.activity.last_progress,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
});
|
|
55
77
|
let controlPlaneCurrent = options.controlPlane ?? null;
|
|
56
78
|
let reloadInFlight = null;
|
|
57
79
|
const controlPlaneReloader = options.controlPlaneReloader ??
|
|
58
80
|
(async ({ repoRoot, config }) => {
|
|
59
|
-
return await bootstrapControlPlane({ repoRoot, config });
|
|
81
|
+
return await bootstrapControlPlane({ repoRoot, config, heartbeatScheduler });
|
|
60
82
|
});
|
|
61
83
|
const controlPlaneProxy = {
|
|
62
84
|
get activeAdapters() {
|
|
@@ -68,12 +90,88 @@ export function createServer(options = {}) {
|
|
|
68
90
|
return null;
|
|
69
91
|
return await handle.handleWebhook(path, req);
|
|
70
92
|
},
|
|
93
|
+
async listRuns(opts) {
|
|
94
|
+
const handle = controlPlaneCurrent;
|
|
95
|
+
if (!handle?.listRuns)
|
|
96
|
+
return [];
|
|
97
|
+
return await handle.listRuns(opts);
|
|
98
|
+
},
|
|
99
|
+
async getRun(idOrRoot) {
|
|
100
|
+
const handle = controlPlaneCurrent;
|
|
101
|
+
if (!handle?.getRun)
|
|
102
|
+
return null;
|
|
103
|
+
return await handle.getRun(idOrRoot);
|
|
104
|
+
},
|
|
105
|
+
async startRun(opts) {
|
|
106
|
+
const handle = controlPlaneCurrent;
|
|
107
|
+
if (!handle?.startRun) {
|
|
108
|
+
throw new Error("run_supervisor_unavailable");
|
|
109
|
+
}
|
|
110
|
+
return await handle.startRun(opts);
|
|
111
|
+
},
|
|
112
|
+
async resumeRun(opts) {
|
|
113
|
+
const handle = controlPlaneCurrent;
|
|
114
|
+
if (!handle?.resumeRun) {
|
|
115
|
+
throw new Error("run_supervisor_unavailable");
|
|
116
|
+
}
|
|
117
|
+
return await handle.resumeRun(opts);
|
|
118
|
+
},
|
|
119
|
+
async interruptRun(opts) {
|
|
120
|
+
const handle = controlPlaneCurrent;
|
|
121
|
+
if (!handle?.interruptRun) {
|
|
122
|
+
return { ok: false, reason: "not_found", run: null };
|
|
123
|
+
}
|
|
124
|
+
return await handle.interruptRun(opts);
|
|
125
|
+
},
|
|
126
|
+
async heartbeatRun(opts) {
|
|
127
|
+
const handle = controlPlaneCurrent;
|
|
128
|
+
if (!handle?.heartbeatRun) {
|
|
129
|
+
return { ok: false, reason: "not_found", run: null };
|
|
130
|
+
}
|
|
131
|
+
return await handle.heartbeatRun(opts);
|
|
132
|
+
},
|
|
133
|
+
async traceRun(opts) {
|
|
134
|
+
const handle = controlPlaneCurrent;
|
|
135
|
+
if (!handle?.traceRun)
|
|
136
|
+
return null;
|
|
137
|
+
return await handle.traceRun(opts);
|
|
138
|
+
},
|
|
71
139
|
async stop() {
|
|
72
140
|
const handle = controlPlaneCurrent;
|
|
73
141
|
controlPlaneCurrent = null;
|
|
74
142
|
await handle?.stop();
|
|
75
143
|
},
|
|
76
144
|
};
|
|
145
|
+
const heartbeatPrograms = new HeartbeatProgramRegistry({
|
|
146
|
+
repoRoot,
|
|
147
|
+
heartbeatScheduler,
|
|
148
|
+
runHeartbeat: async (opts) => {
|
|
149
|
+
const result = await controlPlaneProxy.heartbeatRun?.({
|
|
150
|
+
jobId: opts.jobId ?? null,
|
|
151
|
+
rootIssueId: opts.rootIssueId ?? null,
|
|
152
|
+
reason: opts.reason ?? null,
|
|
153
|
+
});
|
|
154
|
+
return result ?? { ok: false, reason: "not_found" };
|
|
155
|
+
},
|
|
156
|
+
activityHeartbeat: async (opts) => {
|
|
157
|
+
return activitySupervisor.heartbeat({
|
|
158
|
+
activityId: opts.activityId ?? null,
|
|
159
|
+
reason: opts.reason ?? null,
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
onTickEvent: async (event) => {
|
|
163
|
+
await context.eventLog.emit("heartbeat_program.tick", {
|
|
164
|
+
source: "mu-server.heartbeat-programs",
|
|
165
|
+
payload: {
|
|
166
|
+
program_id: event.program_id,
|
|
167
|
+
status: event.status,
|
|
168
|
+
reason: event.reason,
|
|
169
|
+
message: event.message,
|
|
170
|
+
program: event.program,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
});
|
|
77
175
|
const loadConfigFromDisk = async () => {
|
|
78
176
|
try {
|
|
79
177
|
return await readConfig(context.repoRoot);
|
|
@@ -212,6 +310,526 @@ export function createServer(options = {}) {
|
|
|
212
310
|
control_plane: controlPlane,
|
|
213
311
|
}, { headers });
|
|
214
312
|
}
|
|
313
|
+
if (path === "/api/runs") {
|
|
314
|
+
if (request.method !== "GET") {
|
|
315
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
316
|
+
}
|
|
317
|
+
const status = url.searchParams.get("status")?.trim() || undefined;
|
|
318
|
+
const limitRaw = url.searchParams.get("limit");
|
|
319
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10))) : undefined;
|
|
320
|
+
const runs = await controlPlaneProxy.listRuns?.({ status, limit });
|
|
321
|
+
return Response.json({ count: runs?.length ?? 0, runs: runs ?? [] }, { headers });
|
|
322
|
+
}
|
|
323
|
+
if (path === "/api/runs/start") {
|
|
324
|
+
if (request.method !== "POST") {
|
|
325
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
326
|
+
}
|
|
327
|
+
let body;
|
|
328
|
+
try {
|
|
329
|
+
body = (await request.json());
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
333
|
+
}
|
|
334
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
335
|
+
if (prompt.length === 0) {
|
|
336
|
+
return Response.json({ error: "prompt is required" }, { status: 400, headers });
|
|
337
|
+
}
|
|
338
|
+
const maxSteps = typeof body.max_steps === "number" && Number.isFinite(body.max_steps)
|
|
339
|
+
? Math.max(1, Math.trunc(body.max_steps))
|
|
340
|
+
: undefined;
|
|
341
|
+
try {
|
|
342
|
+
const run = await controlPlaneProxy.startRun?.({ prompt, maxSteps });
|
|
343
|
+
if (!run) {
|
|
344
|
+
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
345
|
+
}
|
|
346
|
+
return Response.json({ ok: true, run }, { status: 201, headers });
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
return Response.json({ error: describeError(err) }, { status: 500, headers });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (path === "/api/runs/resume") {
|
|
353
|
+
if (request.method !== "POST") {
|
|
354
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
355
|
+
}
|
|
356
|
+
let body;
|
|
357
|
+
try {
|
|
358
|
+
body = (await request.json());
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
362
|
+
}
|
|
363
|
+
const rootIssueId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
|
|
364
|
+
if (rootIssueId.length === 0) {
|
|
365
|
+
return Response.json({ error: "root_issue_id is required" }, { status: 400, headers });
|
|
366
|
+
}
|
|
367
|
+
const maxSteps = typeof body.max_steps === "number" && Number.isFinite(body.max_steps)
|
|
368
|
+
? Math.max(1, Math.trunc(body.max_steps))
|
|
369
|
+
: undefined;
|
|
370
|
+
try {
|
|
371
|
+
const run = await controlPlaneProxy.resumeRun?.({ rootIssueId, maxSteps });
|
|
372
|
+
if (!run) {
|
|
373
|
+
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
374
|
+
}
|
|
375
|
+
return Response.json({ ok: true, run }, { status: 201, headers });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
return Response.json({ error: describeError(err) }, { status: 500, headers });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (path === "/api/runs/interrupt") {
|
|
382
|
+
if (request.method !== "POST") {
|
|
383
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
384
|
+
}
|
|
385
|
+
let body;
|
|
386
|
+
try {
|
|
387
|
+
body = (await request.json());
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
391
|
+
}
|
|
392
|
+
const rootIssueId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : null;
|
|
393
|
+
const jobId = typeof body.job_id === "string" ? body.job_id.trim() : null;
|
|
394
|
+
const result = await controlPlaneProxy.interruptRun?.({
|
|
395
|
+
rootIssueId,
|
|
396
|
+
jobId,
|
|
397
|
+
});
|
|
398
|
+
if (!result) {
|
|
399
|
+
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
400
|
+
}
|
|
401
|
+
return Response.json(result, { status: result.ok ? 200 : 404, headers });
|
|
402
|
+
}
|
|
403
|
+
if (path === "/api/runs/heartbeat") {
|
|
404
|
+
if (request.method !== "POST") {
|
|
405
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
406
|
+
}
|
|
407
|
+
let body;
|
|
408
|
+
try {
|
|
409
|
+
body = (await request.json());
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
413
|
+
}
|
|
414
|
+
const rootIssueId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : null;
|
|
415
|
+
const jobId = typeof body.job_id === "string" ? body.job_id.trim() : null;
|
|
416
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : null;
|
|
417
|
+
const result = await controlPlaneProxy.heartbeatRun?.({
|
|
418
|
+
rootIssueId,
|
|
419
|
+
jobId,
|
|
420
|
+
reason,
|
|
421
|
+
});
|
|
422
|
+
if (!result) {
|
|
423
|
+
return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
|
|
424
|
+
}
|
|
425
|
+
if (result.ok) {
|
|
426
|
+
return Response.json(result, { status: 200, headers });
|
|
427
|
+
}
|
|
428
|
+
if (result.reason === "missing_target") {
|
|
429
|
+
return Response.json(result, { status: 400, headers });
|
|
430
|
+
}
|
|
431
|
+
if (result.reason === "not_running") {
|
|
432
|
+
return Response.json(result, { status: 409, headers });
|
|
433
|
+
}
|
|
434
|
+
return Response.json(result, { status: 404, headers });
|
|
435
|
+
}
|
|
436
|
+
if (path.startsWith("/api/runs/")) {
|
|
437
|
+
const rest = path.slice("/api/runs/".length);
|
|
438
|
+
const [rawId, maybeSub] = rest.split("/");
|
|
439
|
+
const idOrRoot = decodeURIComponent(rawId ?? "").trim();
|
|
440
|
+
if (idOrRoot.length === 0) {
|
|
441
|
+
return Response.json({ error: "missing run id" }, { status: 400, headers });
|
|
442
|
+
}
|
|
443
|
+
if (maybeSub === "trace") {
|
|
444
|
+
if (request.method !== "GET") {
|
|
445
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
446
|
+
}
|
|
447
|
+
const limitRaw = url.searchParams.get("limit");
|
|
448
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw)
|
|
449
|
+
? Math.max(1, Math.min(2_000, Number.parseInt(limitRaw, 10)))
|
|
450
|
+
: undefined;
|
|
451
|
+
const trace = await controlPlaneProxy.traceRun?.({ idOrRoot, limit });
|
|
452
|
+
if (!trace) {
|
|
453
|
+
return Response.json({ error: "run trace not found" }, { status: 404, headers });
|
|
454
|
+
}
|
|
455
|
+
return Response.json(trace, { headers });
|
|
456
|
+
}
|
|
457
|
+
if (request.method !== "GET") {
|
|
458
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
459
|
+
}
|
|
460
|
+
const run = await controlPlaneProxy.getRun?.(idOrRoot);
|
|
461
|
+
if (!run) {
|
|
462
|
+
return Response.json({ error: "run not found" }, { status: 404, headers });
|
|
463
|
+
}
|
|
464
|
+
return Response.json(run, { headers });
|
|
465
|
+
}
|
|
466
|
+
if (path === "/api/heartbeats") {
|
|
467
|
+
if (request.method !== "GET") {
|
|
468
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
469
|
+
}
|
|
470
|
+
const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
|
|
471
|
+
const enabled = enabledRaw === "true" ? true : enabledRaw === "false" ? false : undefined;
|
|
472
|
+
const targetKindRaw = url.searchParams.get("target_kind")?.trim().toLowerCase();
|
|
473
|
+
const targetKind = targetKindRaw === "run" || targetKindRaw === "activity" ? targetKindRaw : undefined;
|
|
474
|
+
const limitRaw = url.searchParams.get("limit");
|
|
475
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw)
|
|
476
|
+
? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10)))
|
|
477
|
+
: undefined;
|
|
478
|
+
const programs = await heartbeatPrograms.list({ enabled, targetKind, limit });
|
|
479
|
+
return Response.json({ count: programs.length, programs }, { headers });
|
|
480
|
+
}
|
|
481
|
+
if (path === "/api/heartbeats/create") {
|
|
482
|
+
if (request.method !== "POST") {
|
|
483
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
484
|
+
}
|
|
485
|
+
let body;
|
|
486
|
+
try {
|
|
487
|
+
body = (await request.json());
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
491
|
+
}
|
|
492
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
493
|
+
if (!title) {
|
|
494
|
+
return Response.json({ error: "title is required" }, { status: 400, headers });
|
|
495
|
+
}
|
|
496
|
+
const targetKind = typeof body.target_kind === "string" ? body.target_kind.trim().toLowerCase() : "";
|
|
497
|
+
let target = null;
|
|
498
|
+
if (targetKind === "run") {
|
|
499
|
+
const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
|
|
500
|
+
const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
|
|
501
|
+
if (!jobId && !rootIssueId) {
|
|
502
|
+
return Response.json({ error: "run target requires run_job_id or run_root_issue_id" }, { status: 400, headers });
|
|
503
|
+
}
|
|
504
|
+
target = {
|
|
505
|
+
kind: "run",
|
|
506
|
+
job_id: jobId || null,
|
|
507
|
+
root_issue_id: rootIssueId || null,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
else if (targetKind === "activity") {
|
|
511
|
+
const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
|
|
512
|
+
if (!activityId) {
|
|
513
|
+
return Response.json({ error: "activity target requires activity_id" }, { status: 400, headers });
|
|
514
|
+
}
|
|
515
|
+
target = {
|
|
516
|
+
kind: "activity",
|
|
517
|
+
activity_id: activityId,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
|
|
522
|
+
}
|
|
523
|
+
const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
|
|
524
|
+
? Math.max(0, Math.trunc(body.every_ms))
|
|
525
|
+
: undefined;
|
|
526
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
|
|
527
|
+
const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
|
|
528
|
+
try {
|
|
529
|
+
const program = await heartbeatPrograms.create({
|
|
530
|
+
title,
|
|
531
|
+
target,
|
|
532
|
+
everyMs,
|
|
533
|
+
reason,
|
|
534
|
+
enabled,
|
|
535
|
+
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
536
|
+
? body.metadata
|
|
537
|
+
: undefined,
|
|
538
|
+
});
|
|
539
|
+
return Response.json({ ok: true, program }, { status: 201, headers });
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
return Response.json({ error: describeError(err) }, { status: 400, headers });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (path === "/api/heartbeats/update") {
|
|
546
|
+
if (request.method !== "POST") {
|
|
547
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
548
|
+
}
|
|
549
|
+
let body;
|
|
550
|
+
try {
|
|
551
|
+
body = (await request.json());
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
555
|
+
}
|
|
556
|
+
const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
|
|
557
|
+
if (!programId) {
|
|
558
|
+
return Response.json({ error: "program_id is required" }, { status: 400, headers });
|
|
559
|
+
}
|
|
560
|
+
let target;
|
|
561
|
+
if (typeof body.target_kind === "string") {
|
|
562
|
+
const targetKind = body.target_kind.trim().toLowerCase();
|
|
563
|
+
if (targetKind === "run") {
|
|
564
|
+
const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
|
|
565
|
+
const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
|
|
566
|
+
if (!jobId && !rootIssueId) {
|
|
567
|
+
return Response.json({ error: "run target requires run_job_id or run_root_issue_id" }, { status: 400, headers });
|
|
568
|
+
}
|
|
569
|
+
target = {
|
|
570
|
+
kind: "run",
|
|
571
|
+
job_id: jobId || null,
|
|
572
|
+
root_issue_id: rootIssueId || null,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
else if (targetKind === "activity") {
|
|
576
|
+
const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
|
|
577
|
+
if (!activityId) {
|
|
578
|
+
return Response.json({ error: "activity target requires activity_id" }, { status: 400, headers });
|
|
579
|
+
}
|
|
580
|
+
target = {
|
|
581
|
+
kind: "activity",
|
|
582
|
+
activity_id: activityId,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const result = await heartbeatPrograms.update({
|
|
591
|
+
programId,
|
|
592
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
593
|
+
target,
|
|
594
|
+
everyMs: typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
|
|
595
|
+
? Math.max(0, Math.trunc(body.every_ms))
|
|
596
|
+
: undefined,
|
|
597
|
+
reason: typeof body.reason === "string" ? body.reason : undefined,
|
|
598
|
+
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
|
599
|
+
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
600
|
+
? body.metadata
|
|
601
|
+
: undefined,
|
|
602
|
+
});
|
|
603
|
+
if (result.ok) {
|
|
604
|
+
return Response.json(result, { headers });
|
|
605
|
+
}
|
|
606
|
+
return Response.json(result, { status: result.reason === "not_found" ? 404 : 400, headers });
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
return Response.json({ error: describeError(err) }, { status: 400, headers });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (path === "/api/heartbeats/delete") {
|
|
613
|
+
if (request.method !== "POST") {
|
|
614
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
615
|
+
}
|
|
616
|
+
let body;
|
|
617
|
+
try {
|
|
618
|
+
body = (await request.json());
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
622
|
+
}
|
|
623
|
+
const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
|
|
624
|
+
if (!programId) {
|
|
625
|
+
return Response.json({ error: "program_id is required" }, { status: 400, headers });
|
|
626
|
+
}
|
|
627
|
+
const result = await heartbeatPrograms.remove(programId);
|
|
628
|
+
return Response.json(result, { status: result.ok ? 200 : result.reason === "not_found" ? 404 : 400, headers });
|
|
629
|
+
}
|
|
630
|
+
if (path === "/api/heartbeats/trigger") {
|
|
631
|
+
if (request.method !== "POST") {
|
|
632
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
633
|
+
}
|
|
634
|
+
let body;
|
|
635
|
+
try {
|
|
636
|
+
body = (await request.json());
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
640
|
+
}
|
|
641
|
+
const result = await heartbeatPrograms.trigger({
|
|
642
|
+
programId: typeof body.program_id === "string" ? body.program_id : null,
|
|
643
|
+
reason: typeof body.reason === "string" ? body.reason : null,
|
|
644
|
+
});
|
|
645
|
+
if (result.ok) {
|
|
646
|
+
return Response.json(result, { headers });
|
|
647
|
+
}
|
|
648
|
+
if (result.reason === "missing_target") {
|
|
649
|
+
return Response.json(result, { status: 400, headers });
|
|
650
|
+
}
|
|
651
|
+
if (result.reason === "not_found") {
|
|
652
|
+
return Response.json(result, { status: 404, headers });
|
|
653
|
+
}
|
|
654
|
+
return Response.json(result, { status: 409, headers });
|
|
655
|
+
}
|
|
656
|
+
if (path.startsWith("/api/heartbeats/")) {
|
|
657
|
+
if (request.method !== "GET") {
|
|
658
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
659
|
+
}
|
|
660
|
+
const id = decodeURIComponent(path.slice("/api/heartbeats/".length)).trim();
|
|
661
|
+
if (!id) {
|
|
662
|
+
return Response.json({ error: "missing program id" }, { status: 400, headers });
|
|
663
|
+
}
|
|
664
|
+
const program = await heartbeatPrograms.get(id);
|
|
665
|
+
if (!program) {
|
|
666
|
+
return Response.json({ error: "program not found" }, { status: 404, headers });
|
|
667
|
+
}
|
|
668
|
+
return Response.json(program, { headers });
|
|
669
|
+
}
|
|
670
|
+
if (path === "/api/activities") {
|
|
671
|
+
if (request.method !== "GET") {
|
|
672
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
673
|
+
}
|
|
674
|
+
const statusRaw = url.searchParams.get("status")?.trim().toLowerCase();
|
|
675
|
+
const status = statusRaw === "running" ||
|
|
676
|
+
statusRaw === "completed" ||
|
|
677
|
+
statusRaw === "failed" ||
|
|
678
|
+
statusRaw === "cancelled"
|
|
679
|
+
? statusRaw
|
|
680
|
+
: undefined;
|
|
681
|
+
const kind = url.searchParams.get("kind")?.trim() || undefined;
|
|
682
|
+
const limitRaw = url.searchParams.get("limit");
|
|
683
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw)
|
|
684
|
+
? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10)))
|
|
685
|
+
: undefined;
|
|
686
|
+
const activities = activitySupervisor.list({ status, kind, limit });
|
|
687
|
+
return Response.json({ count: activities.length, activities }, { headers });
|
|
688
|
+
}
|
|
689
|
+
if (path === "/api/activities/start") {
|
|
690
|
+
if (request.method !== "POST") {
|
|
691
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
692
|
+
}
|
|
693
|
+
let body;
|
|
694
|
+
try {
|
|
695
|
+
body = (await request.json());
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
699
|
+
}
|
|
700
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
701
|
+
if (!title) {
|
|
702
|
+
return Response.json({ error: "title is required" }, { status: 400, headers });
|
|
703
|
+
}
|
|
704
|
+
const kind = typeof body.kind === "string" ? body.kind.trim() : undefined;
|
|
705
|
+
const heartbeatEveryMs = typeof body.heartbeat_every_ms === "number" && Number.isFinite(body.heartbeat_every_ms)
|
|
706
|
+
? Math.max(0, Math.trunc(body.heartbeat_every_ms))
|
|
707
|
+
: undefined;
|
|
708
|
+
const source = body.source === "api" || body.source === "command" || body.source === "system"
|
|
709
|
+
? body.source
|
|
710
|
+
: "api";
|
|
711
|
+
try {
|
|
712
|
+
const activity = activitySupervisor.start({
|
|
713
|
+
title,
|
|
714
|
+
kind,
|
|
715
|
+
heartbeatEveryMs,
|
|
716
|
+
metadata: body.metadata ?? undefined,
|
|
717
|
+
source,
|
|
718
|
+
});
|
|
719
|
+
return Response.json({ ok: true, activity }, { status: 201, headers });
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
return Response.json({ error: describeError(err) }, { status: 400, headers });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (path === "/api/activities/progress") {
|
|
726
|
+
if (request.method !== "POST") {
|
|
727
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
728
|
+
}
|
|
729
|
+
let body;
|
|
730
|
+
try {
|
|
731
|
+
body = (await request.json());
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
735
|
+
}
|
|
736
|
+
const result = activitySupervisor.progress({
|
|
737
|
+
activityId: typeof body.activity_id === "string" ? body.activity_id : null,
|
|
738
|
+
message: typeof body.message === "string" ? body.message : null,
|
|
739
|
+
});
|
|
740
|
+
if (result.ok) {
|
|
741
|
+
return Response.json(result, { headers });
|
|
742
|
+
}
|
|
743
|
+
if (result.reason === "missing_target") {
|
|
744
|
+
return Response.json(result, { status: 400, headers });
|
|
745
|
+
}
|
|
746
|
+
if (result.reason === "not_running") {
|
|
747
|
+
return Response.json(result, { status: 409, headers });
|
|
748
|
+
}
|
|
749
|
+
return Response.json(result, { status: 404, headers });
|
|
750
|
+
}
|
|
751
|
+
if (path === "/api/activities/heartbeat") {
|
|
752
|
+
if (request.method !== "POST") {
|
|
753
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
754
|
+
}
|
|
755
|
+
let body;
|
|
756
|
+
try {
|
|
757
|
+
body = (await request.json());
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
761
|
+
}
|
|
762
|
+
const result = activitySupervisor.heartbeat({
|
|
763
|
+
activityId: typeof body.activity_id === "string" ? body.activity_id : null,
|
|
764
|
+
reason: typeof body.reason === "string" ? body.reason : null,
|
|
765
|
+
});
|
|
766
|
+
if (result.ok) {
|
|
767
|
+
return Response.json(result, { headers });
|
|
768
|
+
}
|
|
769
|
+
if (result.reason === "missing_target") {
|
|
770
|
+
return Response.json(result, { status: 400, headers });
|
|
771
|
+
}
|
|
772
|
+
if (result.reason === "not_running") {
|
|
773
|
+
return Response.json(result, { status: 409, headers });
|
|
774
|
+
}
|
|
775
|
+
return Response.json(result, { status: 404, headers });
|
|
776
|
+
}
|
|
777
|
+
if (path === "/api/activities/complete" || path === "/api/activities/fail" || path === "/api/activities/cancel") {
|
|
778
|
+
if (request.method !== "POST") {
|
|
779
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
780
|
+
}
|
|
781
|
+
let body;
|
|
782
|
+
try {
|
|
783
|
+
body = (await request.json());
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
787
|
+
}
|
|
788
|
+
const activityId = typeof body.activity_id === "string" ? body.activity_id : null;
|
|
789
|
+
const message = typeof body.message === "string" ? body.message : null;
|
|
790
|
+
const result = path === "/api/activities/complete"
|
|
791
|
+
? activitySupervisor.complete({ activityId, message })
|
|
792
|
+
: path === "/api/activities/fail"
|
|
793
|
+
? activitySupervisor.fail({ activityId, message })
|
|
794
|
+
: activitySupervisor.cancel({ activityId, message });
|
|
795
|
+
if (result.ok) {
|
|
796
|
+
return Response.json(result, { headers });
|
|
797
|
+
}
|
|
798
|
+
if (result.reason === "missing_target") {
|
|
799
|
+
return Response.json(result, { status: 400, headers });
|
|
800
|
+
}
|
|
801
|
+
if (result.reason === "not_running") {
|
|
802
|
+
return Response.json(result, { status: 409, headers });
|
|
803
|
+
}
|
|
804
|
+
return Response.json(result, { status: 404, headers });
|
|
805
|
+
}
|
|
806
|
+
if (path.startsWith("/api/activities/")) {
|
|
807
|
+
if (request.method !== "GET") {
|
|
808
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
809
|
+
}
|
|
810
|
+
const rest = path.slice("/api/activities/".length);
|
|
811
|
+
const [rawId, maybeSub] = rest.split("/");
|
|
812
|
+
const activityId = decodeURIComponent(rawId ?? "").trim();
|
|
813
|
+
if (activityId.length === 0) {
|
|
814
|
+
return Response.json({ error: "missing activity id" }, { status: 400, headers });
|
|
815
|
+
}
|
|
816
|
+
if (maybeSub === "events") {
|
|
817
|
+
const limitRaw = url.searchParams.get("limit");
|
|
818
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw)
|
|
819
|
+
? Math.max(1, Math.min(2_000, Number.parseInt(limitRaw, 10)))
|
|
820
|
+
: undefined;
|
|
821
|
+
const events = activitySupervisor.events(activityId, { limit });
|
|
822
|
+
if (!events) {
|
|
823
|
+
return Response.json({ error: "activity not found" }, { status: 404, headers });
|
|
824
|
+
}
|
|
825
|
+
return Response.json({ count: events.length, events }, { headers });
|
|
826
|
+
}
|
|
827
|
+
const activity = activitySupervisor.get(activityId);
|
|
828
|
+
if (!activity) {
|
|
829
|
+
return Response.json({ error: "activity not found" }, { status: 404, headers });
|
|
830
|
+
}
|
|
831
|
+
return Response.json(activity, { headers });
|
|
832
|
+
}
|
|
215
833
|
if (path.startsWith("/api/issues")) {
|
|
216
834
|
const response = await issueRoutes(request, context);
|
|
217
835
|
headers.forEach((value, key) => {
|
|
@@ -266,14 +884,21 @@ export function createServer(options = {}) {
|
|
|
266
884
|
fetch: handleRequest,
|
|
267
885
|
hostname: "0.0.0.0",
|
|
268
886
|
controlPlane: controlPlaneProxy,
|
|
887
|
+
activitySupervisor,
|
|
888
|
+
heartbeatPrograms,
|
|
269
889
|
};
|
|
270
890
|
return server;
|
|
271
891
|
}
|
|
272
892
|
export async function createServerAsync(options = {}) {
|
|
273
893
|
const repoRoot = options.repoRoot || process.cwd();
|
|
274
894
|
const config = options.config ?? (await readMuConfigFile(repoRoot));
|
|
275
|
-
const
|
|
276
|
-
const
|
|
895
|
+
const heartbeatScheduler = options.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
|
|
896
|
+
const controlPlane = await bootstrapControlPlane({
|
|
897
|
+
repoRoot,
|
|
898
|
+
config: config.control_plane,
|
|
899
|
+
heartbeatScheduler,
|
|
900
|
+
});
|
|
901
|
+
const serverConfig = createServer({ ...options, heartbeatScheduler, controlPlane, config });
|
|
277
902
|
return {
|
|
278
903
|
serverConfig,
|
|
279
904
|
controlPlane: serverConfig.controlPlane,
|