@femtomc/mu-server 26.2.55 → 26.2.56

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.
@@ -1,7 +1,15 @@
1
- import { ApprovedCommandBroker, CommandContextResolver, MessagingOperatorRuntime, PiMessagingOperatorBackend, operatorExtensionPaths, } from "@femtomc/mu-agent";
2
- import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneOutboxDispatcher, correlationFromCommandRecord, ControlPlaneRuntime, DiscordControlPlaneAdapter, getControlPlanePaths, SlackControlPlaneAdapter, TelegramControlPlaneAdapter, } from "@femtomc/mu-control-plane";
1
+ import { ApprovedCommandBroker, CommandContextResolver, MessagingOperatorRuntime, operatorExtensionPaths, PiMessagingOperatorBackend, } from "@femtomc/mu-agent";
2
+ import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneOutboxDispatcher, ControlPlaneRuntime, correlationFromCommandRecord, DiscordControlPlaneAdapter, getControlPlanePaths, SlackControlPlaneAdapter, TelegramControlPlaneAdapter, TelegramControlPlaneAdapterSpec, } from "@femtomc/mu-control-plane";
3
3
  import { DEFAULT_MU_CONFIG } from "./config.js";
4
4
  import { ControlPlaneRunSupervisor, } from "./run_supervisor.js";
5
+ function generationTags(generation, component) {
6
+ return {
7
+ generation_id: generation.generation_id,
8
+ generation_seq: generation.generation_seq,
9
+ supervisor: "control_plane",
10
+ component,
11
+ };
12
+ }
5
13
  export function detectAdapters(config) {
6
14
  const adapters = [];
7
15
  const slackSecret = config.adapters.slack.signing_secret;
@@ -23,6 +31,526 @@ export function detectAdapters(config) {
23
31
  }
24
32
  return adapters;
25
33
  }
