@sigrun/ext-gateway 0.0.0-stage → 0.1.1

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +111 -0
  38. package/dist/interactions/chat.d.ts +20 -0
  39. package/dist/interactions/chat.js +155 -0
  40. package/dist/interactions/prompts.d.ts +42 -0
  41. package/dist/interactions/prompts.js +96 -0
  42. package/dist/interactions/receipts.d.ts +6 -0
  43. package/dist/interactions/receipts.js +36 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +57 -0
  52. package/dist/outbound/delivery.d.ts +124 -0
  53. package/dist/outbound/delivery.js +387 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +80 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +8 -0
  65. package/dist/session-meta.js +21 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +61 -0
  69. package/dist/state/identities.js +131 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +152 -0
  73. package/dist/state/ledger.js +207 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
package/dist/engine.js ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * The gateway engine (spec §8.0, gateway C2): chat-transport supervision
3
+ * over the boot's channel bindings. ONE engine per boot; `start` runs
4
+ * after the server listens, `stopIntake` before the shutdown drain,
5
+ * `stop` after it.
6
+ *
7
+ * Task 2 owns supervision: `start` reads the LIVE config once for the
8
+ * restart-required enablement (a channel absent from `channels`, an
9
+ * `enabled: false`, or a `{failed}` config block never starts its
10
+ * adapter — the engine records a static row instead), then hands every
11
+ * enabled channel a {@link Supervisor}. Health (§8.12) reads the
12
+ * supervisors' unhealthy dwell plus the ledger's failed-in-last-hour
13
+ * count (Task 5: the engine-owned ledger feeds the `failedRecently`
14
+ * seam — tests may still override it).
15
+ *
16
+ * The inbound pipeline (Task 3) drives `receive` (§8.4): the config
17
+ * block is read LIVE per event, admission runs the ordered steps, and
18
+ * the supervisor counts every outcome. `action` (button taps) stays a
19
+ * no-op until the token table lands (Tasks 4/9).
20
+ *
21
+ * `EngineInternals` is the test/task-internal surface (the state-dir
22
+ * path, the status read) — NOT part of the loader contract, which sees
23
+ * only the {@link GatewayEngine} four.
24
+ */
25
+ import { mkdir } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { readConfig } from "./config.js";
28
+ import { createLog } from "./log.js";
29
+ import { createPipeline } from "./inbound/pipeline.js";
30
+ import { Supervisor, } from "./supervisor.js";
31
+ import { PairingStore } from "./access/pairing.js";
32
+ import { LinkCodes } from "./access/link-codes.js";
33
+ import { LoopGuard } from "./access/loop-guard.js";
34
+ import { ButtonTokens } from "./inbound/tokens.js";
35
+ import { chatCommandMenu } from "./commands/chat.js";
36
+ import { bindingKey, BindingStore } from "./state/bindings.js";
37
+ import { CHANNEL_ID_PATTERN, IdentityStore } from "./state/identities.js";
38
+ import { Ledger } from "./state/ledger.js";
39
+ import { DeliveryWorker } from "./outbound/delivery.js";
40
+ import { onRunSettled } from "./outbound/settled.js";
41
+ import { createNotifier } from "./notify.js";
42
+ import { SessionMetaDoc } from "./session-meta.js";
43
+ import { StreamRegistry } from "./outbound/stream.js";
44
+ import { hashId } from "./log.js";
45
+ import { collectAttachments } from "./inbound/media.js";
46
+ import { PromptStore } from "./interactions/prompts.js";
47
+ import { createInteractionNotifier, onInteractionSettled } from "./interactions/chat.js";
48
+ import { answerFromChat, createInteractionAnswerStage } from "./interactions/answer.js";
49
+ import { receiptForInteraction } from "./interactions/receipts.js";
50
+ /** The engine's own timer defaults (the real world). */
51
+ function defaultTimers() {
52
+ return {
53
+ now: () => Date.now(),
54
+ // The jitter source is RANDOM (never the clock): a deterministic
55
+ // sequence would phase-lock concurrent deployments onto the same
56
+ // retry beat.
57
+ random: () => Math.random(),
58
+ setTimer: (fn, ms) => {
59
+ const handle = setTimeout(fn, ms);
60
+ return () => clearTimeout(handle);
61
+ },
62
+ };
63
+ }
64
+ /** The unhealthy-dwell threshold (ms) before health degrades (§8.12). */
65
+ const DEGRADED_AFTER_MS = 300_000;
66
+ /** Await an operation only until its shutdown budget expires. */
67
+ async function bounded(operation, ms, onTimeout) {
68
+ let timer;
69
+ try {
70
+ await Promise.race([
71
+ operation,
72
+ new Promise((resolve) => {
73
+ timer = setTimeout(() => {
74
+ onTimeout();
75
+ resolve();
76
+ }, ms);
77
+ }),
78
+ ]);
79
+ }
80
+ finally {
81
+ if (timer !== undefined)
82
+ clearTimeout(timer);
83
+ }
84
+ }
85
+ /** One static (never-supervised) status row. */
86
+ function staticRow(state, detail, since) {
87
+ return {
88
+ state,
89
+ detail,
90
+ since,
91
+ counts: { admitted: 0, dropped: 0, delivered: 0, failed: 0 },
92
+ };
93
+ }
94
+ /**
95
+ * Build the engine over the host. The factory wires the real
96
+ * dependencies; tests inject the clock/log/timer seams through the same
97
+ * parameter.
98
+ */
99
+ export function createEngine(host, deps = {}) {
100
+ const log = deps.log ?? createLog("engine");
101
+ const timers = deps.timers ?? defaultTimers();
102
+ const stateDir = host.stores.paths.state;
103
+ const gatewayDir = join(stateDir, "gateway");
104
+ // The delivery ledger (Task 5): engine-wide (every channel's worker
105
+ // pulls its own queue from it), built over the state dir at factory
106
+ // time — the stores load synchronously, no timers (the pi lifecycle
107
+ // rule holds). Restart recovery runs at `start` (the boot's one
108
+ // recovery point) — a `recoverAfterRestart()` BEFORE any worker runs
109
+ // re-arms `attempting` entries with the recovered flag.
110
+ const ledger = deps.deliveryLedger ?? new Ledger(join(gatewayDir, "ledger.json"), {
111
+ now: timers.now,
112
+ log,
113
+ });
114
+ // One delivery worker per supervised channel, keyed channel id
115
+ // (built at start — a worker owns its channel's timers).
116
+ const workers = new Map();
117
+ // The ONE binding store: every pipeline (threaded through
118
+ // PipelineDeps.bindings), the settled fan-out and the delivery
119
+ // workers share THIS instance — JsonFile's write serialization is
120
+ // PER INSTANCE, so a second store over the same file would interleave
121
+ // its writes with this one's and lose updates.
122
+ const bindings = new BindingStore(join(gatewayDir, "bindings.json"), {
123
+ now: timers.now,
124
+ });
125
+ const failedRecently = deps.failedRecently ??
126
+ (() => ledger.failedSince(timers.now() - 3_600_000));
127
+ let channels = [];
128
+ // One supervisor per supervised channel, keyed channel id.
129
+ const supervisors = new Map();
130
+ // Static rows for channels that never reach a supervisor, keyed id.
131
+ const staticRows = new Map();
132
+ // submissionId → the origin record (Task 3's pipeline writes; the
133
+ // outbound tasks read). One map per ENGINE (shared across channels).
134
+ const origins = new Map();
135
+ // ONE pipeline per supervised channel, built at start and reused by
136
+ // every supervision retry. Retrying an adapter must not accumulate
137
+ // duplicate SeenSets or open-album collectors for the process lifetime.
138
+ const pipelines = new Map();
139
+ let started = false;
140
+ let intakeStopped = false;
141
+ // Per-channel abort ownership lets stop() signal adapters even when an
142
+ // embedder does not abort the engine's start signal itself.
143
+ const channelAborters = new Map();
144
+ // The engine-wide access state (Task 4): ONE pairing queue, link-code
145
+ // table, token table and loop guard shared by every channel pipeline
146
+ // and the pair/link commands. Built over the engine's state dir at
147
+ // factory time (cheap: the stores load synchronously; no timers, no
148
+ // I/O beyond the state reads — the pi lifecycle rule holds).
149
+ const access = {
150
+ identities: new IdentityStore(host, join(gatewayDir, "identities.json"), { log }),
151
+ pairing: new PairingStore(join(gatewayDir, "pairing.json")),
152
+ linkCodes: new LinkCodes(),
153
+ tokens: new ButtonTokens(),
154
+ loopGuard: new LoopGuard(),
155
+ };
156
+ // The engine-owned half of every adapter context (Task 3's pipeline
157
+ // drives `receive`; the supervisor's makeContext seam stays stable).
158
+ // `supervisorOf` is resolved lazily — the context is built only during
159
+ // a start ATTEMPT, after the supervisor exists (a direct reference
160
+ // would hit the TDZ at construction).
161
+ const makeContext = (_signal, report, supervisorOf,
162
+ /** The channel id — per-channel log attribution (Task 1's
163
+ * `channel:${id}` scoping; a bare "channel" scope loses which
164
+ * adapter a line came from). */
165
+ id) => {
166
+ const channelLog = ctxLog(`channel:${id}`);
167
+ // The channel's ONE pipeline was built at engine start. A retry
168
+ // closes over the same receive/flush handles, preserving its seen
169
+ // set and any open album window instead of allocating replacements.
170
+ const receivePipeline = pipelines.get(id);
171
+ if (receivePipeline === undefined) {
172
+ throw new Error(`gateway: channel "${id}" context built without its pipeline`);
173
+ }
174
+ return {
175
+ signal: _signal,
176
+ // The §8.4 pipeline: the config block is read LIVE per event
177
+ // (every field but `enabled` is per-event); a `{failed}` or
178
+ // absent block drops (the channel's enablement was read once at
179
+ // start — a supervised channel whose config went bad drops
180
+ // events until its next restart).
181
+ receive: async (event) => {
182
+ try {
183
+ if (intakeStopped) {
184
+ supervisorOf().countDropped();
185
+ return { outcome: "dropped" };
186
+ }
187
+ const config = await readConfig(host);
188
+ const block = config.channels[id];
189
+ if (block === undefined || "failed" in block) {
190
+ supervisorOf().countDropped();
191
+ return { outcome: "dropped" };
192
+ }
193
+ const outcome = await receivePipeline(id, event, block);
194
+ if (outcome.outcome === "admitted") {
195
+ supervisorOf().countAdmitted();
196
+ }
197
+ else if (outcome.outcome === "dropped") {
198
+ supervisorOf().countDropped();
199
+ }
200
+ return outcome;
201
+ }
202
+ catch (error) {
203
+ // An engine failure (the §8.4 contract): the adapter does
204
+ // not ack, the platform redelivers, and the supervisor's
205
+ // counts stay honest (no admission claimed).
206
+ channelLog.error("receive failed", error);
207
+ throw error;
208
+ }
209
+ },
210
+ // Button taps route through the pipeline's token handler (Task
211
+ // 4: pair decisions; Task 9 adds interactions). A tap on an
212
+ // unknown channel block drops like an event would.
213
+ action: async (event) => {
214
+ try {
215
+ if (intakeStopped)
216
+ return;
217
+ const config = await readConfig(host);
218
+ const block = config.channels[id];
219
+ if (block === undefined || "failed" in block)
220
+ return;
221
+ await receivePipeline.action(id, event, block);
222
+ }
223
+ catch (error) {
224
+ // A tap failure never rejects the adapter's ack: the token is
225
+ // consumed, the decision lost — logged loud, never retried
226
+ // (a replayed tap is a no-op by the token's single use).
227
+ channelLog.error("action failed", error);
228
+ }
229
+ },
230
+ status: report,
231
+ fail: () => {
232
+ // The supervisor wraps this member (its own fail routes into
233
+ // supervision); reaching the base means the wrapper was
234
+ // bypassed — log it, never crash.
235
+ log.warn("channel fail reached the engine base context");
236
+ },
237
+ log: channelLog,
238
+ };
239
+ };
240
+ // The origin-chat stream registry is engine-wide: pipelines open on
241
+ // admission, settled runs finalize with rendered chunks, and every
242
+ // channel lookup resolves against the same supervised contribution set.
243
+ const streams = new StreamRegistry({
244
+ host,
245
+ channelOf: (id) => channels.find((entry) => entry.channel.id === id)?.channel,
246
+ configOf: async (id) => {
247
+ const config = await readConfig(host);
248
+ const block = config.channels[id];
249
+ return block === undefined || "failed" in block ? undefined : block;
250
+ },
251
+ log,
252
+ now: timers.now,
253
+ setTimer: timers.setTimer,
254
+ });
255
+ /** The settled-run fan-out deps (the factory's `runSettled` closes over
256
+ * these — the same stores the pipelines and commands share, plus the
257
+ * delivery ledger and the stream registry). */
258
+ const settledDeps = (api, context) => ({
259
+ host,
260
+ bindings,
261
+ identities: access.identities,
262
+ ledger,
263
+ channelOf: (id) => channels.find((entry) => entry.channel.id === id)?.channel,
264
+ configOf: async (id) => {
265
+ const config = await readConfig(host);
266
+ const block = config.channels[id];
267
+ return block === undefined || "failed" in block ? undefined : block;
268
+ },
269
+ origins,
270
+ streams,
271
+ log,
272
+ // The event bus's narrowed snapshot API works in harness-only
273
+ // embedders; host.conversations.meta would wait for a server
274
+ // bridge that those embedders never create.
275
+ readMeta: async (conversationId) => await api.document(SessionMetaDoc, conversationId, context),
276
+ });
277
+ const notifyDeps = () => ({
278
+ host,
279
+ bindings,
280
+ identities: access.identities,
281
+ ledger,
282
+ readConfig: () => readConfig(host),
283
+ log,
284
+ });
285
+ const prompts = new PromptStore(join(gatewayDir, "prompts.json"));
286
+ const interactionDeps = () => ({
287
+ host,
288
+ bindings,
289
+ identities: access.identities,
290
+ prompts,
291
+ ledger,
292
+ tokens: access.tokens,
293
+ channelOf,
294
+ log,
295
+ });
296
+ const kickWorkers = () => {
297
+ for (const worker of workers.values())
298
+ worker.kick();
299
+ };
300
+ const channelOf = (id) => channels.find((entry) => entry.channel.id === id)?.channel;
301
+ const ctxLog = (scope) => ({
302
+ info: (msg) => log.info(`[${scope}] ${msg}`),
303
+ warn: (msg) => log.warn(`[${scope}] ${msg}`),
304
+ error: (msg, error) => log.error(`[${scope}] ${msg}`, error),
305
+ debug: (msg) => log.debug(`[${scope}] ${msg}`),
306
+ once: (key, level, msg) => log.once(key, level, `[${scope}] ${msg}`),
307
+ });
308
+ const status = () => {
309
+ const channelsOut = {};
310
+ for (const { channel } of channels) {
311
+ const supervised = supervisors.get(channel.id);
312
+ channelsOut[channel.id] = supervised
313
+ ? supervised.status()
314
+ : (staticRows.get(channel.id) ??
315
+ staticRow("stopped", "never started", timers.now()));
316
+ }
317
+ return { channels: channelsOut };
318
+ };
319
+ return {
320
+ stateDir: gatewayDir,
321
+ channelIds: () => channels.map((entry) => entry.channel.id),
322
+ status,
323
+ pairingStatus: () => access.pairing.pending().map((row) => ({
324
+ channel: row.channel,
325
+ sender: hashId(row.senderId),
326
+ createdAt: row.createdAt,
327
+ })),
328
+ /** The engine-wide access state (the commands and pipelines share). */
329
+ accessState: () => access,
330
+ /** The `/pair` decision deps over the shared state (the factory's
331
+ * command closes over these). */
332
+ pairDeps: (hostArg) => ({
333
+ host: hostArg,
334
+ identities: access.identities,
335
+ pairing: access.pairing,
336
+ channelOf: (id) => channels.find((entry) => entry.channel.id === id)?.channel,
337
+ log,
338
+ now: timers.now,
339
+ }),
340
+ toolDeps: () => ({
341
+ host,
342
+ bindings,
343
+ identities: access.identities,
344
+ ledger,
345
+ channelOf,
346
+ readConfig: () => readConfig(host),
347
+ log,
348
+ kick: kickWorkers,
349
+ ...(deps.toolReceiptWaitMs === undefined
350
+ ? {}
351
+ : { receiptWaitMs: deps.toolReceiptWaitMs }),
352
+ }),
353
+ sectionDeps: () => ({
354
+ host,
355
+ bindings,
356
+ channelOf,
357
+ readConfig: () => readConfig(host),
358
+ }),
359
+ notifier: () => {
360
+ const home = createNotifier(notifyDeps());
361
+ const interactions = createInteractionNotifier(interactionDeps());
362
+ return {
363
+ name: "gateway",
364
+ notify: async (event, ctx) => {
365
+ await interactions.notify(event, ctx);
366
+ await home.notify(event, ctx);
367
+ kickWorkers();
368
+ },
369
+ };
370
+ },
371
+ interactionSettled: async (event, context) => {
372
+ await onInteractionSettled(event, interactionDeps(), context);
373
+ kickWorkers();
374
+ },
375
+ settle: async (event, api, context) => {
376
+ // The fan-out runs over the engine's deps, then every LIVE
377
+ // worker kicks (an entry for an unsupervised channel parks in
378
+ // the ledger — the channel's next start drains it). The prune
379
+ // rides settles too: a long-lived engine prunes without owning
380
+ // a recurring timer.
381
+ await onRunSettled(event, settledDeps(api, context));
382
+ await ledger.prune(timers.now()).catch((error) => {
383
+ log.error("ledger prune failed", error);
384
+ });
385
+ kickWorkers();
386
+ },
387
+ start: async (ctx) => {
388
+ // ONE start per engine instance (the handle's lazy `started`
389
+ // already guards re-reads; this guards a direct second call).
390
+ if (started)
391
+ return;
392
+ started = true;
393
+ channels = ctx.channels;
394
+ await mkdir(gatewayDir, { recursive: true });
395
+ // Ledger restart recovery runs BEFORE any worker (§8.6): every
396
+ // `attempting` entry re-arms `pending` with the recovered flag,
397
+ // and the workers resend them with the marker line. The prune of
398
+ // delivered entries past 7 days rides the same point (plus every
399
+ // settle — no recurring timer: the supervisor tests count the
400
+ // engine's scheduled timers, and an idle engine owns ZERO).
401
+ await ledger.recoverAfterRestart();
402
+ await ledger.prune(timers.now()).catch((error) => {
403
+ log.error("ledger prune failed", error);
404
+ });
405
+ // Enablement is restart-required (spec §8.1): read ONCE at start.
406
+ const config = await readConfig(host);
407
+ log.info(`started: ${channels.length} channel(s) [${channels
408
+ .map((entry) => `${entry.extension}/${entry.channel.id}`)
409
+ .join(", ")}]`);
410
+ for (const entry of channels) {
411
+ const id = entry.channel.id;
412
+ // Adapters are the registration boundary: reject an id core
413
+ // could never place inside chat:<channel>, before any event
414
+ // reaches the identity store. The failed row makes the skip
415
+ // visible on the status route as well as in the log.
416
+ if (!CHANNEL_ID_PATTERN.test(id)) {
417
+ const detail = `channel "${id}" has an invalid id (expected ${CHANNEL_ID_PATTERN.source})`;
418
+ staticRows.set(id, staticRow("failed", detail, timers.now()));
419
+ log.warn(detail);
420
+ continue;
421
+ }
422
+ const block = config.channels[id];
423
+ if (block === undefined) {
424
+ // Absent from `channels` = disabled (spec §8.1).
425
+ staticRows.set(id, staticRow("stopped", "channel not configured", timers.now()));
426
+ log.info(`channel "${id}" not in settings — skipped`);
427
+ continue;
428
+ }
429
+ if ("failed" in block) {
430
+ // A malformed block disables the channel with the reason; the
431
+ // row is `failed` and §8.12's health reads its dwell like any
432
+ // other `failed` state (degraded after 300000 ms).
433
+ staticRows.set(id, staticRow("failed", block.failed, timers.now()));
434
+ log.warn(`channel "${id}" config failed: ${block.failed}`);
435
+ continue;
436
+ }
437
+ if (!block.enabled) {
438
+ staticRows.set(id, staticRow("stopped", "disabled", timers.now()));
439
+ log.info(`channel "${id}" disabled via settings`);
440
+ continue;
441
+ }
442
+ const channelLog = ctxLog(`channel:${id}`);
443
+ // One lifetime signal per channel: the long-lived pipeline's
444
+ // attachment fetches abort with the channel, not one retry.
445
+ const abort = new AbortController();
446
+ const receivePipeline = (deps.pipelineFactory ?? createPipeline)({
447
+ host,
448
+ channel: entry.channel,
449
+ stateDir: gatewayDir,
450
+ log: channelLog,
451
+ origins,
452
+ access,
453
+ // The ONE engine-wide binding store — the fan-out and the
454
+ // delivery workers share this exact instance.
455
+ bindings,
456
+ streams,
457
+ channelOf,
458
+ interactionAnswer: async (answer) => await answerFromChat(interactionDeps(), answer),
459
+ stages: {
460
+ interactionAnswer: createInteractionAnswerStage({
461
+ ...interactionDeps(),
462
+ configOf: async (channel) => {
463
+ const live = await readConfig(host);
464
+ const block = live.channels[channel];
465
+ return block === undefined || "failed" in block ? undefined : block;
466
+ },
467
+ reply: async (event, text) => {
468
+ await entry.channel.send({ chat: event.chat, text });
469
+ },
470
+ }),
471
+ attachments: async (event) => {
472
+ const binding = bindings.get(bindingKey(id, event.chat));
473
+ if (binding === undefined)
474
+ return { parts: [], notes: [] };
475
+ const liveConfig = await readConfig(host);
476
+ return await collectAttachments({ host, log: channelLog }, binding.conversationId, event.attachments ?? [], liveConfig.media.maxBytes, abort.signal);
477
+ },
478
+ },
479
+ });
480
+ pipelines.set(id, receivePipeline);
481
+ const supervisor = new Supervisor(entry, {
482
+ makeContext: (signal, report) => makeContext(signal, report, () => {
483
+ // The lazy lookup: the context is built only inside a start
484
+ // attempt, after `supervisors.set` ran.
485
+ const current = supervisors.get(id);
486
+ if (current === undefined) {
487
+ throw new Error(`gateway: channel "${id}" context built without a supervisor`);
488
+ }
489
+ return current;
490
+ }, id),
491
+ now: timers.now,
492
+ random: timers.random,
493
+ setTimer: timers.setTimer,
494
+ log: createLog(`channel:${id}`),
495
+ });
496
+ supervisors.set(id, supervisor);
497
+ // The channel's delivery worker (Task 5): one in-flight send,
498
+ // the pinned retry ladder, the ledger's queue. Built BEFORE the
499
+ // supervisor starts (its first delivery may kick immediately —
500
+ // recovery entries ride the same loop).
501
+ const worker = new DeliveryWorker(entry.channel, ledger, {
502
+ bindings,
503
+ timeoutMs: 60_000,
504
+ delays: [1_000, 5_000, 30_000],
505
+ setTimer: timers.setTimer,
506
+ now: timers.now,
507
+ log: createLog(`channel:${id}`),
508
+ counts: {
509
+ delivered: () => supervisor.countDelivered(),
510
+ failed: () => supervisor.countFailed(),
511
+ },
512
+ resolveFiles: (entry) => ledger.resolveFiles(host, entry),
513
+ onInteractionReceipt: async (entry, receipt) => {
514
+ await receiptForInteraction(prompts, entry, receipt);
515
+ },
516
+ });
517
+ workers.set(id, worker);
518
+ // The first attempt settles before start resolves (running
519
+ // or backoff — a failing adapter never wedges boot). The engine
520
+ // owns a per-channel signal so stop() aborts adapters directly;
521
+ // the host's start signal is bridged for the normal core order.
522
+ channelAborters.set(id, abort);
523
+ if (ctx.signal.aborted)
524
+ abort.abort();
525
+ else
526
+ ctx.signal.addEventListener("abort", () => abort.abort(), { once: true });
527
+ await supervisor.start(abort.signal);
528
+ // Native menus publish AFTER the adapter start attempt so a
529
+ // hanging setCommands cannot block supervision. Publication is
530
+ // bounded and best-effort; there is no retry on later reconnects.
531
+ if (entry.channel.capabilities.commandMenu === true &&
532
+ entry.channel.setCommands !== undefined) {
533
+ let timeout;
534
+ try {
535
+ await Promise.race([
536
+ entry.channel.setCommands(await chatCommandMenu(host)),
537
+ new Promise((_resolve, reject) => {
538
+ timeout = setTimeout(() => reject(new Error("command-menu setup timed out")), 5_000);
539
+ }),
540
+ ]);
541
+ }
542
+ catch (error) {
543
+ log.error(`channel "${id}" command-menu setup failed`, error);
544
+ }
545
+ finally {
546
+ if (timeout !== undefined)
547
+ clearTimeout(timeout);
548
+ }
549
+ }
550
+ // The recovery kick runs only once the ADAPTER is live (or in
551
+ // backoff): a send before the adapter connected would burn the
552
+ // entry's first attempt on a transport that cannot take it.
553
+ worker.kick();
554
+ }
555
+ },
556
+ stopIntake: async () => {
557
+ // Intake closes before the shutdown drain (the ordering) — but
558
+ // FIRST every open album window flushes: a shutdown inside the
559
+ // 1500 ms merge window must submit the already-admitted album
560
+ // events (they committed `seen`; without the flush, the
561
+ // platform's redelivery would dedupe to a no-op and the album
562
+ // would be lost). One failed album's flush is logged and never
563
+ // strands the others (flushAlbums drains every group).
564
+ // Keep the handles through the shutdown drain: a supervision retry
565
+ // may still call makeContext before stop() aborts the channel. Album
566
+ // flushing is idempotent, so clearing the map buys nothing and would
567
+ // turn that retry into an unhandled rejection.
568
+ const pending = [...pipelines.values()];
569
+ await Promise.all(pending.map((pipeline) => pipeline.flushAlbums()));
570
+ // Close only after the album flush: those events already committed
571
+ // their dedupe ids and must be admitted before new intake is refused.
572
+ intakeStopped = true;
573
+ },
574
+ stop: async () => {
575
+ // A stop-before-start resolves (the lazy-start shutdown edge).
576
+ // Delivery drains share one 10 s budget across channels. A hung
577
+ // adapter send cannot extend it; its entry remains pending or
578
+ // attempting in the durable ledger for restart recovery.
579
+ const draining = [...workers.values()];
580
+ workers.clear();
581
+ const deadline = timers.now() + 10_000;
582
+ await Promise.all(draining.map(async (worker) => {
583
+ try {
584
+ await bounded(worker.drain(deadline), 10_000, () => log.warn("delivery drain exceeded 10000 ms — abandoning the wait"));
585
+ }
586
+ catch (error) {
587
+ log.error("delivery drain failed", error);
588
+ }
589
+ finally {
590
+ // stop() cancels retry timers synchronously, then joins the
591
+ // in-flight send. That join may never settle; the 10 s
592
+ // drain bound above is the only wait shutdown owns.
593
+ void worker.stop().catch((error) => {
594
+ log.error("delivery worker stop failed", error);
595
+ });
596
+ }
597
+ }));
598
+ for (const abort of channelAborters.values())
599
+ abort.abort();
600
+ channelAborters.clear();
601
+ const pending = [...supervisors.values()];
602
+ supervisors.clear();
603
+ await Promise.all(pending.map(async (supervisor) => {
604
+ try {
605
+ await bounded(supervisor.stop(), 5_000, () => {
606
+ log.warn(`channel "${supervisor.id}" stop exceeded 5000 ms — abandoning the wait`);
607
+ });
608
+ }
609
+ catch (error) {
610
+ log.error(`channel "${supervisor.id}" stop failed`, error);
611
+ }
612
+ }));
613
+ // Live previews are not durable delivery. Release their watches
614
+ // after adapter stop; settled previews finalized during the drain
615
+ // have already released theirs.
616
+ streams.abandonAll();
617
+ if (started)
618
+ log.info("stopped");
619
+ },
620
+ health: () => {
621
+ // §8.12: degraded when any enabled, configured channel has been
622
+ // in backoff/failed for over 300000 ms, or the ledger holds
623
+ // failed entries from the last hour (the window is Task 5's
624
+ // `failedRecently` dependency — the engine never reads a clock
625
+ // against the ledger itself). Supervisors own `backoff` dwell;
626
+ // static rows own `failed` dwell (config-error channels).
627
+ const now = timers.now();
628
+ for (const supervisor of supervisors.values()) {
629
+ if (supervisor.unhealthyMs(now) > DEGRADED_AFTER_MS) {
630
+ return "degraded";
631
+ }
632
+ }
633
+ for (const row of staticRows.values()) {
634
+ if (row.state === "failed" &&
635
+ now - row.since > DEGRADED_AFTER_MS) {
636
+ return "degraded";
637
+ }
638
+ }
639
+ if (failedRecently() > 0) {
640
+ return "degraded";
641
+ }
642
+ return "ok";
643
+ },
644
+ };
645
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Album merging (spec §8.4 step 6): events sharing a `groupId` that
3
+ * arrive within 1500 ms merge into ONE submission (a platform media
4
+ * group delivered as several events). There is no time-based burst
5
+ * merging otherwise — every non-album message submits on arrival.
6
+ *
7
+ * The window runs from the FIRST event of its group: the group is
8
+ * closed once a later sibling arrives past `firstAt + 1500`, and a
9
+ * `groupId` reused later opens a NEW group. Group state is per
10
+ * binding key (the chat) — two chats never share an album. Attachment
11
+ * arrays are preserved alongside envelope texts so every sibling's
12
+ * media reaches the one submission.
13
+ */
14
+ import type { InboundAttachment, InboundEvent } from "@sigrun/sigrun/extension";
15
+ /** The album merge window (ms) — the plan's pinned 1.5 s. */
16
+ export declare const ALBUM_WINDOW_MS = 1500;
17
+ /** One complete album, or a plain event's single-item batch. */
18
+ export interface AlbumBatch {
19
+ readonly texts: readonly string[];
20
+ readonly attachments: readonly InboundAttachment[];
21
+ }
22
+ /**
23
+ * The in-flight album collector, one instance per engine. `add` returns
24
+ * `undefined` while the album stays OPEN (the caller holds the event
25
+ * back — no submission yet) and the collected batch when the group is
26
+ * COMPLETE. A follow-up event with no `groupId` returns its own batch.
27
+ */
28
+ export declare class AlbumCollector {
29
+ private readonly now;
30
+ private readonly windowMs;
31
+ private readonly groups;
32
+ constructor(now: () => number, windowMs?: number);
33
+ /** Offer one event's envelope text; undefined means still collecting. */
34
+ add(key: string, event: InboundEvent, text: string): AlbumBatch | undefined;
35
+ /** Close and remove one open group, returning its complete batch. */
36
+ close(key: string, groupId: string): AlbumBatch;
37
+ /** Is a group OPEN (collecting) for `key`/`groupId`? */
38
+ isOpen(key: string, groupId: string): boolean;
39
+ }