@femtomc/mu-server 26.2.66 → 26.2.67

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.
@@ -101,6 +101,17 @@ export type TelegramGenerationSwapHooks = {
101
101
  timeout_ms: number;
102
102
  }) => void | Promise<void>;
103
103
  };
104
+ export type ControlPlaneSessionMutationAction = "reload" | "update";
105
+ export type ControlPlaneSessionMutationResult = {
106
+ ok: boolean;
107
+ action: ControlPlaneSessionMutationAction;
108
+ message: string;
109
+ details?: Record<string, unknown>;
110
+ };
111
+ export type ControlPlaneSessionMutationHooks = {
112
+ reload?: () => Promise<ControlPlaneSessionMutationResult>;
113
+ update?: () => Promise<ControlPlaneSessionMutationResult>;
114
+ };
104
115
  type DetectedAdapter = {
105
116
  name: "slack";
106
117
  signingSecret: string;
@@ -140,6 +151,7 @@ export type BootstrapControlPlaneOpts = {
140
151
  heartbeatScheduler?: ActivityHeartbeatScheduler;
141
152
  runSupervisorSpawnProcess?: ControlPlaneRunSupervisorOpts["spawnProcess"];
142
153
  runSupervisorHeartbeatIntervalMs?: number;
154
+ sessionMutationHooks?: ControlPlaneSessionMutationHooks;
143
155
  generation?: ControlPlaneGenerationContext;
144
156
  telemetry?: GenerationTelemetryRecorder | null;
145
157
  telegramGenerationHooks?: TelegramGenerationSwapHooks;
@@ -745,6 +745,116 @@ export async function bootstrapControlPlane(opts) {
745
745
  runtime,
746
746
  operator,
747
747
  mutationExecutor: async (record) => {
748
+ if (record.target_type === "reload" || record.target_type === "update") {
749
+ if (record.command_args.length > 0) {
750
+ return {
751
+ terminalState: "failed",
752
+ errorCode: "cli_validation_failed",
753
+ trace: {
754
+ cliCommandKind: record.target_type,
755
+ runRootId: null,
756
+ },
757
+ mutatingEvents: [
758
+ {
759
+ eventType: "session.lifecycle.command.failed",
760
+ payload: {
761
+ action: record.target_type,
762
+ reason: "unexpected_args",
763
+ args: record.command_args,
764
+ },
765
+ },
766
+ ],
767
+ };
768
+ }
769
+ const action = record.target_type;
770
+ const hook = action === "reload"
771
+ ? opts.sessionMutationHooks?.reload
772
+ : opts.sessionMutationHooks?.update;
773
+ if (!hook) {
774
+ return {
775
+ terminalState: "failed",
776
+ errorCode: "session_lifecycle_unavailable",
777
+ trace: {
778
+ cliCommandKind: action,
779
+ runRootId: null,
780
+ },
781
+ mutatingEvents: [
782
+ {
783
+ eventType: "session.lifecycle.command.failed",
784
+ payload: {
785
+ action,
786
+ reason: "hook_missing",
787
+ },
788
+ },
789
+ ],
790
+ };
791
+ }
792
+ try {
793
+ const lifecycle = await hook();
794
+ if (!lifecycle.ok) {
795
+ return {
796
+ terminalState: "failed",
797
+ errorCode: "session_lifecycle_failed",
798
+ trace: {
799
+ cliCommandKind: action,
800
+ runRootId: null,
801
+ },
802
+ mutatingEvents: [
803
+ {
804
+ eventType: "session.lifecycle.command.failed",
805
+ payload: {
806
+ action,
807
+ reason: lifecycle.message,
808
+ details: lifecycle.details ?? null,
809
+ },
810
+ },
811
+ ],
812
+ };
813
+ }
814
+ return {
815
+ terminalState: "completed",
816
+ result: {
817
+ ok: true,
818
+ action,
819
+ message: lifecycle.message,
820
+ details: lifecycle.details ?? null,
821
+ },
822
+ trace: {
823
+ cliCommandKind: action,
824
+ runRootId: null,
825
+ },
826
+ mutatingEvents: [
827
+ {
828
+ eventType: `session.lifecycle.command.${action}`,
829
+ payload: {
830
+ action,
831
+ message: lifecycle.message,
832
+ details: lifecycle.details ?? null,
833
+ },
834
+ },
835
+ ],
836
+ };
837
+ }
838
+ catch (err) {
839
+ return {
840
+ terminalState: "failed",
841
+ errorCode: err instanceof Error && err.message ? err.message : "session_lifecycle_failed",
842
+ trace: {
843
+ cliCommandKind: action,
844
+ runRootId: null,
845
+ },
846
+ mutatingEvents: [
847
+ {
848
+ eventType: "session.lifecycle.command.failed",
849
+ payload: {
850
+ action,
851
+ reason: err instanceof Error && err.message ? err.message : "session_lifecycle_failed",
852
+ },
853
+ },
854
+ ],
855
+ };
856
+ }
857
+ }
748
858
  if (record.target_type === "run start" || record.target_type === "run resume") {
749
859
  try {
750
860
  const launched = await runSupervisor?.startFromCommand(record);
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export type { ControlPlaneActivityEvent, ControlPlaneActivityEventKind, ControlP
2
2
  export { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
3
3
  export type { MuConfig, MuConfigPatch, MuConfigPresence } from "./config.js";
4
4
  export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
5
- export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneHandle } from "./control_plane.js";
5
+ export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneHandle, ControlPlaneSessionMutationAction, ControlPlaneSessionMutationHooks, ControlPlaneSessionMutationResult, } from "./control_plane.js";
6
6
  export { bootstrapControlPlane, detectAdapters } from "./control_plane.js";
7
7
  export type { CronProgramLifecycleAction, CronProgramLifecycleEvent, CronProgramOperationResult, CronProgramRegistryOpts, CronProgramSnapshot, CronProgramStatusSnapshot, CronProgramTarget, CronProgramTickEvent, CronProgramWakeMode, } from "./cron_programs.js";
8
8
  export { CronProgramRegistry } from "./cron_programs.js";
package/dist/server.d.ts CHANGED
@@ -5,7 +5,7 @@ import { ForumStore } from "@femtomc/mu-forum";
5
5
  import { IssueStore } from "@femtomc/mu-issue";
6
6
  import { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
7
7
  import { type MuConfig } from "./config.js";
8
- import { type ControlPlaneConfig, type ControlPlaneHandle } from "./control_plane.js";
8
+ import { type ControlPlaneConfig, type ControlPlaneHandle, type ControlPlaneSessionMutationHooks } from "./control_plane.js";
9
9
  import { CronProgramRegistry } from "./cron_programs.js";
10
10
  import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
11
11
  import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
@@ -30,6 +30,7 @@ export type ServerOptions = {
30
30
  config?: MuConfig;
31
31
  configReader?: ConfigReader;
32
32
  configWriter?: ConfigWriter;
33
+ sessionMutationHooks?: ControlPlaneSessionMutationHooks;
33
34
  };
34
35
  export type ServerContext = {
35
36
  repoRoot: string;
package/dist/server.js CHANGED
@@ -46,6 +46,12 @@ function toNonNegativeInt(value, fallback) {
46
46
  }
47
47
  return Math.max(0, Math.trunc(fallback));
48
48
  }
49
+ function shellQuoteArg(value) {
50
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
51
+ }
52
+ function shellJoin(args) {
53
+ return args.map(shellQuoteArg).join(" ");
54
+ }
49
55
  function describeError(err) {
50
56
  if (err instanceof Error)
51
57
  return err.message;
@@ -165,6 +171,144 @@ export function createServer(options = {}) {
165
171
  const autoRunHeartbeatEveryMs = Math.max(1_000, toNonNegativeInt(options.autoRunHeartbeatEveryMs, DEFAULT_AUTO_RUN_HEARTBEAT_EVERY_MS));
166
172
  const operatorWakeLastByKey = new Map();
167
173
  const autoRunHeartbeatProgramByJobId = new Map();
174
+ let sessionMutationScheduled = null;
175
+ const runShellCommand = async (command) => {
176
+ const proc = Bun.spawn({
177
+ cmd: ["bash", "-lc", command],
178
+ cwd: repoRoot,
179
+ env: Bun.env,
180
+ stdin: "ignore",
181
+ stdout: "pipe",
182
+ stderr: "pipe",
183
+ });
184
+ const [exitCode, stdout, stderr] = await Promise.all([
185
+ proc.exited,
186
+ proc.stdout ? new Response(proc.stdout).text() : Promise.resolve(""),
187
+ proc.stderr ? new Response(proc.stderr).text() : Promise.resolve(""),
188
+ ]);
189
+ return {
190
+ exitCode: Number.isFinite(exitCode) ? Number(exitCode) : 1,
191
+ stdout,
192
+ stderr,
193
+ };
194
+ };
195
+ const defaultSessionMutationHooks = {
196
+ reload: async () => {
197
+ if (sessionMutationScheduled) {
198
+ return {
199
+ ok: true,
200
+ action: sessionMutationScheduled.action,
201
+ message: `session ${sessionMutationScheduled.action} already scheduled`,
202
+ details: { scheduled_at_ms: sessionMutationScheduled.at_ms },
203
+ };
204
+ }
205
+ const nowMs = Date.now();
206
+ const restartCommand = Bun.env.MU_RESTART_COMMAND?.trim();
207
+ const inferredArgs = process.argv[0] === process.execPath
208
+ ? [process.execPath, ...process.argv.slice(1)]
209
+ : [process.execPath, ...process.argv];
210
+ const restartShellCommand = restartCommand && restartCommand.length > 0 ? restartCommand : shellJoin(inferredArgs);
211
+ if (!restartShellCommand.trim()) {
212
+ return {
213
+ ok: false,
214
+ action: "reload",
215
+ message: "unable to determine restart command",
216
+ };
217
+ }
218
+ const exitDelayMs = 1_000;
219
+ const launchDelayMs = exitDelayMs + 300;
220
+ const delayedShellCommand = `sleep ${(launchDelayMs / 1_000).toFixed(2)}; ${restartShellCommand}`;
221
+ let spawnedPid = null;
222
+ try {
223
+ const proc = Bun.spawn({
224
+ cmd: ["bash", "-lc", delayedShellCommand],
225
+ cwd: repoRoot,
226
+ env: Bun.env,
227
+ stdin: "ignore",
228
+ stdout: "inherit",
229
+ stderr: "inherit",
230
+ });
231
+ spawnedPid = proc.pid ?? null;
232
+ }
233
+ catch (err) {
234
+ return {
235
+ ok: false,
236
+ action: "reload",
237
+ message: `failed to spawn replacement process: ${describeError(err)}`,
238
+ };
239
+ }
240
+ sessionMutationScheduled = { action: "reload", at_ms: nowMs };
241
+ setTimeout(() => {
242
+ process.exit(0);
243
+ }, exitDelayMs);
244
+ return {
245
+ ok: true,
246
+ action: "reload",
247
+ message: "reload scheduled; restarting process",
248
+ details: {
249
+ restart_command: restartShellCommand,
250
+ restart_launch_command: delayedShellCommand,
251
+ spawned_pid: spawnedPid,
252
+ exit_delay_ms: exitDelayMs,
253
+ launch_delay_ms: launchDelayMs,
254
+ },
255
+ };
256
+ },
257
+ update: async () => {
258
+ if (sessionMutationScheduled) {
259
+ return {
260
+ ok: true,
261
+ action: sessionMutationScheduled.action,
262
+ message: `session ${sessionMutationScheduled.action} already scheduled`,
263
+ details: { scheduled_at_ms: sessionMutationScheduled.at_ms },
264
+ };
265
+ }
266
+ const updateCommand = Bun.env.MU_UPDATE_COMMAND?.trim() || "npm install -g @femtomc/mu@latest";
267
+ const result = await runShellCommand(updateCommand);
268
+ if (result.exitCode !== 0) {
269
+ return {
270
+ ok: false,
271
+ action: "update",
272
+ message: `update command failed (exit ${result.exitCode})`,
273
+ details: {
274
+ update_command: updateCommand,
275
+ stdout: result.stdout.slice(-4_000),
276
+ stderr: result.stderr.slice(-4_000),
277
+ },
278
+ };
279
+ }
280
+ const reloadResult = await defaultSessionMutationHooks.reload?.();
281
+ if (!reloadResult) {
282
+ return {
283
+ ok: false,
284
+ action: "update",
285
+ message: "reload hook unavailable after update",
286
+ };
287
+ }
288
+ if (!reloadResult.ok) {
289
+ return {
290
+ ok: false,
291
+ action: "update",
292
+ message: reloadResult.message,
293
+ details: {
294
+ update_command: updateCommand,
295
+ reload: reloadResult.details ?? null,
296
+ },
297
+ };
298
+ }
299
+ return {
300
+ ok: true,
301
+ action: "update",
302
+ message: "update applied; reload scheduled",
303
+ details: {
304
+ update_command: updateCommand,
305
+ reload: reloadResult.details ?? null,
306
+ update_stdout_tail: result.stdout.slice(-1_000),
307
+ },
308
+ };
309
+ },
310
+ };
311
+ const sessionMutationHooks = options.sessionMutationHooks ?? defaultSessionMutationHooks;
168
312
  const emitOperatorWake = async (opts) => {
169
313
  const dedupeKey = opts.dedupeKey.trim();
170
314
  if (!dedupeKey) {
@@ -214,6 +358,7 @@ export function createServer(options = {}) {
214
358
  heartbeatScheduler,
215
359
  generation,
216
360
  telemetry: generationTelemetry,
361
+ sessionMutationHooks,
217
362
  terminalEnabled: true,
218
363
  });
219
364
  });
@@ -1151,6 +1296,12 @@ export function createServer(options = {}) {
1151
1296
  commandText = `mu! run interrupt${rootId ? ` ${rootId}` : ""}`;
1152
1297
  break;
1153
1298
  }
1299
+ case "reload":
1300
+ commandText = "/mu reload";
1301
+ break;
1302
+ case "update":
1303
+ commandText = "/mu update";
1304
+ break;
1154
1305
  case "status":
1155
1306
  commandText = "/mu status";
1156
1307
  break;
@@ -2112,6 +2263,7 @@ export async function createServerAsync(options = {}) {
2112
2263
  generation_seq: 0,
2113
2264
  },
2114
2265
  telemetry: generationTelemetry,
2266
+ sessionMutationHooks: options.sessionMutationHooks,
2115
2267
  terminalEnabled: true,
2116
2268
  });
2117
2269
  const serverConfig = createServer({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.66",
3
+ "version": "26.2.67",
4
4
  "description": "HTTP API server for mu status, work items, messaging setup, and web UI.",
5
5
  "keywords": [
6
6
  "mu",
@@ -31,10 +31,10 @@
31
31
  "start": "bun run dist/cli.js"
32
32
  },
33
33
  "dependencies": {
34
- "@femtomc/mu-agent": "26.2.65",
35
- "@femtomc/mu-control-plane": "26.2.65",
36
- "@femtomc/mu-core": "26.2.65",
37
- "@femtomc/mu-forum": "26.2.65",
38
- "@femtomc/mu-issue": "26.2.65"
34
+ "@femtomc/mu-agent": "26.2.67",
35
+ "@femtomc/mu-control-plane": "26.2.67",
36
+ "@femtomc/mu-core": "26.2.67",
37
+ "@femtomc/mu-forum": "26.2.67",
38
+ "@femtomc/mu-issue": "26.2.67"
39
39
  }
40
40
  }