@femtomc/mu-server 26.2.102 → 26.2.104
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 +9 -0
- package/dist/api/heartbeats.js +7 -0
- package/dist/config.d.ts +0 -3
- package/dist/config.js +0 -11
- package/dist/control_plane_adapter_registry.d.ts +0 -1
- package/dist/control_plane_adapter_registry.js +0 -1
- package/dist/control_plane_telegram_generation.js +0 -4
- package/dist/heartbeat_programs.d.ts +20 -0
- package/dist/heartbeat_programs.js +82 -8
- package/dist/heartbeat_scheduler.d.ts +1 -0
- package/dist/heartbeat_scheduler.js +3 -0
- package/dist/server.js +34 -0
- package/dist/server_program_coordination.js +11 -0
- package/package.json +4 -4
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
|
|
@@ -126,12 +131,16 @@ Use `mu store paths --pretty` to resolve `<store>` for the active repo/workspace
|
|
|
126
131
|
"source": "neovim"
|
|
127
132
|
}
|
|
128
133
|
```
|
|
134
|
+
- `session_kind` is optional. When omitted, the server auto-resolves `session_id` across
|
|
135
|
+
`<store>/operator/sessions` and `<store>/control-plane/operator-sessions`.
|
|
136
|
+
If the same id exists in both stores, pass `session_kind` (or `session_dir`) to disambiguate.
|
|
129
137
|
- Optional overrides: `session_file`, `session_dir`, `provider`, `model`, `thinking`, `extension_profile`
|
|
130
138
|
- Response includes: `turn.reply`, `turn.context_entry_id`, `turn.session_file`
|
|
131
139
|
|
|
132
140
|
### Control-plane Coordination Endpoints
|
|
133
141
|
|
|
134
142
|
- Scheduling + coordination:
|
|
143
|
+
- `GET /api/heartbeats/status` (heartbeat scheduler summary: total/enabled/armed)
|
|
135
144
|
- `GET|POST|PATCH|DELETE /api/heartbeats...`
|
|
136
145
|
- `GET|POST|PATCH|DELETE /api/cron...`
|
|
137
146
|
- Heartbeat programs support an optional free-form `prompt` field; when present it becomes the primary wake instruction sent to the operator turn path.
|
package/dist/api/heartbeats.js
CHANGED
|
@@ -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 });
|
package/dist/config.d.ts
CHANGED
|
@@ -12,7 +12,6 @@ export type MuConfig = {
|
|
|
12
12
|
telegram: {
|
|
13
13
|
webhook_secret: string | null;
|
|
14
14
|
bot_token: string | null;
|
|
15
|
-
bot_username: string | null;
|
|
16
15
|
};
|
|
17
16
|
neovim: {
|
|
18
17
|
shared_secret: string | null;
|
|
@@ -44,7 +43,6 @@ export type MuConfigPatch = {
|
|
|
44
43
|
telegram?: {
|
|
45
44
|
webhook_secret?: string | null;
|
|
46
45
|
bot_token?: string | null;
|
|
47
|
-
bot_username?: string | null;
|
|
48
46
|
};
|
|
49
47
|
neovim?: {
|
|
50
48
|
shared_secret?: string | null;
|
|
@@ -76,7 +74,6 @@ export type MuConfigPresence = {
|
|
|
76
74
|
telegram: {
|
|
77
75
|
webhook_secret: boolean;
|
|
78
76
|
bot_token: boolean;
|
|
79
|
-
bot_username: boolean;
|
|
80
77
|
};
|
|
81
78
|
neovim: {
|
|
82
79
|
shared_secret: boolean;
|
package/dist/config.js
CHANGED
|
@@ -15,7 +15,6 @@ export const DEFAULT_MU_CONFIG = {
|
|
|
15
15
|
telegram: {
|
|
16
16
|
webhook_secret: null,
|
|
17
17
|
bot_token: null,
|
|
18
|
-
bot_username: null,
|
|
19
18
|
},
|
|
20
19
|
neovim: {
|
|
21
20
|
shared_secret: null,
|
|
@@ -107,9 +106,6 @@ export function normalizeMuConfig(input) {
|
|
|
107
106
|
if ("bot_token" in telegram) {
|
|
108
107
|
next.control_plane.adapters.telegram.bot_token = normalizeNullableString(telegram.bot_token);
|
|
109
108
|
}
|
|
110
|
-
if ("bot_username" in telegram) {
|
|
111
|
-
next.control_plane.adapters.telegram.bot_username = normalizeNullableString(telegram.bot_username);
|
|
112
|
-
}
|
|
113
109
|
}
|
|
114
110
|
const neovim = asRecord(adapters.neovim);
|
|
115
111
|
if (neovim && "shared_secret" in neovim) {
|
|
@@ -185,9 +181,6 @@ function normalizeMuConfigPatch(input) {
|
|
|
185
181
|
if ("bot_token" in telegram) {
|
|
186
182
|
telegramPatch.bot_token = normalizeNullableString(telegram.bot_token);
|
|
187
183
|
}
|
|
188
|
-
if ("bot_username" in telegram) {
|
|
189
|
-
telegramPatch.bot_username = normalizeNullableString(telegram.bot_username);
|
|
190
|
-
}
|
|
191
184
|
if (Object.keys(telegramPatch).length > 0) {
|
|
192
185
|
patch.control_plane.adapters.telegram = telegramPatch;
|
|
193
186
|
}
|
|
@@ -273,9 +266,6 @@ export function applyMuConfigPatch(base, patchInput) {
|
|
|
273
266
|
if ("bot_token" in adapters.telegram) {
|
|
274
267
|
next.control_plane.adapters.telegram.bot_token = adapters.telegram.bot_token ?? null;
|
|
275
268
|
}
|
|
276
|
-
if ("bot_username" in adapters.telegram) {
|
|
277
|
-
next.control_plane.adapters.telegram.bot_username = adapters.telegram.bot_username ?? null;
|
|
278
|
-
}
|
|
279
269
|
}
|
|
280
270
|
if (adapters.neovim && "shared_secret" in adapters.neovim) {
|
|
281
271
|
next.control_plane.adapters.neovim.shared_secret = adapters.neovim.shared_secret ?? null;
|
|
@@ -372,7 +362,6 @@ export function muConfigPresence(config) {
|
|
|
372
362
|
telegram: {
|
|
373
363
|
webhook_secret: isPresent(config.control_plane.adapters.telegram.webhook_secret),
|
|
374
364
|
bot_token: isPresent(config.control_plane.adapters.telegram.bot_token),
|
|
375
|
-
bot_username: isPresent(config.control_plane.adapters.telegram.bot_username),
|
|
376
365
|
},
|
|
377
366
|
neovim: {
|
|
378
367
|
shared_secret: isPresent(config.control_plane.adapters.neovim.shared_secret),
|
|
@@ -8,7 +8,6 @@ export type DetectedTelegramAdapter = {
|
|
|
8
8
|
name: "telegram";
|
|
9
9
|
webhookSecret: string;
|
|
10
10
|
botToken: string | null;
|
|
11
|
-
botUsername: string | null;
|
|
12
11
|
};
|
|
13
12
|
export type DetectedAdapter = DetectedStaticAdapter | DetectedTelegramAdapter;
|
|
14
13
|
export declare function detectAdapters(config: ControlPlaneConfig): DetectedAdapter[];
|
|
@@ -23,21 +23,18 @@ function telegramAdapterConfigFromControlPlane(config) {
|
|
|
23
23
|
return {
|
|
24
24
|
webhookSecret,
|
|
25
25
|
botToken: config.adapters.telegram.bot_token,
|
|
26
|
-
botUsername: config.adapters.telegram.bot_username,
|
|
27
26
|
};
|
|
28
27
|
}
|
|
29
28
|
function applyTelegramAdapterConfig(base, telegram) {
|
|
30
29
|
const next = cloneControlPlaneConfig(base);
|
|
31
30
|
next.adapters.telegram.webhook_secret = telegram?.webhookSecret ?? null;
|
|
32
31
|
next.adapters.telegram.bot_token = telegram?.botToken ?? null;
|
|
33
|
-
next.adapters.telegram.bot_username = telegram?.botUsername ?? null;
|
|
34
32
|
return next;
|
|
35
33
|
}
|
|
36
34
|
function cloneTelegramAdapterConfig(config) {
|
|
37
35
|
return {
|
|
38
36
|
webhookSecret: config.webhookSecret,
|
|
39
37
|
botToken: config.botToken,
|
|
40
|
-
botUsername: config.botUsername,
|
|
41
38
|
};
|
|
42
39
|
}
|
|
43
40
|
function describeError(err) {
|
|
@@ -112,7 +109,6 @@ export class TelegramAdapterGenerationManager {
|
|
|
112
109
|
outbox: this.#outbox,
|
|
113
110
|
webhookSecret: config.webhookSecret,
|
|
114
111
|
botToken: config.botToken,
|
|
115
|
-
botUsername: config.botUsername,
|
|
116
112
|
deferredIngress: true,
|
|
117
113
|
onOutboxEnqueued: this.#onOutboxEnqueued ?? undefined,
|
|
118
114
|
signalObserver: this.#signalObserver ?? undefined,
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
335
|
+
const nowMs = Math.trunc(this.#nowMs());
|
|
336
|
+
program.updated_at_ms = nowMs;
|
|
283
337
|
this.#applySchedule(program);
|
|
284
338
|
await this.#persist();
|
|
285
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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.
|
|
3
|
+
"version": "26.2.104",
|
|
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.
|
|
34
|
-
"@femtomc/mu-control-plane": "26.2.
|
|
35
|
-
"@femtomc/mu-core": "26.2.
|
|
33
|
+
"@femtomc/mu-agent": "26.2.104",
|
|
34
|
+
"@femtomc/mu-control-plane": "26.2.104",
|
|
35
|
+
"@femtomc/mu-core": "26.2.104"
|
|
36
36
|
}
|
|
37
37
|
}
|