@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +111 -0
- package/dist/interactions/chat.d.ts +20 -0
- package/dist/interactions/chat.js +155 -0
- package/dist/interactions/prompts.d.ts +42 -0
- package/dist/interactions/prompts.js +96 -0
- package/dist/interactions/receipts.d.ts +6 -0
- package/dist/interactions/receipts.js +36 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +57 -0
- package/dist/outbound/delivery.d.ts +124 -0
- package/dist/outbound/delivery.js +387 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +80 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +8 -0
- package/dist/session-meta.js +21 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +61 -0
- package/dist/state/identities.js +131 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +152 -0
- package/dist/state/ledger.js +207 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- 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
|
+
}
|