@femtomc/mu-server 26.2.103 → 26.2.105

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 CHANGED
@@ -37,6 +37,11 @@ Use `mu store paths --pretty` to resolve `<store>` for the active repo/workspace
37
37
 
38
38
  ## API Endpoints
39
39
 
40
+ > Security note: `mu-server` is designed for trusted local/operator environments.
41
+ > The HTTP surface does not provide built-in authn/authz middleware for `/api/*`.
42
+ > Do not expose this port directly to untrusted networks without an external
43
+ > auth/reverse-proxy layer.
44
+
40
45
  ### Health Check
41
46
 
42
47
  - `GET /healthz` or `GET /health` - Returns 200 OK
@@ -135,6 +140,7 @@ Use `mu store paths --pretty` to resolve `<store>` for the active repo/workspace
135
140
  ### Control-plane Coordination Endpoints
136
141
 
137
142
  - Scheduling + coordination:
143
+ - `GET /api/heartbeats/status` (heartbeat scheduler summary: total/enabled/armed)
138
144
  - `GET|POST|PATCH|DELETE /api/heartbeats...`
139
145
  - `GET|POST|PATCH|DELETE /api/cron...`
140
146
  - Heartbeat programs support an optional free-form `prompt` field; when present it becomes the primary wake instruction sent to the operator turn path.
