@femtomc/mu-server 26.2.69 → 26.2.71
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 +7 -3
- package/dist/api/activities.d.ts +2 -0
- package/dist/api/activities.js +160 -0
- package/dist/api/config.d.ts +2 -0
- package/dist/api/config.js +45 -0
- package/dist/api/control_plane.d.ts +2 -0
- package/dist/api/control_plane.js +28 -0
- package/dist/api/cron.d.ts +2 -0
- package/dist/api/cron.js +182 -0
- package/dist/api/events.js +77 -19
- package/dist/api/forum.js +52 -18
- package/dist/api/heartbeats.d.ts +2 -0
- package/dist/api/heartbeats.js +211 -0
- package/dist/api/identities.d.ts +2 -0
- package/dist/api/identities.js +103 -0
- package/dist/api/issues.js +120 -33
- package/dist/api/runs.d.ts +2 -0
- package/dist/api/runs.js +207 -0
- package/dist/cli.js +58 -3
- package/dist/config.d.ts +4 -21
- package/dist/config.js +24 -75
- package/dist/control_plane.d.ts +7 -114
- package/dist/control_plane.js +238 -654
- package/dist/control_plane_bootstrap_helpers.d.ts +16 -0
- package/dist/control_plane_bootstrap_helpers.js +85 -0
- package/dist/control_plane_contract.d.ts +176 -0
- package/dist/control_plane_contract.js +1 -0
- package/dist/control_plane_reload.d.ts +63 -0
- package/dist/control_plane_reload.js +525 -0
- package/dist/control_plane_run_outbox.d.ts +7 -0
- package/dist/control_plane_run_outbox.js +52 -0
- package/dist/control_plane_run_queue_coordinator.d.ts +48 -0
- package/dist/control_plane_run_queue_coordinator.js +327 -0
- package/dist/control_plane_telegram_generation.d.ts +27 -0
- package/dist/control_plane_telegram_generation.js +520 -0
- package/dist/control_plane_wake_delivery.d.ts +50 -0
- package/dist/control_plane_wake_delivery.js +123 -0
- package/dist/cron_request.d.ts +8 -0
- package/dist/cron_request.js +65 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -1
- package/dist/run_queue.d.ts +95 -0
- package/dist/run_queue.js +817 -0
- package/dist/run_supervisor.d.ts +20 -0
- package/dist/run_supervisor.js +25 -1
- package/dist/server.d.ts +12 -49
- package/dist/server.js +365 -2128
- package/dist/server_program_orchestration.d.ts +38 -0
- package/dist/server_program_orchestration.js +254 -0
- package/dist/server_routing.d.ts +31 -0
- package/dist/server_routing.js +230 -0
- package/dist/server_runtime.d.ts +30 -0
- package/dist/server_runtime.js +43 -0
- package/dist/server_types.d.ts +3 -0
- package/dist/server_types.js +16 -0
- package/dist/session_lifecycle.d.ts +11 -0
- package/dist/session_lifecycle.js +149 -0
- package/package.json +7 -6
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EventLog } from "@femtomc/mu-core/node";
|
|
2
|
+
import type { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
|
|
3
|
+
import type { ControlPlaneHandle } from "./control_plane_contract.js";
|
|
4
|
+
import { CronProgramRegistry } from "./cron_programs.js";
|
|
5
|
+
import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
6
|
+
import type { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
7
|
+
export type AutoHeartbeatRunSnapshot = {
|
|
8
|
+
job_id: string;
|
|
9
|
+
root_issue_id: string | null;
|
|
10
|
+
status: string;
|
|
11
|
+
source: "command" | "api";
|
|
12
|
+
mode: string;
|
|
13
|
+
};
|
|
14
|
+
type OperatorWakeEmitter = (opts: {
|
|
15
|
+
dedupeKey: string;
|
|
16
|
+
message: string;
|
|
17
|
+
payload: Record<string, unknown>;
|
|
18
|
+
coalesceMs?: number;
|
|
19
|
+
}) => Promise<boolean>;
|
|
20
|
+
export declare function createServerProgramOrchestration(opts: {
|
|
21
|
+
repoRoot: string;
|
|
22
|
+
heartbeatScheduler: ActivityHeartbeatScheduler;
|
|
23
|
+
controlPlaneProxy: ControlPlaneHandle;
|
|
24
|
+
activitySupervisor: ControlPlaneActivitySupervisor;
|
|
25
|
+
eventLog: EventLog;
|
|
26
|
+
autoRunHeartbeatEveryMs: number;
|
|
27
|
+
emitOperatorWake: OperatorWakeEmitter;
|
|
28
|
+
}): {
|
|
29
|
+
heartbeatPrograms: HeartbeatProgramRegistry;
|
|
30
|
+
cronPrograms: CronProgramRegistry;
|
|
31
|
+
registerAutoRunHeartbeatProgram: (run: AutoHeartbeatRunSnapshot) => Promise<void>;
|
|
32
|
+
disableAutoRunHeartbeatProgram: (opts: {
|
|
33
|
+
jobId: string;
|
|
34
|
+
status: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
}) => Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { CronProgramRegistry } from "./cron_programs.js";
|
|
2
|
+
import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
3
|
+
const AUTO_RUN_HEARTBEAT_REASON = "auto-run-heartbeat";
|
|
4
|
+
export function createServerProgramOrchestration(opts) {
|
|
5
|
+
const autoRunHeartbeatProgramByJobId = new Map();
|
|
6
|
+
const heartbeatPrograms = new HeartbeatProgramRegistry({
|
|
7
|
+
repoRoot: opts.repoRoot,
|
|
8
|
+
heartbeatScheduler: opts.heartbeatScheduler,
|
|
9
|
+
runHeartbeat: async (runOpts) => {
|
|
10
|
+
const result = await opts.controlPlaneProxy.heartbeatRun?.({
|
|
11
|
+
jobId: runOpts.jobId ?? null,
|
|
12
|
+
rootIssueId: runOpts.rootIssueId ?? null,
|
|
13
|
+
reason: runOpts.reason ?? null,
|
|
14
|
+
wakeMode: runOpts.wakeMode,
|
|
15
|
+
});
|
|
16
|
+
return result ?? { ok: false, reason: "not_found" };
|
|
17
|
+
},
|
|
18
|
+
activityHeartbeat: async (activityOpts) => {
|
|
19
|
+
return opts.activitySupervisor.heartbeat({
|
|
20
|
+
activityId: activityOpts.activityId ?? null,
|
|
21
|
+
reason: activityOpts.reason ?? null,
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
onTickEvent: async (event) => {
|
|
25
|
+
await opts.eventLog.emit("heartbeat_program.tick", {
|
|
26
|
+
source: "mu-server.heartbeat-programs",
|
|
27
|
+
payload: {
|
|
28
|
+
program_id: event.program_id,
|
|
29
|
+
status: event.status,
|
|
30
|
+
reason: event.reason,
|
|
31
|
+
message: event.message,
|
|
32
|
+
program: event.program,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
await opts.emitOperatorWake({
|
|
36
|
+
dedupeKey: `heartbeat-program:${event.program_id}`,
|
|
37
|
+
message: event.message,
|
|
38
|
+
payload: {
|
|
39
|
+
wake_source: "heartbeat_program",
|
|
40
|
+
source_ts_ms: event.ts_ms,
|
|
41
|
+
program_id: event.program_id,
|
|
42
|
+
status: event.status,
|
|
43
|
+
reason: event.reason,
|
|
44
|
+
wake_mode: event.program.wake_mode,
|
|
45
|
+
target_kind: event.program.target.kind,
|
|
46
|
+
target: event.program.target.kind === "run"
|
|
47
|
+
? {
|
|
48
|
+
job_id: event.program.target.job_id,
|
|
49
|
+
root_issue_id: event.program.target.root_issue_id,
|
|
50
|
+
}
|
|
51
|
+
: { activity_id: event.program.target.activity_id },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const cronPrograms = new CronProgramRegistry({
|
|
57
|
+
repoRoot: opts.repoRoot,
|
|
58
|
+
heartbeatScheduler: opts.heartbeatScheduler,
|
|
59
|
+
runHeartbeat: async (runOpts) => {
|
|
60
|
+
const result = await opts.controlPlaneProxy.heartbeatRun?.({
|
|
61
|
+
jobId: runOpts.jobId ?? null,
|
|
62
|
+
rootIssueId: runOpts.rootIssueId ?? null,
|
|
63
|
+
reason: runOpts.reason ?? null,
|
|
64
|
+
wakeMode: runOpts.wakeMode,
|
|
65
|
+
});
|
|
66
|
+
return result ?? { ok: false, reason: "not_found" };
|
|
67
|
+
},
|
|
68
|
+
activityHeartbeat: async (activityOpts) => {
|
|
69
|
+
return opts.activitySupervisor.heartbeat({
|
|
70
|
+
activityId: activityOpts.activityId ?? null,
|
|
71
|
+
reason: activityOpts.reason ?? null,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
onLifecycleEvent: async (event) => {
|
|
75
|
+
await opts.eventLog.emit("cron_program.lifecycle", {
|
|
76
|
+
source: "mu-server.cron-programs",
|
|
77
|
+
payload: {
|
|
78
|
+
action: event.action,
|
|
79
|
+
program_id: event.program_id,
|
|
80
|
+
message: event.message,
|
|
81
|
+
program: event.program,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
onTickEvent: async (event) => {
|
|
86
|
+
await opts.eventLog.emit("cron_program.tick", {
|
|
87
|
+
source: "mu-server.cron-programs",
|
|
88
|
+
payload: {
|
|
89
|
+
program_id: event.program_id,
|
|
90
|
+
status: event.status,
|
|
91
|
+
reason: event.reason,
|
|
92
|
+
message: event.message,
|
|
93
|
+
program: event.program,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await opts.emitOperatorWake({
|
|
97
|
+
dedupeKey: `cron-program:${event.program_id}`,
|
|
98
|
+
message: event.message,
|
|
99
|
+
payload: {
|
|
100
|
+
wake_source: "cron_program",
|
|
101
|
+
source_ts_ms: event.ts_ms,
|
|
102
|
+
program_id: event.program_id,
|
|
103
|
+
status: event.status,
|
|
104
|
+
reason: event.reason,
|
|
105
|
+
wake_mode: event.program.wake_mode,
|
|
106
|
+
target_kind: event.program.target.kind,
|
|
107
|
+
target: event.program.target.kind === "run"
|
|
108
|
+
? {
|
|
109
|
+
job_id: event.program.target.job_id,
|
|
110
|
+
root_issue_id: event.program.target.root_issue_id,
|
|
111
|
+
}
|
|
112
|
+
: { activity_id: event.program.target.activity_id },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const findAutoRunHeartbeatProgram = async (jobId) => {
|
|
118
|
+
const normalizedJobId = jobId.trim();
|
|
119
|
+
if (!normalizedJobId) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const knownProgramId = autoRunHeartbeatProgramByJobId.get(normalizedJobId);
|
|
123
|
+
if (knownProgramId) {
|
|
124
|
+
const knownProgram = await heartbeatPrograms.get(knownProgramId);
|
|
125
|
+
if (knownProgram) {
|
|
126
|
+
return knownProgram;
|
|
127
|
+
}
|
|
128
|
+
autoRunHeartbeatProgramByJobId.delete(normalizedJobId);
|
|
129
|
+
}
|
|
130
|
+
const programs = await heartbeatPrograms.list({ targetKind: "run", limit: 500 });
|
|
131
|
+
for (const program of programs) {
|
|
132
|
+
if (program.metadata.auto_run_job_id !== normalizedJobId) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
autoRunHeartbeatProgramByJobId.set(normalizedJobId, program.program_id);
|
|
136
|
+
return program;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
const registerAutoRunHeartbeatProgram = async (run) => {
|
|
141
|
+
if (run.source === "command") {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const jobId = run.job_id.trim();
|
|
145
|
+
if (!jobId || run.status !== "running") {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const rootIssueId = typeof run.root_issue_id === "string" ? run.root_issue_id.trim() : "";
|
|
149
|
+
const metadata = {
|
|
150
|
+
auto_run_heartbeat: true,
|
|
151
|
+
auto_run_job_id: jobId,
|
|
152
|
+
auto_run_root_issue_id: rootIssueId || null,
|
|
153
|
+
auto_disable_on_terminal: true,
|
|
154
|
+
run_mode: run.mode,
|
|
155
|
+
run_source: run.source,
|
|
156
|
+
};
|
|
157
|
+
const existing = await findAutoRunHeartbeatProgram(jobId);
|
|
158
|
+
if (existing) {
|
|
159
|
+
const result = await heartbeatPrograms.update({
|
|
160
|
+
programId: existing.program_id,
|
|
161
|
+
title: `Run heartbeat: ${rootIssueId || jobId}`,
|
|
162
|
+
target: {
|
|
163
|
+
kind: "run",
|
|
164
|
+
job_id: jobId,
|
|
165
|
+
root_issue_id: rootIssueId || null,
|
|
166
|
+
},
|
|
167
|
+
enabled: true,
|
|
168
|
+
everyMs: opts.autoRunHeartbeatEveryMs,
|
|
169
|
+
reason: AUTO_RUN_HEARTBEAT_REASON,
|
|
170
|
+
wakeMode: "next_heartbeat",
|
|
171
|
+
metadata,
|
|
172
|
+
});
|
|
173
|
+
if (result.ok && result.program) {
|
|
174
|
+
autoRunHeartbeatProgramByJobId.set(jobId, result.program.program_id);
|
|
175
|
+
await opts.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
176
|
+
source: "mu-server.runs",
|
|
177
|
+
payload: {
|
|
178
|
+
action: "updated",
|
|
179
|
+
run_job_id: jobId,
|
|
180
|
+
run_root_issue_id: rootIssueId || null,
|
|
181
|
+
program_id: result.program.program_id,
|
|
182
|
+
program: result.program,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const created = await heartbeatPrograms.create({
|
|
189
|
+
title: `Run heartbeat: ${rootIssueId || jobId}`,
|
|
190
|
+
target: {
|
|
191
|
+
kind: "run",
|
|
192
|
+
job_id: jobId,
|
|
193
|
+
root_issue_id: rootIssueId || null,
|
|
194
|
+
},
|
|
195
|
+
everyMs: opts.autoRunHeartbeatEveryMs,
|
|
196
|
+
reason: AUTO_RUN_HEARTBEAT_REASON,
|
|
197
|
+
wakeMode: "next_heartbeat",
|
|
198
|
+
metadata,
|
|
199
|
+
enabled: true,
|
|
200
|
+
});
|
|
201
|
+
autoRunHeartbeatProgramByJobId.set(jobId, created.program_id);
|
|
202
|
+
await opts.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
203
|
+
source: "mu-server.runs",
|
|
204
|
+
payload: {
|
|
205
|
+
action: "registered",
|
|
206
|
+
run_job_id: jobId,
|
|
207
|
+
run_root_issue_id: rootIssueId || null,
|
|
208
|
+
program_id: created.program_id,
|
|
209
|
+
program: created,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const disableAutoRunHeartbeatProgram = async (disableOpts) => {
|
|
214
|
+
const program = await findAutoRunHeartbeatProgram(disableOpts.jobId);
|
|
215
|
+
if (!program) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const metadata = {
|
|
219
|
+
...program.metadata,
|
|
220
|
+
auto_disabled_from_status: disableOpts.status,
|
|
221
|
+
auto_disabled_reason: disableOpts.reason,
|
|
222
|
+
auto_disabled_at_ms: Date.now(),
|
|
223
|
+
};
|
|
224
|
+
const result = await heartbeatPrograms.update({
|
|
225
|
+
programId: program.program_id,
|
|
226
|
+
enabled: false,
|
|
227
|
+
everyMs: 0,
|
|
228
|
+
reason: AUTO_RUN_HEARTBEAT_REASON,
|
|
229
|
+
wakeMode: program.wake_mode,
|
|
230
|
+
metadata,
|
|
231
|
+
});
|
|
232
|
+
autoRunHeartbeatProgramByJobId.delete(disableOpts.jobId.trim());
|
|
233
|
+
if (!result.ok || !result.program) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await opts.eventLog.emit("run.auto_heartbeat.lifecycle", {
|
|
237
|
+
source: "mu-server.runs",
|
|
238
|
+
payload: {
|
|
239
|
+
action: "disabled",
|
|
240
|
+
run_job_id: disableOpts.jobId,
|
|
241
|
+
status: disableOpts.status,
|
|
242
|
+
reason: disableOpts.reason,
|
|
243
|
+
program_id: result.program.program_id,
|
|
244
|
+
program: result.program,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
heartbeatPrograms,
|
|
250
|
+
cronPrograms,
|
|
251
|
+
registerAutoRunHeartbeatProgram,
|
|
252
|
+
disableAutoRunHeartbeatProgram,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
|
|
2
|
+
import type { MuConfig } from "./config.js";
|
|
3
|
+
import type { ControlPlaneHandle } from "./control_plane_contract.js";
|
|
4
|
+
import type { CronProgramRegistry } from "./cron_programs.js";
|
|
5
|
+
import type { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
6
|
+
import type { AutoHeartbeatRunSnapshot } from "./server_program_orchestration.js";
|
|
7
|
+
import type { ServerContext } from "./server.js";
|
|
8
|
+
export type ServerRoutingDependencies = {
|
|
9
|
+
context: ServerContext;
|
|
10
|
+
controlPlaneProxy: ControlPlaneHandle;
|
|
11
|
+
activitySupervisor: ControlPlaneActivitySupervisor;
|
|
12
|
+
heartbeatPrograms: HeartbeatProgramRegistry;
|
|
13
|
+
cronPrograms: CronProgramRegistry;
|
|
14
|
+
loadConfigFromDisk: () => Promise<MuConfig>;
|
|
15
|
+
writeConfig: (repoRoot: string, config: MuConfig) => Promise<string>;
|
|
16
|
+
reloadControlPlane: (reason: string) => Promise<{
|
|
17
|
+
ok: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
getControlPlaneStatus: () => unknown;
|
|
20
|
+
registerAutoRunHeartbeatProgram: (run: AutoHeartbeatRunSnapshot) => Promise<void>;
|
|
21
|
+
disableAutoRunHeartbeatProgram: (opts: {
|
|
22
|
+
jobId: string;
|
|
23
|
+
status: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
}) => Promise<void>;
|
|
26
|
+
describeError: (error: unknown) => string;
|
|
27
|
+
initiateShutdown?: () => Promise<void>;
|
|
28
|
+
publicDir?: string;
|
|
29
|
+
mimeTypes?: Record<string, string>;
|
|
30
|
+
};
|
|
31
|
+
export declare function createServerRequestHandler(deps: ServerRoutingDependencies): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { extname, join, resolve } from "node:path";
|
|
2
|
+
import { activityRoutes } from "./api/activities.js";
|
|
3
|
+
import { configRoutes } from "./api/config.js";
|
|
4
|
+
import { controlPlaneRoutes } from "./api/control_plane.js";
|
|
5
|
+
import { cronRoutes } from "./api/cron.js";
|
|
6
|
+
import { eventRoutes } from "./api/events.js";
|
|
7
|
+
import { forumRoutes } from "./api/forum.js";
|
|
8
|
+
import { heartbeatRoutes } from "./api/heartbeats.js";
|
|
9
|
+
import { identityRoutes } from "./api/identities.js";
|
|
10
|
+
import { issueRoutes } from "./api/issues.js";
|
|
11
|
+
import { runRoutes } from "./api/runs.js";
|
|
12
|
+
const DEFAULT_MIME_TYPES = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "text/javascript; charset=utf-8",
|
|
15
|
+
".css": "text/css; charset=utf-8",
|
|
16
|
+
".json": "application/json",
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".svg": "image/svg+xml",
|
|
20
|
+
".ico": "image/x-icon",
|
|
21
|
+
".woff": "font/woff",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
};
|
|
24
|
+
const DEFAULT_PUBLIC_DIR = join(new URL(".", import.meta.url).pathname, "..", "public");
|
|
25
|
+
export function createServerRequestHandler(deps) {
|
|
26
|
+
const publicDir = deps.publicDir ?? DEFAULT_PUBLIC_DIR;
|
|
27
|
+
const mimeTypes = deps.mimeTypes ?? DEFAULT_MIME_TYPES;
|
|
28
|
+
return async (request) => {
|
|
29
|
+
const url = new URL(request.url);
|
|
30
|
+
const path = url.pathname;
|
|
31
|
+
const headers = new Headers({
|
|
32
|
+
"Access-Control-Allow-Origin": "*",
|
|
33
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
34
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
35
|
+
});
|
|
36
|
+
if (request.method === "OPTIONS") {
|
|
37
|
+
return new Response(null, { status: 204, headers });
|
|
38
|
+
}
|
|
39
|
+
if (path === "/healthz" || path === "/health") {
|
|
40
|
+
return new Response("ok", { status: 200, headers });
|
|
41
|
+
}
|
|
42
|
+
if (path === "/api/server/shutdown") {
|
|
43
|
+
if (request.method !== "POST") {
|
|
44
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
45
|
+
}
|
|
46
|
+
if (!deps.initiateShutdown) {
|
|
47
|
+
return Response.json({ error: "shutdown not supported" }, { status: 501, headers });
|
|
48
|
+
}
|
|
49
|
+
const shutdown = deps.initiateShutdown;
|
|
50
|
+
// Respond before shutting down so the client receives the response.
|
|
51
|
+
setTimeout(() => { void shutdown(); }, 100);
|
|
52
|
+
return Response.json({ ok: true, message: "shutdown initiated" }, { headers });
|
|
53
|
+
}
|
|
54
|
+
if (path === "/api/config") {
|
|
55
|
+
return configRoutes(request, url, deps, headers);
|
|
56
|
+
}
|
|
57
|
+
if (path === "/api/control-plane/reload" || path === "/api/control-plane/rollback") {
|
|
58
|
+
return controlPlaneRoutes(request, url, deps, headers);
|
|
59
|
+
}
|
|
60
|
+
if (path === "/api/status") {
|
|
61
|
+
const issues = await deps.context.issueStore.list();
|
|
62
|
+
const openIssues = issues.filter((i) => i.status === "open");
|
|
63
|
+
const readyIssues = await deps.context.issueStore.ready();
|
|
64
|
+
const controlPlane = deps.getControlPlaneStatus();
|
|
65
|
+
return Response.json({
|
|
66
|
+
repo_root: deps.context.repoRoot,
|
|
67
|
+
open_count: openIssues.length,
|
|
68
|
+
ready_count: readyIssues.length,
|
|
69
|
+
control_plane: controlPlane,
|
|
70
|
+
}, { headers });
|
|
71
|
+
}
|
|
72
|
+
if (path === "/api/commands/submit") {
|
|
73
|
+
if (request.method !== "POST") {
|
|
74
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
75
|
+
}
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
body = (await request.json());
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
82
|
+
}
|
|
83
|
+
const kind = typeof body.kind === "string" ? body.kind.trim() : "";
|
|
84
|
+
if (!kind) {
|
|
85
|
+
return Response.json({ error: "kind is required" }, { status: 400, headers });
|
|
86
|
+
}
|
|
87
|
+
let commandText;
|
|
88
|
+
switch (kind) {
|
|
89
|
+
case "run_start": {
|
|
90
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
91
|
+
if (!prompt) {
|
|
92
|
+
return Response.json({ error: "prompt is required for run_start" }, { status: 400, headers });
|
|
93
|
+
}
|
|
94
|
+
const maxStepsSuffix = typeof body.max_steps === "number" && Number.isFinite(body.max_steps)
|
|
95
|
+
? ` --max-steps ${Math.max(1, Math.trunc(body.max_steps))}`
|
|
96
|
+
: "";
|
|
97
|
+
commandText = `mu! run start ${prompt}${maxStepsSuffix}`;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "run_resume": {
|
|
101
|
+
const rootId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
|
|
102
|
+
const maxSteps = typeof body.max_steps === "number" && Number.isFinite(body.max_steps)
|
|
103
|
+
? ` ${Math.max(1, Math.trunc(body.max_steps))}`
|
|
104
|
+
: "";
|
|
105
|
+
commandText = `mu! run resume${rootId ? ` ${rootId}` : ""}${maxSteps}`;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "run_interrupt": {
|
|
109
|
+
const rootId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
|
|
110
|
+
commandText = `mu! run interrupt${rootId ? ` ${rootId}` : ""}`;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "reload":
|
|
114
|
+
commandText = "/mu reload";
|
|
115
|
+
break;
|
|
116
|
+
case "update":
|
|
117
|
+
commandText = "/mu update";
|
|
118
|
+
break;
|
|
119
|
+
case "status":
|
|
120
|
+
commandText = "/mu status";
|
|
121
|
+
break;
|
|
122
|
+
case "issue_list":
|
|
123
|
+
commandText = "/mu issue list";
|
|
124
|
+
break;
|
|
125
|
+
case "issue_get": {
|
|
126
|
+
const issueId = typeof body.issue_id === "string" ? body.issue_id.trim() : "";
|
|
127
|
+
commandText = `/mu issue get${issueId ? ` ${issueId}` : ""}`;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case "forum_read": {
|
|
131
|
+
const topic = typeof body.topic === "string" ? body.topic.trim() : "";
|
|
132
|
+
const limit = typeof body.limit === "number" && Number.isFinite(body.limit)
|
|
133
|
+
? ` ${Math.max(1, Math.trunc(body.limit))}`
|
|
134
|
+
: "";
|
|
135
|
+
commandText = `/mu forum read${topic ? ` ${topic}` : ""}${limit}`;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case "run_list":
|
|
139
|
+
commandText = "/mu run list";
|
|
140
|
+
break;
|
|
141
|
+
case "run_status": {
|
|
142
|
+
const rootId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
|
|
143
|
+
commandText = `/mu run status${rootId ? ` ${rootId}` : ""}`;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "ready":
|
|
147
|
+
commandText = "/mu ready";
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
return Response.json({ error: `unknown command kind: ${kind}` }, { status: 400, headers });
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
if (!deps.controlPlaneProxy.submitTerminalCommand) {
|
|
154
|
+
return Response.json({ error: "control plane not available" }, { status: 503, headers });
|
|
155
|
+
}
|
|
156
|
+
const result = await deps.controlPlaneProxy.submitTerminalCommand({
|
|
157
|
+
commandText,
|
|
158
|
+
repoRoot: deps.context.repoRoot,
|
|
159
|
+
});
|
|
160
|
+
return Response.json({ ok: true, result }, { headers });
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return Response.json({ error: `command failed: ${deps.describeError(err)}` }, { status: 500, headers });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (path === "/api/runs" || path.startsWith("/api/runs/")) {
|
|
167
|
+
return runRoutes(request, url, deps, headers);
|
|
168
|
+
}
|
|
169
|
+
if (path === "/api/cron" || path.startsWith("/api/cron/")) {
|
|
170
|
+
return cronRoutes(request, url, deps, headers);
|
|
171
|
+
}
|
|
172
|
+
if (path === "/api/heartbeats" || path.startsWith("/api/heartbeats/")) {
|
|
173
|
+
return heartbeatRoutes(request, url, deps, headers);
|
|
174
|
+
}
|
|
175
|
+
if (path === "/api/activities" || path.startsWith("/api/activities/")) {
|
|
176
|
+
return activityRoutes(request, url, deps, headers);
|
|
177
|
+
}
|
|
178
|
+
if (path === "/api/identities" || path === "/api/identities/link" || path === "/api/identities/unlink") {
|
|
179
|
+
return identityRoutes(request, url, deps, headers);
|
|
180
|
+
}
|
|
181
|
+
if (path.startsWith("/api/issues")) {
|
|
182
|
+
const response = await issueRoutes(request, deps.context);
|
|
183
|
+
headers.forEach((value, key) => {
|
|
184
|
+
response.headers.set(key, value);
|
|
185
|
+
});
|
|
186
|
+
return response;
|
|
187
|
+
}
|
|
188
|
+
if (path.startsWith("/api/forum")) {
|
|
189
|
+
const response = await forumRoutes(request, deps.context);
|
|
190
|
+
headers.forEach((value, key) => {
|
|
191
|
+
response.headers.set(key, value);
|
|
192
|
+
});
|
|
193
|
+
return response;
|
|
194
|
+
}
|
|
195
|
+
if (path.startsWith("/api/events")) {
|
|
196
|
+
const response = await eventRoutes(request, deps.context);
|
|
197
|
+
headers.forEach((value, key) => {
|
|
198
|
+
response.headers.set(key, value);
|
|
199
|
+
});
|
|
200
|
+
return response;
|
|
201
|
+
}
|
|
202
|
+
if (path.startsWith("/webhooks/")) {
|
|
203
|
+
const response = await deps.controlPlaneProxy.handleWebhook(path, request);
|
|
204
|
+
if (response) {
|
|
205
|
+
headers.forEach((value, key) => {
|
|
206
|
+
response.headers.set(key, value);
|
|
207
|
+
});
|
|
208
|
+
return response;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const filePath = resolve(publicDir, `.${path === "/" ? "/index.html" : path}`);
|
|
212
|
+
if (!filePath.startsWith(publicDir)) {
|
|
213
|
+
return new Response("Forbidden", { status: 403, headers });
|
|
214
|
+
}
|
|
215
|
+
const file = Bun.file(filePath);
|
|
216
|
+
if (await file.exists()) {
|
|
217
|
+
const ext = extname(filePath);
|
|
218
|
+
const mime = mimeTypes[ext] ?? "application/octet-stream";
|
|
219
|
+
headers.set("Content-Type", mime);
|
|
220
|
+
return new Response(await file.arrayBuffer(), { status: 200, headers });
|
|
221
|
+
}
|
|
222
|
+
const indexPath = join(publicDir, "index.html");
|
|
223
|
+
const indexFile = Bun.file(indexPath);
|
|
224
|
+
if (await indexFile.exists()) {
|
|
225
|
+
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
226
|
+
return new Response(await indexFile.arrayBuffer(), { status: 200, headers });
|
|
227
|
+
}
|
|
228
|
+
return new Response("Not Found", { status: 404, headers });
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { GenerationTelemetryRecorder } from "@femtomc/mu-control-plane";
|
|
2
|
+
import type { MuConfig } from "./config.js";
|
|
3
|
+
import type { ControlPlaneHandle, ControlPlaneSessionLifecycle, ControlPlaneSessionMutationAction } from "./control_plane_contract.js";
|
|
4
|
+
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
5
|
+
type ConfigReader = (repoRoot: string) => Promise<MuConfig>;
|
|
6
|
+
export type ServerRuntimeOptions = {
|
|
7
|
+
repoRoot?: string;
|
|
8
|
+
controlPlane?: ControlPlaneHandle | null;
|
|
9
|
+
heartbeatScheduler?: ActivityHeartbeatScheduler;
|
|
10
|
+
generationTelemetry?: GenerationTelemetryRecorder;
|
|
11
|
+
config?: MuConfig;
|
|
12
|
+
configReader?: ConfigReader;
|
|
13
|
+
sessionLifecycle?: ControlPlaneSessionLifecycle;
|
|
14
|
+
};
|
|
15
|
+
export type ServerRuntimeCapabilities = {
|
|
16
|
+
session_lifecycle_actions: readonly ControlPlaneSessionMutationAction[];
|
|
17
|
+
control_plane_bootstrapped: boolean;
|
|
18
|
+
control_plane_adapters: string[];
|
|
19
|
+
};
|
|
20
|
+
export type ServerRuntime = {
|
|
21
|
+
repoRoot: string;
|
|
22
|
+
config: MuConfig;
|
|
23
|
+
heartbeatScheduler: ActivityHeartbeatScheduler;
|
|
24
|
+
generationTelemetry: GenerationTelemetryRecorder;
|
|
25
|
+
sessionLifecycle: ControlPlaneSessionLifecycle;
|
|
26
|
+
controlPlane: ControlPlaneHandle | null;
|
|
27
|
+
capabilities: ServerRuntimeCapabilities;
|
|
28
|
+
};
|
|
29
|
+
export declare function composeServerRuntime(options?: ServerRuntimeOptions): Promise<ServerRuntime>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GenerationTelemetryRecorder } from "@femtomc/mu-control-plane";
|
|
2
|
+
import { readMuConfigFile } from "./config.js";
|
|
3
|
+
import { bootstrapControlPlane } from "./control_plane.js";
|
|
4
|
+
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
5
|
+
import { createProcessSessionLifecycle } from "./session_lifecycle.js";
|
|
6
|
+
function computeServerRuntimeCapabilities(controlPlane) {
|
|
7
|
+
return {
|
|
8
|
+
session_lifecycle_actions: ["reload", "update"],
|
|
9
|
+
control_plane_bootstrapped: controlPlane !== null,
|
|
10
|
+
control_plane_adapters: controlPlane?.activeAdapters.map((adapter) => adapter.name) ?? [],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export async function composeServerRuntime(options = {}) {
|
|
14
|
+
const repoRoot = options.repoRoot || process.cwd();
|
|
15
|
+
const readConfig = options.configReader ?? readMuConfigFile;
|
|
16
|
+
const config = options.config ?? (await readConfig(repoRoot));
|
|
17
|
+
const heartbeatScheduler = options.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
|
|
18
|
+
const generationTelemetry = options.generationTelemetry ?? new GenerationTelemetryRecorder();
|
|
19
|
+
const sessionLifecycle = options.sessionLifecycle ?? createProcessSessionLifecycle({ repoRoot });
|
|
20
|
+
const controlPlane = options.controlPlane !== undefined
|
|
21
|
+
? options.controlPlane
|
|
22
|
+
: await bootstrapControlPlane({
|
|
23
|
+
repoRoot,
|
|
24
|
+
config: config.control_plane,
|
|
25
|
+
heartbeatScheduler,
|
|
26
|
+
generation: {
|
|
27
|
+
generation_id: "control-plane-gen-0",
|
|
28
|
+
generation_seq: 0,
|
|
29
|
+
},
|
|
30
|
+
telemetry: generationTelemetry,
|
|
31
|
+
sessionLifecycle,
|
|
32
|
+
terminalEnabled: true,
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
repoRoot,
|
|
36
|
+
config,
|
|
37
|
+
heartbeatScheduler,
|
|
38
|
+
generationTelemetry,
|
|
39
|
+
sessionLifecycle,
|
|
40
|
+
controlPlane,
|
|
41
|
+
capabilities: computeServerRuntimeCapabilities(controlPlane),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function normalizeWakeMode(value) {
|
|
2
|
+
if (typeof value !== "string") {
|
|
3
|
+
return "immediate";
|
|
4
|
+
}
|
|
5
|
+
const normalized = value.trim().toLowerCase().replaceAll("-", "_");
|
|
6
|
+
return normalized === "next_heartbeat" ? "next_heartbeat" : "immediate";
|
|
7
|
+
}
|
|
8
|
+
export function toNonNegativeInt(value, fallback) {
|
|
9
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
10
|
+
return Math.max(0, Math.trunc(value));
|
|
11
|
+
}
|
|
12
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
13
|
+
return Math.max(0, Number.parseInt(value, 10));
|
|
14
|
+
}
|
|
15
|
+
return Math.max(0, Math.trunc(fallback));
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ControlPlaneSessionLifecycle } from "./control_plane_contract.js";
|
|
2
|
+
export type ShellCommandResult = {
|
|
3
|
+
exitCode: number;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
};
|
|
7
|
+
export type ShellCommandRunner = (command: string) => Promise<ShellCommandResult>;
|
|
8
|
+
export declare function createProcessSessionLifecycle(opts: {
|
|
9
|
+
repoRoot: string;
|
|
10
|
+
runShellCommand?: ShellCommandRunner;
|
|
11
|
+
}): ControlPlaneSessionLifecycle;
|