34
+ const TELEGRAM_GENERATION_SUPERVISOR_ID = "telegram-adapter";
35
+ const TELEGRAM_WARMUP_TIMEOUT_MS = 2_000;
36
+ const TELEGRAM_DRAIN_TIMEOUT_MS = 5_000;
37
+ function cloneControlPlaneConfig(config) {
38
+ return JSON.parse(JSON.stringify(config));
39
+ }
40
+ function controlPlaneNonTelegramFingerprint(config) {
41
+ return JSON.stringify({
42
+ adapters: {
43
+ slack: config.adapters.slack,
44
+ discord: config.adapters.discord,
45
+ gmail: config.adapters.gmail,
46
+ },
47
+ operator: config.operator,
48
+ });
49
+ }
50
+ function telegramAdapterConfigFromControlPlane(config) {
51
+ const webhookSecret = config.adapters.telegram.webhook_secret;
52
+ if (!webhookSecret) {
53
+ return null;
54
+ }
55
+ return {
56
+ webhookSecret,
57
+ botToken: config.adapters.telegram.bot_token,
58
+ botUsername: config.adapters.telegram.bot_username,
59
+ };
60
+ }
61
+ function applyTelegramAdapterConfig(base, telegram) {
62
+ const next = cloneControlPlaneConfig(base);
63
+ next.adapters.telegram.webhook_secret = telegram?.webhookSecret ?? null;
64
+ next.adapters.telegram.bot_token = telegram?.botToken ?? null;
65
+ next.adapters.telegram.bot_username = telegram?.botUsername ?? null;
66
+ return next;
67
+ }
68
+ function cloneTelegramAdapterConfig(config) {
69
+ return {
70
+ webhookSecret: config.webhookSecret,
71
+ botToken: config.botToken,
72
+ botUsername: config.botUsername,
73
+ };
74
+ }
75
+ function describeError(err) {
76
+ if (err instanceof Error && err.message.trim().length > 0) {
77
+ return err.message;
78
+ }
79
+ return String(err);
80
+ }
81
+ async function runWithTimeout(opts) {
82
+ if (opts.timeoutMs <= 0) {
83
+ return await opts.run();
84
+ }
85
+ return await new Promise((resolve, reject) => {
86
+ let settled = false;
87
+ const timer = setTimeout(() => {
88
+ if (settled) {
89
+ return;
90
+ }
91
+ settled = true;
92
+ reject(new Error(opts.timeoutMessage));
93
+ }, opts.timeoutMs);
94
+ void opts
95
+ .run()
96
+ .then((value) => {
97
+ if (settled) {
98
+ return;
99
+ }
100
+ settled = true;
101
+ clearTimeout(timer);
102
+ resolve(value);
103
+ })
104
+ .catch((err) => {
105
+ if (settled) {
106
+ return;
107
+ }
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ reject(err);
111
+ });
112
+ });
113
+ }
114
+ class TelegramAdapterGenerationManager {
115
+ #pipeline;
116
+ #outbox;
117
+ #nowMs;
118
+ #onOutboxEnqueued;
119
+ #signalObserver;
120
+ #hooks;
121
+ #generationSeq = -1;
122
+ #active = null;
123
+ #previousConfig = null;
124
+ #activeControlPlaneConfig;
125
+ constructor(opts) {
126
+ this.#pipeline = opts.pipeline;
127
+ this.#outbox = opts.outbox;
128
+ this.#nowMs = opts.nowMs ?? Date.now;
129
+ this.#onOutboxEnqueued = opts.onOutboxEnqueued ?? null;
130
+ this.#signalObserver = opts.signalObserver ?? null;
131
+ this.#hooks = opts.hooks ?? null;
132
+ this.#activeControlPlaneConfig = cloneControlPlaneConfig(opts.initialConfig);
133
+ }
134
+ #nextGeneration() {
135
+ const nextSeq = this.#generationSeq + 1;
136
+ return {
137
+ generation_id: `${TELEGRAM_GENERATION_SUPERVISOR_ID}-gen-${nextSeq}`,
138
+ generation_seq: nextSeq,
139
+ };
140
+ }
141
+ #buildAdapter(config, opts) {
142
+ return new TelegramControlPlaneAdapter({
143
+ pipeline: this.#pipeline,
144
+ outbox: this.#outbox,
145
+ webhookSecret: config.webhookSecret,
146
+ botUsername: config.botUsername,
147
+ deferredIngress: true,
148
+ onOutboxEnqueued: this.#onOutboxEnqueued ?? undefined,
149
+ signalObserver: this.#signalObserver ?? undefined,
150
+ acceptIngress: opts.acceptIngress,
151
+ ingressDrainEnabled: opts.ingressDrainEnabled,
152
+ nowMs: this.#nowMs,
153
+ });
154
+ }
155
+ async initialize() {
156
+ const initial = telegramAdapterConfigFromControlPlane(this.#activeControlPlaneConfig);
157
+ if (!initial) {
158
+ return;
159
+ }
160
+ const generation = this.#nextGeneration();
161
+ const adapter = this.#buildAdapter(initial, {
162
+ acceptIngress: true,
163
+ ingressDrainEnabled: true,
164
+ });
165
+ await adapter.warmup();
166
+ const health = await adapter.healthCheck();
167
+ if (!health.ok) {
168
+ await adapter.stop({ force: true, reason: "startup_health_gate_failed" });
169
+ throw new Error(`telegram adapter warmup health failed: ${health.reason}`);
170
+ }
171
+ this.#active = {
172
+ generation,
173
+ config: cloneTelegramAdapterConfig(initial),
174
+ adapter,
175
+ };
176
+ this.#generationSeq = generation.generation_seq;
177
+ }
178
+ hasActiveGeneration() {
179
+ return this.#active != null;
180
+ }
181
+ activeGeneration() {
182
+ return this.#active ? { ...this.#active.generation } : null;
183
+ }
184
+ activeBotToken() {
185
+ return this.#active?.config.botToken ?? null;
186
+ }
187
+ activeAdapter() {
188
+ return this.#active?.adapter ?? null;
189
+ }
190
+ canHandleConfig(nextConfig, reason) {
191
+ if (reason === "rollback") {
192
+ return true;
193
+ }
194
+ return (controlPlaneNonTelegramFingerprint(nextConfig) ===
195
+ controlPlaneNonTelegramFingerprint(this.#activeControlPlaneConfig));
196
+ }
197
+ async #rollbackToPrevious(opts) {
198
+ if (!opts.previous) {
199
+ return { ok: false, error: "rollback_unavailable" };
200
+ }
201
+ try {
202
+ opts.previous.adapter.activateIngress();
203
+ this.#active = opts.previous;
204
+ this.#previousConfig = cloneTelegramAdapterConfig(opts.failedRecord.config);
205
+ await opts.failedRecord.adapter.stop({ force: true, reason: `rollback:${opts.reason}` });
206
+ this.#activeControlPlaneConfig = applyTelegramAdapterConfig(this.#activeControlPlaneConfig, opts.previous.config);
207
+ return { ok: true };
208
+ }
209
+ catch (err) {
210
+ return { ok: false, error: describeError(err) };
211
+ }
212
+ }
213
+ async reload(opts) {
214
+ if (!this.canHandleConfig(opts.config, opts.reason)) {
215
+ return {
216
+ handled: false,
217
+ ok: false,
218
+ reason: opts.reason,
219
+ route: "/webhooks/telegram",
220
+ from_generation: this.#active?.generation ?? null,
221
+ to_generation: null,
222
+ active_generation: this.#active?.generation ?? null,
223
+ warmup: null,
224
+ cutover: null,
225
+ drain: null,
226
+ rollback: {
227
+ requested: opts.reason === "rollback",
228
+ trigger: null,
229
+ attempted: false,
230
+ ok: true,
231
+ },
232
+ };
233
+ }
234
+ const rollbackRequested = opts.reason === "rollback";
235
+ let rollbackTrigger = rollbackRequested ? "manual" : null;
236
+ let rollbackAttempted = false;
237
+ let rollbackOk = true;
238
+ let rollbackError;
239
+ const fromGeneration = this.#active?.generation ?? null;
240
+ const previousRecord = this.#active;
241
+ const warmupTimeoutMs = Math.max(0, Math.trunc(opts.warmupTimeoutMs ?? TELEGRAM_WARMUP_TIMEOUT_MS));
242
+ const drainTimeoutMs = Math.max(0, Math.trunc(opts.drainTimeoutMs ?? TELEGRAM_DRAIN_TIMEOUT_MS));
243
+ const targetConfig = rollbackRequested
244
+ ? this.#previousConfig
245
+ : telegramAdapterConfigFromControlPlane(opts.config);
246
+ if (rollbackRequested && !targetConfig) {
247
+ return {
248
+ handled: true,
249
+ ok: false,
250
+ reason: opts.reason,
251
+ route: "/webhooks/telegram",
252
+ from_generation: fromGeneration,
253
+ to_generation: null,
254
+ active_generation: fromGeneration,
255
+ warmup: null,
256
+ cutover: null,
257
+ drain: null,
258
+ rollback: {
259
+ requested: true,
260
+ trigger: "rollback_unavailable",
261
+ attempted: false,
262
+ ok: false,
263
+ error: "rollback_unavailable",
264
+ },
265
+ error: "rollback_unavailable",
266
+ };
267
+ }
268
+ if (!targetConfig && !previousRecord) {
269
+ this.#activeControlPlaneConfig = cloneControlPlaneConfig(opts.config);
270
+ return {
271
+ handled: true,
272
+ ok: true,
273
+ reason: opts.reason,
274
+ route: "/webhooks/telegram",
275
+ from_generation: null,
276
+ to_generation: null,
277
+ active_generation: null,
278
+ warmup: null,
279
+ cutover: null,
280
+ drain: null,
281
+ rollback: {
282
+ requested: rollbackRequested,
283
+ trigger: rollbackTrigger,
284
+ attempted: false,
285
+ ok: true,
286
+ },
287
+ };
288
+ }
289
+ if (!targetConfig && previousRecord) {
290
+ const drainStartedAtMs = Math.trunc(this.#nowMs());
291
+ let forcedStop = false;
292
+ let drainError;
293
+ let drainTimedOut = false;
294
+ try {
295
+ previousRecord.adapter.beginDrain();
296
+ if (this.#hooks?.onDrain) {
297
+ await this.#hooks.onDrain({
298
+ generation: previousRecord.generation,
299
+ reason: opts.reason,
300
+ timeout_ms: drainTimeoutMs,
301
+ });
302
+ }
303
+ const drain = await runWithTimeout({
304
+ timeoutMs: drainTimeoutMs,
305
+ timeoutMessage: "telegram_drain_timeout",
306
+ run: async () => await previousRecord.adapter.drain({ timeoutMs: drainTimeoutMs, reason: opts.reason }),
307
+ });
308
+ drainTimedOut = drain.timed_out;
309
+ if (!drain.ok || drain.timed_out) {
310
+ forcedStop = true;
311
+ await previousRecord.adapter.stop({ force: true, reason: "disable_drain_timeout" });
312
+ }
313
+ else {
314
+ await previousRecord.adapter.stop({ force: false, reason: "disable" });
315
+ }
316
+ }
317
+ catch (err) {
318
+ drainError = describeError(err);
319
+ forcedStop = true;
320
+ drainTimedOut = drainError.includes("timeout");
321
+ await previousRecord.adapter.stop({ force: true, reason: "disable_drain_failed" });
322
+ }
323
+ this.#previousConfig = cloneTelegramAdapterConfig(previousRecord.config);
324
+ this.#active = null;
325
+ this.#activeControlPlaneConfig = applyTelegramAdapterConfig(this.#activeControlPlaneConfig, null);
326
+ return {
327
+ handled: true,
328
+ ok: drainError == null,
329
+ reason: opts.reason,
330
+ route: "/webhooks/telegram",
331
+ from_generation: fromGeneration,
332
+ to_generation: null,
333
+ active_generation: null,
334
+ warmup: null,
335
+ cutover: {
336
+ ok: true,
337
+ elapsed_ms: 0,
338
+ },
339
+ drain: {
340
+ ok: drainError == null && !drainTimedOut,
341
+ elapsed_ms: Math.max(0, Math.trunc(this.#nowMs()) - drainStartedAtMs),
342
+ timed_out: drainTimedOut,
343
+ forced_stop: forcedStop,
344
+ ...(drainError ? { error: drainError } : {}),
345
+ },
346
+ rollback: {
347
+ requested: rollbackRequested,
348
+ trigger: rollbackTrigger,
349
+ attempted: false,
350
+ ok: true,
351
+ },
352
+ ...(drainError ? { error: drainError } : {}),
353
+ };
354
+ }
355
+ const nextConfig = cloneTelegramAdapterConfig(targetConfig);
356
+ const toGeneration = this.#nextGeneration();
357
+ const nextAdapter = this.#buildAdapter(nextConfig, {
358
+ acceptIngress: false,
359
+ ingressDrainEnabled: false,
360
+ });
361
+ const nextRecord = {
362
+ generation: toGeneration,
363
+ config: nextConfig,
364
+ adapter: nextAdapter,
365
+ };
366
+ const warmupStartedAtMs = Math.trunc(this.#nowMs());
367
+ try {
368
+ if (this.#hooks?.onWarmup) {
369
+ await this.#hooks.onWarmup({ generation: toGeneration, reason: opts.reason });
370
+ }
371
+ await runWithTimeout({
372
+ timeoutMs: warmupTimeoutMs,
373
+ timeoutMessage: "telegram_warmup_timeout",
374
+ run: async () => {
375
+ await nextAdapter.warmup();
376
+ const health = await nextAdapter.healthCheck();
377
+ if (!health.ok) {
378
+ throw new Error(`telegram_health_gate_failed:${health.reason}`);
379
+ }
380
+ },
381
+ });
382
+ }
383
+ catch (err) {
384
+ const error = describeError(err);
385
+ rollbackTrigger = error.includes("health_gate") ? "health_gate_failed" : "warmup_failed";
386
+ await nextAdapter.stop({ force: true, reason: "warmup_failed" });
387
+ return {
388
+ handled: true,
389
+ ok: false,
390
+ reason: opts.reason,
391
+ route: "/webhooks/telegram",
392
+ from_generation: fromGeneration,
393
+ to_generation: toGeneration,
394
+ active_generation: fromGeneration,
395
+ warmup: {
396
+ ok: false,
397
+ elapsed_ms: Math.max(0, Math.trunc(this.#nowMs()) - warmupStartedAtMs),
398
+ error,
399
+ },
400
+ cutover: null,
401
+ drain: null,
402
+ rollback: {
403
+ requested: rollbackRequested,
404
+ trigger: rollbackTrigger,
405
+ attempted: false,
406
+ ok: true,
407
+ },
408
+ error,
409
+ };
410
+ }
411
+ const cutoverStartedAtMs = Math.trunc(this.#nowMs());
412
+ try {
413
+ if (this.#hooks?.onCutover) {
414
+ await this.#hooks.onCutover({
415
+ from_generation: fromGeneration,
416
+ to_generation: toGeneration,
417
+ reason: opts.reason,
418
+ });
419
+ }
420
+ nextAdapter.activateIngress();
421
+ if (previousRecord) {
422
+ previousRecord.adapter.beginDrain();
423
+ }
424
+ this.#active = nextRecord;
425
+ this.#generationSeq = toGeneration.generation_seq;
426
+ const postCutoverHealth = await nextAdapter.healthCheck();
427
+ if (!postCutoverHealth.ok) {
428
+ throw new Error(`telegram_post_cutover_health_failed:${postCutoverHealth.reason}`);
429
+ }
430
+ }
431
+ catch (err) {
432
+ const error = describeError(err);
433
+ rollbackTrigger = error.includes("post_cutover") ? "post_cutover_health_failed" : "cutover_failed";
434
+ rollbackAttempted = true;
435
+ const rollback = await this.#rollbackToPrevious({
436
+ failedRecord: nextRecord,
437
+ previous: previousRecord,
438
+ reason: opts.reason,
439
+ });
440
+ rollbackOk = rollback.ok;
441
+ rollbackError = rollback.error;
442
+ if (!rollback.ok) {
443
+ await nextAdapter.stop({ force: true, reason: "rollback_failed" });
444
+ this.#active = previousRecord ?? null;
445
+ this.#activeControlPlaneConfig = applyTelegramAdapterConfig(this.#activeControlPlaneConfig, previousRecord?.config ?? null);
446
+ }
447
+ return {
448
+ handled: true,
449
+ ok: false,
450
+ reason: opts.reason,
451
+ route: "/webhooks/telegram",
452
+ from_generation: fromGeneration,
453
+ to_generation: toGeneration,
454
+ active_generation: this.#active?.generation ?? fromGeneration,
455
+ warmup: {
456
+ ok: true,
457
+ elapsed_ms: Math.max(0, cutoverStartedAtMs - warmupStartedAtMs),
458
+ },
459
+ cutover: {
460
+ ok: false,
461
+ elapsed_ms: Math.max(0, Math.trunc(this.#nowMs()) - cutoverStartedAtMs),
462
+ error,
463
+ },
464
+ drain: null,
465
+ rollback: {
466
+ requested: rollbackRequested,
467
+ trigger: rollbackTrigger,
468
+ attempted: rollbackAttempted,
469
+ ok: rollbackOk,
470
+ ...(rollbackError ? { error: rollbackError } : {}),
471
+ },
472
+ error,
473
+ };
474
+ }
475
+ let drain = null;
476
+ if (previousRecord) {
477
+ const drainStartedAtMs = Math.trunc(this.#nowMs());
478
+ let forcedStop = false;
479
+ let drainTimedOut = false;
480
+ let drainError;
481
+ try {
482
+ if (this.#hooks?.onDrain) {
483
+ await this.#hooks.onDrain({
484
+ generation: previousRecord.generation,
485
+ reason: opts.reason,
486
+ timeout_ms: drainTimeoutMs,
487
+ });
488
+ }
489
+ const drained = await runWithTimeout({
490
+ timeoutMs: drainTimeoutMs,
491
+ timeoutMessage: "telegram_drain_timeout",
492
+ run: async () => await previousRecord.adapter.drain({ timeoutMs: drainTimeoutMs, reason: opts.reason }),
493
+ });
494
+ drainTimedOut = drained.timed_out;
495
+ if (!drained.ok || drained.timed_out) {
496
+ forcedStop = true;
497
+ await previousRecord.adapter.stop({ force: true, reason: "generation_drain_timeout" });
498
+ }
499
+ else {
500
+ await previousRecord.adapter.stop({ force: false, reason: "generation_drained" });
501
+ }
502
+ }
503
+ catch (err) {
504
+ drainError = describeError(err);
505
+ forcedStop = true;
506
+ drainTimedOut = drainError.includes("timeout");
507
+ await previousRecord.adapter.stop({ force: true, reason: "generation_drain_failed" });
508
+ }
509
+ drain = {
510
+ ok: drainError == null && !drainTimedOut,
511
+ elapsed_ms: Math.max(0, Math.trunc(this.#nowMs()) - drainStartedAtMs),
512
+ timed_out: drainTimedOut,
513
+ forced_stop: forcedStop,
514
+ ...(drainError ? { error: drainError } : {}),
515
+ };
516
+ }
517
+ this.#previousConfig = previousRecord ? cloneTelegramAdapterConfig(previousRecord.config) : this.#previousConfig;
518
+ this.#activeControlPlaneConfig = applyTelegramAdapterConfig(this.#activeControlPlaneConfig, nextConfig);
519
+ return {
520
+ handled: true,
521
+ ok: true,
522
+ reason: opts.reason,
523
+ route: "/webhooks/telegram",
524
+ from_generation: fromGeneration,
525
+ to_generation: toGeneration,
526
+ active_generation: toGeneration,
527
+ warmup: {
528
+ ok: true,
529
+ elapsed_ms: Math.max(0, cutoverStartedAtMs - warmupStartedAtMs),
530
+ },
531
+ cutover: {
532
+ ok: true,
533
+ elapsed_ms: Math.max(0, Math.trunc(this.#nowMs()) - cutoverStartedAtMs),
534
+ },
535
+ drain,
536
+ rollback: {
537
+ requested: rollbackRequested,
538
+ trigger: rollbackTrigger,
539
+ attempted: rollbackAttempted,
540
+ ok: rollbackOk,
541
+ ...(rollbackError ? { error: rollbackError } : {}),
542
+ },
543
+ };
544
+ }
545
+ async stop() {
546
+ const active = this.#active;
547
+ this.#active = null;
548
+ if (!active) {
549
+ return;
550
+ }
551
+ await active.adapter.stop({ force: true, reason: "shutdown" });
552
+ }
553
+ }
26
554
  function sha256Hex(input) {
27
555
  const hasher = new Bun.CryptoHasher("sha256");
28
556
  hasher.update(input);
@@ -159,6 +687,21 @@ function buildMessagingOperatorRuntime(opts) {
159
687
  export async function bootstrapControlPlane(opts) {
160
688
  const controlPlaneConfig = opts.config ?? DEFAULT_MU_CONFIG.control_plane;
161
689
  const detected = detectAdapters(controlPlaneConfig);
690
+ const generation = opts.generation ?? {
691
+ generation_id: "control-plane-gen-0",
692
+ generation_seq: 0,
693
+ };
694
+ const telemetry = opts.telemetry ?? null;
695
+ const signalObserver = telemetry
696
+ ? {
697
+ onDuplicateSignal: (signal) => {
698
+ telemetry.recordDuplicateSignal(generationTags(generation, `control_plane.${signal.source}`), signal);
699
+ },
700
+ onDropSignal: (signal) => {
701
+ telemetry.recordDropSignal(generationTags(generation, `control_plane.${signal.source}`), signal);
702
+ },
703
+ }
704
+ : undefined;
162
705
  if (detected.length === 0) {
163
706
  return null;
164
707
  }
@@ -177,7 +720,9 @@ export async function bootstrapControlPlane(opts) {
177
720
  config: controlPlaneConfig,
178
721
  backend: opts.operatorBackend,
179
722
  });
180
- const outbox = new ControlPlaneOutbox(paths.outboxPath);
723
+ const outbox = new ControlPlaneOutbox(paths.outboxPath, {
724
+ signalObserver,
725
+ });
181
726
  await outbox.load();
182
727
  let scheduleOutboxDrainRef = null;
183
728
  runSupervisor = new ControlPlaneRunSupervisor({
@@ -293,40 +838,32 @@ export async function bootstrapControlPlane(opts) {
293
838
  },
294
839
  });
295
840
  await pipeline.start();
296
- let telegramBotToken = null;
841
+ const telegramManager = new TelegramAdapterGenerationManager({
842
+ pipeline,
843
+ outbox,
844
+ initialConfig: controlPlaneConfig,
845
+ onOutboxEnqueued: () => {
846
+ scheduleOutboxDrainRef?.();
847
+ },
848
+ signalObserver,
849
+ hooks: opts.telegramGenerationHooks,
850
+ });
851
+ await telegramManager.initialize();
297
852
  for (const d of detected) {
298
- let adapter;
299
- switch (d.name) {
300
- case "slack":
301
- adapter = new SlackControlPlaneAdapter({
302
- pipeline,
303
- outbox,
304
- signingSecret: d.signingSecret,
305
- });
306
- break;
307
- case "discord":
308
- adapter = new DiscordControlPlaneAdapter({
309
- pipeline,
310
- outbox,
311
- signingSecret: d.signingSecret,
312
- });
313
- break;
314
- case "telegram":
315
- adapter = new TelegramControlPlaneAdapter({
316
- pipeline,
317
- outbox,
318
- webhookSecret: d.webhookSecret,
319
- botUsername: d.botUsername ?? undefined,
320
- deferredIngress: true,
321
- onOutboxEnqueued: () => {
322
- scheduleOutboxDrainRef?.();
323
- },
324
- });
325
- if (d.botToken) {
326
- telegramBotToken = d.botToken;
327
- }
328
- break;
853
+ if (d.name === "telegram") {
854
+ continue;
329
855
  }
856
+ const adapter = d.name === "slack"
857
+ ? new SlackControlPlaneAdapter({
858
+ pipeline,
859
+ outbox,
860
+ signingSecret: d.signingSecret,
861
+ })
862
+ : new DiscordControlPlaneAdapter({
863
+ pipeline,
864
+ outbox,
865
+ signingSecret: d.signingSecret,
866
+ });
330
867
  const route = adapter.spec.route;
331
868
  if (adapterMap.has(route)) {
332
869
  throw new Error(`duplicate control-plane webhook route: ${route}`);
@@ -337,11 +874,46 @@ export async function bootstrapControlPlane(opts) {
337
874
  name: adapter.spec.channel,
338
875
  route,
339
876
  },
877
+ isActive: () => true,
340
878
  });
341
879
  }
880
+ const telegramProxy = {
881
+ spec: TelegramControlPlaneAdapterSpec,
882
+ async ingest(req) {
883
+ const active = telegramManager.activeAdapter();
884
+ if (!active) {
885
+ return {
886
+ channel: "telegram",
887
+ accepted: false,
888
+ reason: "telegram_not_configured",
889
+ response: new Response("telegram_not_configured", { status: 404 }),
890
+ inbound: null,
891
+ pipelineResult: null,
892
+ outboxRecord: null,
893
+ auditEntry: null,
894
+ };
895
+ }
896
+ return await active.ingest(req);
897
+ },
898
+ async stop() {
899
+ await telegramManager.stop();
900
+ },
901
+ };
902
+ if (adapterMap.has(TelegramControlPlaneAdapterSpec.route)) {
903
+ throw new Error(`duplicate control-plane webhook route: ${TelegramControlPlaneAdapterSpec.route}`);
904
+ }
905
+ adapterMap.set(TelegramControlPlaneAdapterSpec.route, {
906
+ adapter: telegramProxy,
907
+ info: {
908
+ name: "telegram",
909
+ route: TelegramControlPlaneAdapterSpec.route,
910
+ },
911
+ isActive: () => telegramManager.hasActiveGeneration(),
912
+ });
342
913
  const deliver = async (record) => {
343
914
  const { envelope } = record;
344
915
  if (envelope.channel === "telegram") {
916
+ const telegramBotToken = telegramManager.activeBotToken();
345
917
  if (!telegramBotToken) {
346
918
  return { kind: "retry", error: "telegram bot token not configured in .mu/config.json" };
347
919
  }
@@ -413,10 +985,12 @@ export async function bootstrapControlPlane(opts) {
413
985
  }, OUTBOX_DRAIN_INTERVAL_MS);
414
986
  scheduleOutboxDrain();
415
987
  return {
416
- activeAdapters: [...adapterMap.values()].map((v) => v.info),
988
+ get activeAdapters() {
989
+ return [...adapterMap.values()].filter((entry) => entry.isActive()).map((v) => v.info);
990
+ },
417
991
  async handleWebhook(path, req) {
418
992
  const entry = adapterMap.get(path);
419
- if (!entry)
993
+ if (!entry || !entry.isActive())
420
994
  return null;
421
995
  const result = await entry.adapter.ingest(req);
422
996
  if (result.outboxRecord) {
@@ -424,6 +998,16 @@ export async function bootstrapControlPlane(opts) {
424
998
  }
425
999
  return result.response;
426
1000
  },
1001
+ async reloadTelegramGeneration(reloadOpts) {
1002
+ const result = await telegramManager.reload({
1003
+ config: reloadOpts.config,
1004
+ reason: reloadOpts.reason,
1005
+ });
1006
+ if (result.handled && result.ok) {
1007
+ scheduleOutboxDrain();
1008
+ }
1009
+ return result;
1010
+ },
427
1011
  async listRuns(opts = {}) {
428
1012
  return (runSupervisor?.list({
429
1013
  status: opts.status,