@@ -1,5 +1,12 @@
1
1
  export async function heartbeatRoutes(request, url, deps, headers) {
2
2
  const path = url.pathname;
3
+ if (path === "/api/heartbeats/status") {
4
+ if (request.method !== "GET") {
5
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
6
+ }
7
+ const status = await deps.heartbeatPrograms.status();
8
+ return Response.json(status, { headers });
9
+ }
3
10
  if (path === "/api/heartbeats") {
4
11
  if (request.method !== "GET") {
5
12
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
@@ -15,6 +15,24 @@ export type HeartbeatProgramSnapshot = {
15
15
  last_result: "ok" | "coalesced" | "failed" | null;
16
16
  last_error: string | null;
17
17
  };
18
+ export type HeartbeatProgramLifecycleAction = "created" | "updated" | "deleted";
19
+ export type HeartbeatProgramLifecycleEvent = {
20
+ ts_ms: number;
21
+ action: HeartbeatProgramLifecycleAction;
22
+ program_id: string;
23
+ message: string;
24
+ program: HeartbeatProgramSnapshot | null;
25
+ };
26
+ export type HeartbeatProgramStatusSnapshot = {
27
+ count: number;
28
+ enabled_count: number;
29
+ armed_count: number;
30
+ armed: Array<{
31
+ program_id: string;
32
+ every_ms: number;
33
+ last_triggered_at_ms: number | null;
34
+ }>;
35
+ };
18
36
  export type HeartbeatProgramOperationResult = {
19
37
  ok: boolean;
20
38
  reason: "not_found" | "missing_target" | "not_running" | "failed" | null;
@@ -52,6 +70,7 @@ export type HeartbeatProgramRegistryOpts = {
52
70
  triggeredAtMs: number;
53
71
  }) => Promise<HeartbeatProgramDispatchResult>;
54
72
  onTickEvent?: (event: HeartbeatProgramTickEvent) => void | Promise<void>;
73
+ onLifecycleEvent?: (event: HeartbeatProgramLifecycleEvent) => void | Promise<void>;
55
74
  };
56
75
  export declare class HeartbeatProgramRegistry {
57
76
  #private;
@@ -60,6 +79,7 @@ export declare class HeartbeatProgramRegistry {
60
79
  enabled?: boolean;
61
80
  limit?: number;
62
81
  }): Promise<HeartbeatProgramSnapshot[]>;
82
+ status(): Promise<HeartbeatProgramStatusSnapshot>;
63
83
  get(programId: string): Promise<HeartbeatProgramSnapshot | null>;
64
84
  create(opts: {
65
85
  title: string;
@@ -72,6 +72,7 @@ export class HeartbeatProgramRegistry {
72
72
  #heartbeatScheduler;
73
73
  #dispatchWake;
74
74
  #onTickEvent;
75
+ #onLifecycleEvent;
75
76
  #nowMs;
76
77
  #programs = new Map();
77
78
  #loaded = null;
@@ -79,10 +80,14 @@ export class HeartbeatProgramRegistry {
79
80
  this.#heartbeatScheduler = opts.heartbeatScheduler;
80
81
  this.#dispatchWake = opts.dispatchWake;
81
82
  this.#onTickEvent = opts.onTickEvent;
83
+ this.#onLifecycleEvent = opts.onLifecycleEvent;
82
84
  this.#nowMs = opts.nowMs ?? defaultNowMs;
83
85
  this.#store =
84
86
  opts.store ??
85
87
  new FsJsonlStore(join(getStorePaths(opts.repoRoot).storeDir, HEARTBEAT_PROGRAMS_FILENAME));
88
+ void this.#ensureLoaded().catch(() => {
89
+ // Best effort eager load for startup re-arming.
90
+ });
86
91
  }
87
92
  #scheduleId(programId) {
88
93
  return `heartbeat-program:${programId}`;
@@ -93,6 +98,16 @@ export class HeartbeatProgramRegistry {
93
98
  metadata: { ...program.metadata },
94
99
  };
95
100
  }
101
+ #normalizeEveryMs(raw) {
102
+ if (!Number.isFinite(raw)) {
103
+ return 0;
104
+ }
105
+ const normalized = Math.max(0, Math.trunc(raw));
106
+ if (normalized <= 0) {
107
+ return 0;
108
+ }
109
+ return Math.max(this.#heartbeatScheduler.getMinIntervalMs(), normalized);
110
+ }
96
111
  async #ensureLoaded() {
97
112
  if (!this.#loaded) {
98
113
  this.#loaded = this.#load();
@@ -106,6 +121,7 @@ export class HeartbeatProgramRegistry {
106
121
  if (!normalized) {
107
122
  continue;
108
123
  }
124
+ normalized.every_ms = this.#normalizeEveryMs(normalized.every_ms);
109
125
  this.#programs.set(normalized.program_id, normalized);
110
126
  }
111
127
  for (const program of this.#programs.values()) {
@@ -118,6 +134,7 @@ export class HeartbeatProgramRegistry {
118
134
  }
119
135
  #applySchedule(program) {
120
136
  const scheduleId = this.#scheduleId(program.program_id);
137
+ program.every_ms = this.#normalizeEveryMs(program.every_ms);
121
138
  if (!program.enabled || program.every_ms <= 0) {
122
139
  this.#heartbeatScheduler.unregister(scheduleId);
123
140
  return;
@@ -136,6 +153,12 @@ export class HeartbeatProgramRegistry {
136
153
  }
137
154
  await this.#onTickEvent(event);
138
155
  }
156
+ async #emitLifecycleEvent(event) {
157
+ if (!this.#onLifecycleEvent) {
158
+ return;
159
+ }
160
+ await this.#onLifecycleEvent(event);
161
+ }
139
162
  async #tickProgram(programId, reason) {
140
163
  const program = this.#programs.get(programId);
141
164
  if (!program) {
@@ -217,6 +240,28 @@ export class HeartbeatProgramRegistry {
217
240
  .slice(0, limit)
218
241
  .map((program) => this.#snapshot(program));
219
242
  }
243
+ async status() {
244
+ await this.#ensureLoaded();
245
+ const programs = sortPrograms([...this.#programs.values()]);
246
+ const armed = programs
247
+ .filter((program) => {
248
+ if (!program.enabled || program.every_ms <= 0) {
249
+ return false;
250
+ }
251
+ return this.#heartbeatScheduler.has(this.#scheduleId(program.program_id));
252
+ })
253
+ .map((program) => ({
254
+ program_id: program.program_id,
255
+ every_ms: program.every_ms,
256
+ last_triggered_at_ms: program.last_triggered_at_ms,
257
+ }));
258
+ return {
259
+ count: programs.length,
260
+ enabled_count: programs.filter((program) => program.enabled).length,
261
+ armed_count: armed.length,
262
+ armed,
263
+ };
264
+ }
220
265
  async get(programId) {
221
266
  await this.#ensureLoaded();
222
267
  const program = this.#programs.get(programId.trim());
@@ -235,9 +280,7 @@ export class HeartbeatProgramRegistry {
235
280
  title,
236
281
  prompt: normalizePrompt(opts.prompt),
237
282
  enabled: opts.enabled !== false,
238
- every_ms: typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs)
239
- ? Math.max(0, Math.trunc(opts.everyMs))
240
- : 15_000,
283
+ every_ms: this.#normalizeEveryMs(typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs) ? opts.everyMs : 15_000),
241
284
  reason: opts.reason?.trim() || "scheduled",
242
285
  metadata: sanitizeMetadata(opts.metadata),
243
286
  created_at_ms: nowMs,
@@ -249,7 +292,17 @@ export class HeartbeatProgramRegistry {
249
292
  this.#programs.set(program.program_id, program);
250
293
  this.#applySchedule(program);
251
294
  await this.#persist();
252
- return this.#snapshot(program);
295
+ const snapshot = this.#snapshot(program);
296
+ await this.#emitLifecycleEvent({
297
+ ts_ms: nowMs,
298
+ action: "created",
299
+ program_id: program.program_id,
300
+ message: `heartbeat program created: ${program.title}`,
301
+ program: snapshot,
302
+ }).catch(() => {
303
+ // best effort only
304
+ });
305
+ return snapshot;
253
306
  }
254
307
  async update(opts) {
255
308
  await this.#ensureLoaded();
@@ -268,7 +321,7 @@ export class HeartbeatProgramRegistry {
268
321
  program.prompt = normalizePrompt(opts.prompt);
269
322
  }
270
323
  if (typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs)) {
271
- program.every_ms = Math.max(0, Math.trunc(opts.everyMs));
324
+ program.every_ms = this.#normalizeEveryMs(opts.everyMs);
272
325
  }
273
326
  if (typeof opts.reason === "string") {
274
327
  program.reason = opts.reason.trim() || "scheduled";
@@ -279,10 +332,21 @@ export class HeartbeatProgramRegistry {
279
332
  if (opts.metadata) {
280
333
  program.metadata = sanitizeMetadata(opts.metadata);
281
334
  }
282
- program.updated_at_ms = Math.trunc(this.#nowMs());
335
+ const nowMs = Math.trunc(this.#nowMs());
336
+ program.updated_at_ms = nowMs;
283
337
  this.#applySchedule(program);
284
338
  await this.#persist();
285
- return { ok: true, reason: null, program: this.#snapshot(program) };
339
+ const snapshot = this.#snapshot(program);
340
+ await this.#emitLifecycleEvent({
341
+ ts_ms: nowMs,
342
+ action: "updated",
343
+ program_id: program.program_id,
344
+ message: `heartbeat program updated: ${program.title}`,
345
+ program: snapshot,
346
+ }).catch(() => {
347
+ // best effort only
348
+ });
349
+ return { ok: true, reason: null, program: snapshot };
286
350
  }
287
351
  async remove(programId) {
288
352
  await this.#ensureLoaded();
@@ -294,10 +358,20 @@ export class HeartbeatProgramRegistry {
294
358
  if (!program) {
295
359
  return { ok: false, reason: "not_found", program: null };
296
360
  }
361
+ const removed = this.#snapshot(program);
297
362
  this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
298
363
  this.#programs.delete(normalizedId);
299
364
  await this.#persist();
300
- return { ok: true, reason: null, program: this.#snapshot(program) };
365
+ await this.#emitLifecycleEvent({
366
+ ts_ms: Math.trunc(this.#nowMs()),
367
+ action: "deleted",
368
+ program_id: removed.program_id,
369
+ message: `heartbeat program deleted: ${removed.title}`,
370
+ program: removed,
371
+ }).catch(() => {
372
+ // best effort only
373
+ });
374
+ return { ok: true, reason: null, program: removed };
301
375
  }
302
376
  async trigger(opts) {
303
377
  await this.#ensureLoaded();
@@ -34,5 +34,6 @@ export declare class ActivityHeartbeatScheduler {
34
34
  unregister(activityIdRaw: string): boolean;
35
35
  has(activityIdRaw: string): boolean;
36
36
  listActivityIds(): string[];
37
+ getMinIntervalMs(): number;
37
38
  stop(): void;
38
39
  }
@@ -272,6 +272,9 @@ export class ActivityHeartbeatScheduler {
272
272
  listActivityIds() {
273
273
  return [...this.#states.keys()];
274
274
  }
275
+ getMinIntervalMs() {
276
+ return this.#minIntervalMs;
277
+ }
275
278
  stop() {
276
279
  for (const state of this.#states.values()) {
277
280
  this.#disposeState(state);
package/dist/server.js CHANGED
@@ -10,6 +10,9 @@ import { createServerProgramCoordination } from "./server_program_coordination.j
10
10
  import { createServerRequestHandler } from "./server_routing.js";
11
11
  import { toNonNegativeInt } from "./server_types.js";
12
12
  const DEFAULT_OPERATOR_WAKE_COALESCE_MS = 2_000;
13
+ const OPERATOR_WAKE_DEDUPE_MAP_MAX_ENTRIES = 2_048;
14
+ const OPERATOR_WAKE_DEDUPE_MIN_RETENTION_MS = 60_000;
15
+ const OPERATOR_WAKE_DEDUPE_PRUNE_INTERVAL_MS = 15_000;
13
16
  export { createProcessSessionLifecycle };
14
17
  function describeError(err) {
15
18
  if (err instanceof Error)
@@ -101,6 +104,30 @@ function buildWakeTurnIngressText(opts) {
101
104
  "Respond conversationally with exactly one concise operator message suitable for immediate broadcast.",
102
105
  ].join("\n");
103
106
  }
107
+ function pruneOperatorWakeDedupeMap(opts) {
108
+ if (opts.map.size === 0) {
109
+ return opts.lastPrunedAtMs;
110
+ }
111
+ const shouldPruneByTime = opts.nowMs - opts.lastPrunedAtMs >= OPERATOR_WAKE_DEDUPE_PRUNE_INTERVAL_MS;
112
+ const shouldPruneBySize = opts.map.size > OPERATOR_WAKE_DEDUPE_MAP_MAX_ENTRIES;
113
+ if (!shouldPruneByTime && !shouldPruneBySize) {
114
+ return opts.lastPrunedAtMs;
115
+ }
116
+ const retentionMs = Math.max(OPERATOR_WAKE_DEDUPE_MIN_RETENTION_MS, Math.max(0, opts.coalesceMs) * 4);
117
+ for (const [dedupeKey, tsMs] of opts.map.entries()) {
118
+ if (opts.nowMs - tsMs >= retentionMs) {
119
+ opts.map.delete(dedupeKey);
120
+ }
121
+ }
122
+ while (opts.map.size > OPERATOR_WAKE_DEDUPE_MAP_MAX_ENTRIES) {
123
+ const oldest = opts.map.keys().next().value;
124
+ if (typeof oldest !== "string" || oldest.length === 0) {
125
+ break;
126
+ }
127
+ opts.map.delete(oldest);
128
+ }
129
+ return opts.nowMs;
130
+ }
104
131
  export function createContext(repoRoot) {
105
132
  const paths = getStorePaths(repoRoot);
106
133
  const eventsStore = new FsJsonlStore(paths.eventsPath);
@@ -118,6 +145,7 @@ function createServer(options = {}) {
118
145
  const heartbeatScheduler = options.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
119
146
  const operatorWakeCoalesceMs = toNonNegativeInt(options.operatorWakeCoalesceMs, DEFAULT_OPERATOR_WAKE_COALESCE_MS);
120
147
  const operatorWakeLastByKey = new Map();
148
+ let operatorWakeDedupeLastPrunedAtMs = 0;
121
149
  const sessionLifecycle = options.sessionLifecycle ?? createProcessSessionLifecycle({ repoRoot });
122
150
  const emitWakeDeliveryEvent = async (payload) => {
123
151
  await context.eventLog.emit("operator.wake.delivery", {
@@ -132,6 +160,12 @@ function createServer(options = {}) {
132
160
  }
133
161
  const nowMs = Date.now();
134
162
  const coalesceMs = Math.max(0, Math.trunc(opts.coalesceMs ?? operatorWakeCoalesceMs));
163
+ operatorWakeDedupeLastPrunedAtMs = pruneOperatorWakeDedupeMap({
164
+ nowMs,
165
+ coalesceMs,
166
+ map: operatorWakeLastByKey,
167
+ lastPrunedAtMs: operatorWakeDedupeLastPrunedAtMs,
168
+ });
135
169
  const previous = operatorWakeLastByKey.get(dedupeKey);
136
170
  if (typeof previous === "number" && nowMs - previous < coalesceMs) {
137
171
  return { status: "coalesced", reason: "coalesced_window" };
@@ -33,6 +33,17 @@ export function createServerProgramCoordination(opts) {
33
33
  }
34
34
  return { status: "ok" };
35
35
  },
36
+ onLifecycleEvent: async (event) => {
37
+ await opts.eventLog.emit("heartbeat_program.lifecycle", {
38
+ source: "mu-server.heartbeat-programs",
39
+ payload: {
40
+ action: event.action,
41
+ program_id: event.program_id,
42
+ message: event.message,
43
+ program: event.program,
44
+ },
45
+ });
46
+ },
36
47
  onTickEvent: async (event) => {
37
48
  await opts.eventLog.emit("heartbeat_program.tick", {
38
49
  source: "mu-server.heartbeat-programs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.103",
3
+ "version": "26.2.105",
4
4
  "description": "HTTP API server for mu control-plane transport/session plus run/activity scheduling coordination.",
5
5
  "keywords": [
6
6
  "mu",
@@ -30,8 +30,8 @@
30
30
  "start": "bun run dist/cli.js"
31
31
  },
32
32
  "dependencies": {
33
- "@femtomc/mu-agent": "26.2.103",
34
- "@femtomc/mu-control-plane": "26.2.103",
35
- "@femtomc/mu-core": "26.2.103"
33
+ "@femtomc/mu-agent": "26.2.105",
34
+ "@femtomc/mu-control-plane": "26.2.105",
35
+ "@femtomc/mu-core": "26.2.105"
36
36
  }
37
37
  }