@interactive-inc/claude-funnel 0.49.0 → 0.51.0

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 (53) hide show
  1. package/dist/bin.js +1 -1
  2. package/dist/claude-CB1WkV77.d.ts +115 -0
  3. package/dist/claude.d.ts +59 -0
  4. package/dist/claude.js +322 -0
  5. package/dist/{connector-diagnostic-log-OPpPi9V9.d.ts → connector-diagnostic-log-yTOojKUR.d.ts} +14 -14
  6. package/dist/{logger-Czli2OKh.js → connector-listener-DU54DN-f.js} +1 -9
  7. package/dist/connectors/discord.d.ts +3 -3
  8. package/dist/connectors/discord.js +2 -1
  9. package/dist/connectors/gh.d.ts +4 -3
  10. package/dist/connectors/gh.js +2 -1
  11. package/dist/connectors/schedule.d.ts +1 -1
  12. package/dist/connectors/schedule.js +2 -1
  13. package/dist/connectors/slack.d.ts +2 -2
  14. package/dist/connectors/slack.js +2 -1
  15. package/dist/discord-connector-schema-CBDyGdOI.js +21 -0
  16. package/dist/{discord-connector-schema-BeThExJp.js → discord-listener-_jSE3HsQ.js} +2 -22
  17. package/dist/file-system-BeOKXjlV.d.ts +26 -0
  18. package/dist/file-system-PWKKU7lA.js +9 -0
  19. package/dist/gateway.d.ts +3 -0
  20. package/dist/gateway.js +2 -0
  21. package/dist/gh-connector-schema-eoTtHbY6.d.ts +14 -0
  22. package/dist/{gh-connector-schema-eYE4g77K.js → gh-connector-schema-o3Q1-ojL.js} +1 -176
  23. package/dist/gh-listener-DH-fClQm.js +178 -0
  24. package/dist/index-BM0-f6KL.d.ts +3404 -0
  25. package/dist/index.d.ts +11 -4083
  26. package/dist/index.js +247 -3459
  27. package/dist/local-config-json-schema-8IHjS4Q7.js +439 -0
  28. package/dist/local-config-sync-BdsrDZOu.d.ts +381 -0
  29. package/dist/local-config.d.ts +3 -0
  30. package/dist/local-config.js +3 -0
  31. package/dist/logger-BP6SisKt.js +9 -0
  32. package/dist/mcp-Dr-nIBwN.js +253 -0
  33. package/dist/memory-connector-diagnostic-log-CrW1ltLM.js +2245 -0
  34. package/dist/memory-token-prompter-B5FFCsGP.d.ts +57 -0
  35. package/dist/memory-token-prompter-CLerGsgM.js +61 -0
  36. package/dist/node-file-system-BcrmWN9I.js +48 -0
  37. package/dist/{gh-connector-schema-CQmEWzdV.d.ts → process-runner-DfniuWVU.d.ts} +1 -14
  38. package/dist/profiles-f0mNmEyP.d.ts +64 -0
  39. package/dist/profiles-wMRnjSid.js +129 -0
  40. package/dist/profiles.d.ts +2 -0
  41. package/dist/profiles.js +2 -0
  42. package/dist/schedule-connector-schema-iCI61gzU.js +31 -0
  43. package/dist/{schedule-listener-3M6WkH1Y.d.ts → schedule-listener-CUyUFFR1.d.ts} +22 -46
  44. package/dist/{schedule-connector-schema-CM-sRkac.js → schedule-listener-ePAjians.js} +3 -86
  45. package/dist/settings-reader-BSU6JyvM.d.ts +167 -0
  46. package/dist/settings-reader-DPqrpV7s.js +11 -0
  47. package/dist/settings-store-D2XSXTyt.js +186 -0
  48. package/dist/slack-connector-schema-BCNWluHM.js +32 -0
  49. package/dist/{slack-listener-9UdAn_ui.d.ts → slack-listener-Bv5xI9gC.d.ts} +31 -31
  50. package/dist/{slack-connector-schema-DDbSGPZn.js → slack-listener-ClQuHhEF.js} +2 -32
  51. package/package.json +6 -1
  52. /package/dist/{connector-adapter-VA6undzc.d.ts → connector-adapter-DKgsVuMH.d.ts} +0 -0
  53. /package/dist/{discord-connector-schema-DF4pL3Sc.d.ts → discord-connector-schema-R0Uu-3ns.d.ts} +0 -0
@@ -0,0 +1,2245 @@
1
+ import { n as NodeFunnelProcessRunner } from "./gh-connector-schema-o3Q1-ojL.js";
2
+ import { t as NodeFunnelFileSystem } from "./node-file-system-BcrmWN9I.js";
3
+ import { n as FUNNEL_DIR, o as resolveFunnelPort } from "./settings-store-D2XSXTyt.js";
4
+ import { dirname, join } from "node:path";
5
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
6
+ import { z } from "zod";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { timingSafeEqual } from "node:crypto";
9
+ import { createFactory } from "hono/factory";
10
+ import { Database } from "bun:sqlite";
11
+ import { HTTPException } from "hono/http-exception";
12
+ import { zValidator } from "@hono/zod-validator";
13
+ //#region lib/engine/settings/tmp-dir.ts
14
+ /**
15
+ * Resolves the funnel temp/log root for the current OS. Defaults to
16
+ * `<os.tmpdir()>/funnel` so Windows lands under `%TEMP%\funnel` and POSIX
17
+ * lands under `/tmp/funnel`. Callers may override via `FUNNEL_TMP_DIR`.
18
+ */
19
+ function funnelTmpDir() {
20
+ const override = process.env.FUNNEL_TMP_DIR;
21
+ if (override && override.length > 0) return override;
22
+ return join(tmpdir(), "funnel");
23
+ }
24
+ //#endregion
25
+ //#region lib/gateway/publish-schema.ts
26
+ /**
27
+ * Shared schema for `POST /channels/:channel/publish` — used by both the
28
+ * gateway route handler (input validation) and the CLI / programmable client
29
+ * (request shape). The route resolves `channel` from the path; this body
30
+ * covers everything else.
31
+ */
32
+ const publishRequestSchema = z.object({
33
+ content: z.string().min(1),
34
+ meta: z.record(z.string(), z.string()).optional(),
35
+ connector: z.string().min(1).optional(),
36
+ /**
37
+ * Address the event to a single subscriber. When set, only the WS client that
38
+ * declared this id at upgrade time (`?id=<subscriberId>`) receives it. Omit for
39
+ * the default fanout. The route surfaces it to subscribers as `meta.target`.
40
+ */
41
+ target: z.string().min(1).optional()
42
+ });
43
+ const publishResponseSchema = z.object({
44
+ ok: z.literal(true),
45
+ offset: z.number().int().nonnegative()
46
+ });
47
+ //#endregion
48
+ //#region lib/gateway/channel-publisher.ts
49
+ const OFFLINE = { state: "offline" };
50
+ /**
51
+ * HTTP client for `POST /channels/:channel/publish` on a running gateway
52
+ * daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
53
+ * can branch without exceptions, mirroring `FunnelListenersClient`.
54
+ */
55
+ var FunnelChannelPublisher = class {
56
+ port;
57
+ isDaemonRunning;
58
+ getToken;
59
+ constructor(deps) {
60
+ this.port = deps.port;
61
+ this.isDaemonRunning = deps.isDaemonRunning;
62
+ this.getToken = deps.getToken ?? (() => null);
63
+ Object.freeze(this);
64
+ }
65
+ async publish(channelName, request) {
66
+ if (!this.isDaemonRunning()) return OFFLINE;
67
+ try {
68
+ const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
69
+ const res = await fetch(url, {
70
+ method: "POST",
71
+ headers: {
72
+ ...this.authHeaders(),
73
+ "content-type": "application/json"
74
+ },
75
+ body: JSON.stringify(request)
76
+ });
77
+ if (!res.ok) return {
78
+ state: "error",
79
+ reason: await res.text() || `HTTP ${res.status}`
80
+ };
81
+ const parsed = publishResponseSchema.safeParse(await res.json());
82
+ if (!parsed.success) return {
83
+ state: "error",
84
+ reason: "malformed daemon response"
85
+ };
86
+ return {
87
+ state: "ok",
88
+ offset: parsed.data.offset
89
+ };
90
+ } catch (error) {
91
+ return {
92
+ state: "error",
93
+ reason: error instanceof Error ? error.message : String(error)
94
+ };
95
+ }
96
+ }
97
+ authHeaders() {
98
+ const token = this.getToken();
99
+ return token ? { authorization: `Bearer ${token}` } : {};
100
+ }
101
+ };
102
+ //#endregion
103
+ //#region lib/gateway/auth-middleware.ts
104
+ /**
105
+ * Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
106
+ * Mounted on the routes that mutate listener state or expose detailed status.
107
+ * `/health` is intentionally left unauthenticated so the daemon manager can
108
+ * probe liveness without needing the token.
109
+ */
110
+ const requireBearerToken = (deps) => {
111
+ return async (c, next) => {
112
+ if (!constantTimeEqual((c.req.header("authorization") ?? "").match(/^Bearer\s+(.+)$/i)?.[1] ?? "", deps.expected)) return c.text("unauthorized", 401);
113
+ return await next();
114
+ };
115
+ };
116
+ const constantTimeEqual = (a, b) => {
117
+ const bufA = Buffer.from(a, "utf-8");
118
+ const bufB = Buffer.from(b, "utf-8");
119
+ const maxLen = Math.max(bufA.length, bufB.length, 1);
120
+ const padA = Buffer.alloc(maxLen);
121
+ const padB = Buffer.alloc(maxLen);
122
+ bufA.copy(padA);
123
+ bufB.copy(padB);
124
+ return timingSafeEqual(padA, padB) && bufA.length === bufB.length;
125
+ };
126
+ //#endregion
127
+ //#region lib/gateway/factory.ts
128
+ const factory = createFactory();
129
+ //#endregion
130
+ //#region lib/gateway/broadcaster.ts
131
+ const byteLengthOf = (event) => {
132
+ let bytes = Buffer.byteLength(event.content, "utf-8");
133
+ if (event.meta) for (const [k, v] of Object.entries(event.meta)) bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8");
134
+ return bytes;
135
+ };
136
+ const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
137
+ const DEFAULT_REPLAY_BUFFER_SIZE = 200;
138
+ const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
139
+ const defaultOnError$2 = () => {};
140
+ /**
141
+ * In-process pub/sub for connector events.
142
+ *
143
+ * Two outbound paths:
144
+ * - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
145
+ * - In-process subscribers registered via `subscribe()` (programmable API)
146
+ *
147
+ * Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
148
+ * (default 1 MiB), the client is closed with code 1009 and dropped from the
149
+ * registry to keep one slow consumer from blocking the daemon.
150
+ *
151
+ * Replay: every emitted event gets a strictly increasing `offset`. The latest
152
+ * `replayBufferSize` events are kept in memory; reconnecting WS clients can
153
+ * pass `?since=<offset>` and the broadcaster resends matching events before
154
+ * resuming the live stream. The in-memory ring covers short reconnects;
155
+ * older history is served from the event log wired in as `persistentReplay`.
156
+ */
157
+ var FunnelBroadcaster = class {
158
+ clients = /* @__PURE__ */ new Map();
159
+ subscribers = /* @__PURE__ */ new Set();
160
+ logger;
161
+ onError;
162
+ maxBufferedBytes;
163
+ now;
164
+ replayBufferSize;
165
+ replayBufferMaxBytes;
166
+ replayBuffer = [];
167
+ persistentReplay;
168
+ exclusiveCursor = /* @__PURE__ */ new Map();
169
+ replayBufferBytes = 0;
170
+ eventsBroadcast = 0;
171
+ droppedSlowClients = 0;
172
+ lastBroadcastAt = null;
173
+ latestOffset = 0;
174
+ constructor(deps = {}) {
175
+ this.logger = deps.logger;
176
+ this.onError = deps.onError ?? defaultOnError$2;
177
+ this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
178
+ this.now = deps.now ?? (() => Date.now());
179
+ this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE);
180
+ this.replayBufferMaxBytes = Math.max(0, deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES);
181
+ this.persistentReplay = deps.persistentReplay ?? null;
182
+ }
183
+ getMetrics() {
184
+ return {
185
+ clients: this.clients.size,
186
+ subscribers: this.subscribers.size,
187
+ eventsBroadcast: this.eventsBroadcast,
188
+ droppedSlowClients: this.droppedSlowClients,
189
+ lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
190
+ latestOffset: this.latestOffset,
191
+ oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null
192
+ };
193
+ }
194
+ /**
195
+ * Returns events with offset > since, filtered by the connector subscription
196
+ * rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
197
+ *
198
+ * Two-tier lookup:
199
+ * 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
200
+ * 2. If `since` predates the oldest in-memory entry and a persistent replay source
201
+ * is wired in (SQLite by default), the gap is filled from it. This covers reconnects
202
+ * across daemon restarts where the in-memory buffer was lost.
203
+ *
204
+ * Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
205
+ */
206
+ replaySince(since, data) {
207
+ const oldestInMemory = this.replayBuffer[0]?.offset;
208
+ const needFallback = this.persistentReplay && (oldestInMemory === void 0 || since < oldestInMemory - 1);
209
+ const fromMemory = this.replayBuffer.filter((event) => event.offset > since && this.matchesClient(event, data));
210
+ if (!needFallback) return fromMemory;
211
+ const persisted = this.persistentReplay ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data)) : [];
212
+ const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY;
213
+ return [...persisted.filter((event) => event.offset < cutoff), ...fromMemory];
214
+ }
215
+ matchesClient(event, data) {
216
+ const target = event.meta?.target;
217
+ if (target && target !== data.subscriberId) return false;
218
+ const channelId = event.meta?.channelId;
219
+ if (channelId && channelId !== data.channel) return false;
220
+ const connector = event.meta?.connector;
221
+ if (!connector) return true;
222
+ return data.connectors.includes(connector);
223
+ }
224
+ /**
225
+ * Returns the list of WS clients that should receive `event`. For each per-channel group:
226
+ * - fanout → every matching client receives
227
+ * - exclusive → exactly one client receives, picked round-robin per channel
228
+ *
229
+ * `meta.target` narrows the recipient set via `matchesClient`: only the subscriber
230
+ * whose `subscriberId` equals `target` receives a targeted event.
231
+ */
232
+ pickRecipients(event) {
233
+ const exclusiveByChannel = /* @__PURE__ */ new Map();
234
+ const recipients = [];
235
+ for (const [ws, data] of this.clients) {
236
+ if (!this.matchesClient(event, data)) continue;
237
+ if (data.delivery === "exclusive") {
238
+ const list = exclusiveByChannel.get(data.channel) ?? [];
239
+ list.push(ws);
240
+ exclusiveByChannel.set(data.channel, list);
241
+ continue;
242
+ }
243
+ recipients.push(ws);
244
+ }
245
+ for (const [channel, candidates] of exclusiveByChannel) {
246
+ if (candidates.length === 0) continue;
247
+ const cursor = this.exclusiveCursor.get(channel) ?? 0;
248
+ const picked = candidates[cursor % candidates.length];
249
+ if (picked) recipients.push(picked);
250
+ this.exclusiveCursor.set(channel, cursor + 1);
251
+ }
252
+ return recipients;
253
+ }
254
+ addClient(ws, data) {
255
+ this.clients.set(ws, data);
256
+ }
257
+ removeClient(ws) {
258
+ this.clients.delete(ws);
259
+ }
260
+ getClientCount() {
261
+ return this.clients.size;
262
+ }
263
+ listChannels() {
264
+ return [...this.clients.values()].map((d) => ({ ...d }));
265
+ }
266
+ subscribe(handler) {
267
+ this.subscribers.add(handler);
268
+ return () => {
269
+ this.subscribers.delete(handler);
270
+ };
271
+ }
272
+ broadcast(content, meta) {
273
+ this.latestOffset += 1;
274
+ const event = {
275
+ content,
276
+ meta,
277
+ offset: this.latestOffset
278
+ };
279
+ const payload = JSON.stringify(event);
280
+ meta?.connector;
281
+ this.eventsBroadcast += 1;
282
+ this.lastBroadcastAt = this.now();
283
+ if (this.replayBufferSize > 0) {
284
+ const eventBytes = byteLengthOf(event);
285
+ this.replayBuffer.push(event);
286
+ this.replayBufferBytes += eventBytes;
287
+ while ((this.replayBuffer.length > this.replayBufferSize || this.replayBufferBytes > this.replayBufferMaxBytes) && this.replayBuffer.length > 0) {
288
+ const dropped = this.replayBuffer.shift();
289
+ if (dropped) this.replayBufferBytes -= byteLengthOf(dropped);
290
+ }
291
+ }
292
+ const recipients = this.pickRecipients(event);
293
+ for (const ws of recipients) {
294
+ const buffered = ws.getBufferedAmount();
295
+ if (buffered > this.maxBufferedBytes) {
296
+ const data = this.clients.get(ws);
297
+ this.logger?.warn("dropping slow WS client (backpressure)", {
298
+ channel: data?.channel,
299
+ buffered,
300
+ max: this.maxBufferedBytes
301
+ });
302
+ try {
303
+ ws.close(1009, "backpressure");
304
+ } catch {}
305
+ this.clients.delete(ws);
306
+ this.droppedSlowClients += 1;
307
+ continue;
308
+ }
309
+ ws.send(payload);
310
+ }
311
+ for (const handler of this.subscribers) try {
312
+ handler(event);
313
+ } catch (error) {
314
+ const err = error instanceof Error ? error : new Error(String(error));
315
+ this.logger?.error("broadcast subscriber threw", { error: err.message });
316
+ this.onError(err, {
317
+ component: "broadcaster.subscriber",
318
+ offset: event.offset,
319
+ connector: event.meta?.connector ?? null,
320
+ channel: event.meta?.channel ?? null
321
+ });
322
+ }
323
+ return event;
324
+ }
325
+ /** Forward-seed the offset counter (used at startup from the persisted event store). */
326
+ seedLatestOffset(offset) {
327
+ if (offset > this.latestOffset) this.latestOffset = offset;
328
+ }
329
+ };
330
+ //#endregion
331
+ //#region lib/gateway/funnel-event-log.ts
332
+ /**
333
+ * Replayable event payload persisted by the gateway. Domain events the
334
+ * broadcaster emits to WS clients land here so reconnects across daemon
335
+ * restarts can be served from disk. System events (gateway start, channel
336
+ * connected, etc.) are routed to `FunnelLogger` instead — they never go
337
+ * through this log, which keeps the offset space clean for replay.
338
+ */
339
+ const funnelEventSchema = z.object({
340
+ type: z.string(),
341
+ content: z.string(),
342
+ channel_id: z.string().nullable(),
343
+ connector_id: z.string().nullable(),
344
+ meta: z.record(z.string(), z.string()).nullable()
345
+ });
346
+ /**
347
+ * Durable, append-only log of broadcaster events keyed by the offset the
348
+ * broadcaster assigns. The gateway persists every domain event here, and
349
+ * across restarts it both seeds the broadcaster's offset counter
350
+ * (`findMaxOffset`) and serves reconnect replay (`loadSince`) from it.
351
+ *
352
+ * `loadSince` is the only method the broadcaster itself needs, which makes
353
+ * any implementation assignable to the broadcaster's narrow `ReplaySource`.
354
+ *
355
+ * Implementations:
356
+ * - `SqliteFunnelEventLog` — the default; durable across daemon restarts.
357
+ * - `MemoryFunnelEventLog` — an in-process double for tests and embedders
358
+ * that do not need durability (replay is lost when the process exits).
359
+ */
360
+ var FunnelEventLog = class {};
361
+ //#endregion
362
+ //#region lib/logger/leuco-logger-sqlite-sink.ts
363
+ /** Conservative whitelist for column names interpolated into SQL. */
364
+ const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
365
+ /** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
366
+ const BYTE_CHECK_INTERVAL = 500;
367
+ const RESERVED_COLUMNS = new Set([
368
+ "seq",
369
+ "ts",
370
+ "type",
371
+ "event"
372
+ ]);
373
+ /**
374
+ * Schema versions. Each entry is the list of DDL statements that take the
375
+ * database from version i to version i + 1. Migrations run in a transaction
376
+ * so a partial failure rolls back. Adding a new version is append-only —
377
+ * never edit a published one. Caller-defined index columns are added
378
+ * dynamically on construct (independent of versioned migrations) because
379
+ * they are configuration, not schema evolution.
380
+ */
381
+ const MIGRATIONS = [[
382
+ "CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
383
+ "CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
384
+ "CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
385
+ ]];
386
+ /**
387
+ * SQLite-backed sink built on `bun:sqlite`. Implements both primary and
388
+ * relay roles so the same instance can own seq generation for one bus and
389
+ * mirror records from another (e.g. cross-process replication, restore
390
+ * from a backup stream).
391
+ *
392
+ * Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
393
+ * atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
394
+ * at the same database file therefore see one monotonically increasing
395
+ * seq stream without any bus-level coordination — the database itself is
396
+ * the synchronization point.
397
+ *
398
+ * Schema is version-managed via `PRAGMA user_version`. Migrations are
399
+ * append-only and run in a transaction on every construct so a partial
400
+ * upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
401
+ * via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
402
+ * a new index to an existing database is a no-downtime operation.
403
+ *
404
+ * Type safety: the second generic parameter `I` is the literal tuple of
405
+ * index column names. `extractIndexes` and `getRecords({ where })` are
406
+ * both type-checked against this tuple, so a typo at the call site is a
407
+ * compile-time error rather than a silent miss at runtime.
408
+ *
409
+ * Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
410
+ * insert as a single indexed DELETE that no-ops below the cap.
411
+ *
412
+ * Bulk inserts use `insertMany`, which wraps the batch in one transaction
413
+ * for ~10–100x throughput at the cost of one fsync per batch instead of
414
+ * one per row.
415
+ */
416
+ var LeucoLoggerSqliteSink = class {
417
+ db;
418
+ maxRows;
419
+ maxAgeMs;
420
+ maxBytes;
421
+ targetBytes;
422
+ now;
423
+ indexes;
424
+ extractIndexes;
425
+ insertStmt;
426
+ insertWithSeqStmt;
427
+ maxSeqStmt;
428
+ countStmt;
429
+ trimRowsStmt;
430
+ trimAgeStmt;
431
+ trimOldestStmt;
432
+ insertsSinceByteCheck = 0;
433
+ constructor(props) {
434
+ this.db = new Database(props.path);
435
+ this.db.run("PRAGMA journal_mode = WAL");
436
+ this.migrate();
437
+ this.maxRows = props.maxRows ?? null;
438
+ this.maxAgeMs = props.maxAgeMs ?? null;
439
+ this.maxBytes = props.maxBytes ?? null;
440
+ this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
441
+ this.now = props.now ?? (() => Date.now());
442
+ this.indexes = props.indexes ?? [];
443
+ if (this.indexes.length > 0) {
444
+ validateIndexNames(this.indexes);
445
+ this.extractIndexes = props.extractIndexes ?? null;
446
+ this.syncIndexColumns();
447
+ } else this.extractIndexes = null;
448
+ const cols = [
449
+ "ts",
450
+ "type",
451
+ "event",
452
+ ...this.indexes
453
+ ];
454
+ const placeholders = cols.map(() => "?").join(", ");
455
+ this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
456
+ const colsWithSeq = ["seq", ...cols];
457
+ const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
458
+ this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
459
+ this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
460
+ this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
461
+ this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
462
+ this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
463
+ this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
464
+ }
465
+ insert(input) {
466
+ try {
467
+ const params = this.buildInsertParams(input.ts, input.event);
468
+ const result = this.insertStmt.run(...params);
469
+ const seq = Number(result.lastInsertRowid);
470
+ this.trim();
471
+ return {
472
+ seq,
473
+ ts: input.ts,
474
+ event: input.event
475
+ };
476
+ } catch (e) {
477
+ return e instanceof Error ? e : new Error(String(e));
478
+ }
479
+ }
480
+ insertMany(inputs) {
481
+ if (inputs.length === 0) return [];
482
+ try {
483
+ const records = [];
484
+ this.db.transaction((batch) => {
485
+ for (const input of batch) {
486
+ const params = this.buildInsertParams(input.ts, input.event);
487
+ const result = this.insertStmt.run(...params);
488
+ records.push({
489
+ seq: Number(result.lastInsertRowid),
490
+ ts: input.ts,
491
+ event: input.event
492
+ });
493
+ }
494
+ })(inputs);
495
+ this.trim();
496
+ return records;
497
+ } catch (e) {
498
+ return e instanceof Error ? e : new Error(String(e));
499
+ }
500
+ }
501
+ write(record) {
502
+ try {
503
+ const params = [record.seq, ...this.buildInsertParams(record.ts, record.event)];
504
+ this.insertWithSeqStmt.run(...params);
505
+ this.trim();
506
+ } catch (e) {
507
+ return e instanceof Error ? e : new Error(String(e));
508
+ }
509
+ }
510
+ getMaxSeq() {
511
+ const row = this.maxSeqStmt.get();
512
+ return row ? row.max : 0;
513
+ }
514
+ getRecords(props = {}) {
515
+ const conditions = ["seq > ?"];
516
+ const params = [props.sinceSeq ?? 0];
517
+ if (typeof props.type === "string") {
518
+ conditions.push("type = ?");
519
+ params.push(props.type);
520
+ }
521
+ if (props.where) this.appendWhereConditions(props.where, conditions, params);
522
+ const limit = props.limit ?? 1e3;
523
+ params.push(limit);
524
+ const dir = props.order === "desc" ? "DESC" : "ASC";
525
+ const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
526
+ const rows = this.db.prepare(sql).all(...params);
527
+ if (dir === "DESC") rows.reverse();
528
+ return rows.map(toRecord);
529
+ }
530
+ /**
531
+ * Current schema version. Useful for diagnostics and for tests that want
532
+ * to verify migrations ran. Reads `PRAGMA user_version` once per call.
533
+ */
534
+ getSchemaVersion() {
535
+ return this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
536
+ }
537
+ close() {
538
+ this.db.close();
539
+ }
540
+ buildInsertParams(ts, event) {
541
+ const type = extractType(event);
542
+ const json = JSON.stringify(event);
543
+ if (this.indexes.length === 0) return [
544
+ ts,
545
+ type,
546
+ json
547
+ ];
548
+ const values = this.extractIndexes ? this.extractIndexes(event) : null;
549
+ return [
550
+ ts,
551
+ type,
552
+ json,
553
+ ...this.indexes.map((col) => values?.[col] ?? null)
554
+ ];
555
+ }
556
+ appendWhereConditions(where, conditions, params) {
557
+ const widened = where;
558
+ for (const col of this.indexes) {
559
+ const value = widened[col];
560
+ if (value === void 0) continue;
561
+ if (value === null) conditions.push(`${col} IS NULL`);
562
+ else {
563
+ conditions.push(`${col} = ?`);
564
+ params.push(value);
565
+ }
566
+ }
567
+ }
568
+ trim() {
569
+ if (this.maxRows !== null) {
570
+ const row = this.countStmt.get();
571
+ if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
572
+ }
573
+ if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
574
+ this.maybeTrimBytes();
575
+ }
576
+ /**
577
+ * Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
578
+ * we measure the file; on overflow we estimate how many of the oldest rows to
579
+ * drop to land near targetBytes (by the byte/row ratio), delete them in one
580
+ * statement, then VACUUM once to return the freed pages to the filesystem (a
581
+ * plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
582
+ * overflow keeps the expensive rewrite rare — the file must refill the whole
583
+ * maxBytes→targetBytes delta before the next overflow can trigger.
584
+ */
585
+ maybeTrimBytes() {
586
+ if (this.maxBytes === null || this.targetBytes === null) return;
587
+ this.insertsSinceByteCheck += 1;
588
+ if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
589
+ this.insertsSinceByteCheck = 0;
590
+ const bytes = this.byteSize();
591
+ if (bytes <= this.maxBytes) return;
592
+ const rows = this.countStmt.get()?.n ?? 0;
593
+ if (rows === 0) return;
594
+ const bytesToFree = bytes - this.targetBytes;
595
+ const bytesPerRow = bytes / rows;
596
+ const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
597
+ this.trimOldestStmt.run(rowsToDrop);
598
+ this.db.run("VACUUM");
599
+ }
600
+ byteSize() {
601
+ return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
602
+ }
603
+ /** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
604
+ clear() {
605
+ this.db.run("DELETE FROM leuco_log");
606
+ this.db.run("VACUUM");
607
+ this.insertsSinceByteCheck = 0;
608
+ }
609
+ syncIndexColumns() {
610
+ const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
611
+ for (const col of this.indexes) {
612
+ if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
613
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
614
+ }
615
+ }
616
+ migrate() {
617
+ const current = this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
618
+ if (current >= MIGRATIONS.length) return;
619
+ const pending = MIGRATIONS.slice(current);
620
+ let version = current;
621
+ for (const stmts of pending) {
622
+ version += 1;
623
+ this.db.transaction(() => {
624
+ for (const stmt of stmts) this.db.run(stmt);
625
+ this.db.run(`PRAGMA user_version = ${version}`);
626
+ })();
627
+ }
628
+ }
629
+ };
630
+ function validateIndexNames(names) {
631
+ for (const name of names) {
632
+ if (!COLUMN_NAME_RE.test(name)) throw new Error(`invalid index column name: ${name}`);
633
+ if (RESERVED_COLUMNS.has(name)) throw new Error(`reserved index column name: ${name}`);
634
+ }
635
+ }
636
+ function extractType(event) {
637
+ if (typeof event !== "object" || event === null) return null;
638
+ if (!("type" in event)) return null;
639
+ const t = event.type;
640
+ return typeof t === "string" ? t : null;
641
+ }
642
+ function toRecord(row) {
643
+ return {
644
+ seq: row.seq,
645
+ ts: row.ts,
646
+ event: JSON.parse(row.event)
647
+ };
648
+ }
649
+ //#endregion
650
+ //#region lib/gateway/sqlite-funnel-event-log.ts
651
+ const MAX_CONTENT_CHARS = 2e3;
652
+ /**
653
+ * SQLite-backed `FunnelEventLog`. One indexed table holds every broadcaster
654
+ * event with `channel_id` and `connector_id` as dedicated columns, so
655
+ * per-channel and per-connector replay is an indexed range scan.
656
+ *
657
+ * Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
658
+ * atomically. The broadcaster owns its own offset counter at runtime
659
+ * (seeded from `findMaxOffset()` at startup); each broadcaster event
660
+ * flows in here via `record()` with that pre-assigned offset, which the
661
+ * sink stores via `write()` — PK uniqueness catches double-emit bugs.
662
+ *
663
+ * System events (gateway lifecycle, channel connect/disconnect, etc.) do
664
+ * NOT go through this store. They are diagnostic only and live in
665
+ * `FunnelLogger`'s file so the seq space here stays exclusive to
666
+ * broadcaster traffic. This is what makes the broadcaster's seq seeding
667
+ * (`getMaxSeq()` at startup) correct without per-event coordination.
668
+ */
669
+ var SqliteFunnelEventLog = class extends FunnelEventLog {
670
+ sink;
671
+ now;
672
+ constructor(props) {
673
+ super();
674
+ this.now = props.now ?? (() => Date.now());
675
+ this.sink = new LeucoLoggerSqliteSink({
676
+ path: props.path,
677
+ indexes: ["channel_id", "connector_id"],
678
+ extractIndexes: (event) => ({
679
+ channel_id: event.channel_id,
680
+ connector_id: event.connector_id
681
+ }),
682
+ now: this.now,
683
+ ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {},
684
+ ...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {},
685
+ ...props.maxBytes !== void 0 ? { maxBytes: props.maxBytes } : {},
686
+ ...props.targetBytes !== void 0 ? { targetBytes: props.targetBytes } : {}
687
+ });
688
+ }
689
+ /**
690
+ * Persist a broadcaster-driven event with its assigned offset. Caller
691
+ * (the gateway-server) supplies the offset from `broadcaster.broadcast()`
692
+ * so this store and the broadcaster's in-memory ring stay aligned.
693
+ */
694
+ record(record) {
695
+ const event = {
696
+ type: record.meta?.event_type ?? "unknown",
697
+ content: truncate(record.content),
698
+ channel_id: record.channelId,
699
+ connector_id: record.connectorId,
700
+ meta: record.meta
701
+ };
702
+ this.sink.write({
703
+ seq: record.offset,
704
+ ts: this.now(),
705
+ event
706
+ });
707
+ }
708
+ /**
709
+ * Returns events with offset > since. Filtering by channel/connector is
710
+ * the broadcaster's responsibility (it knows the client's subscription),
711
+ * so this returns the full slice and lets the caller filter.
712
+ */
713
+ loadSince(since) {
714
+ const records = this.sink.getRecords({ sinceSeq: since });
715
+ const out = [];
716
+ for (const record of records) out.push({
717
+ content: record.event.content,
718
+ meta: record.event.meta ?? void 0,
719
+ offset: record.seq
720
+ });
721
+ return out;
722
+ }
723
+ /**
724
+ * Returns events for one channel (and optionally one connector). Used
725
+ * by the gateway logs CLI for scoped queries. Channel/connector filters
726
+ * are indexed columns, so this is an indexed range scan.
727
+ */
728
+ loadForChannel(props) {
729
+ const where = { channel_id: props.channelId };
730
+ if (props.connectorId !== void 0) where.connector_id = props.connectorId;
731
+ const records = this.sink.getRecords({
732
+ where,
733
+ ...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
734
+ ...props.limit !== void 0 ? { limit: props.limit } : {}
735
+ });
736
+ const out = [];
737
+ for (const record of records) out.push({
738
+ content: record.event.content,
739
+ meta: record.event.meta ?? void 0,
740
+ offset: record.seq
741
+ });
742
+ return out;
743
+ }
744
+ findMaxOffset() {
745
+ return this.sink.getMaxSeq();
746
+ }
747
+ clear() {
748
+ this.sink.clear();
749
+ }
750
+ close() {
751
+ this.sink.close();
752
+ }
753
+ };
754
+ function truncate(content) {
755
+ if (content.length <= MAX_CONTENT_CHARS) return content;
756
+ return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
757
+ }
758
+ //#endregion
759
+ //#region lib/gateway/listener-supervisor.ts
760
+ const defaultOnError$1 = () => {};
761
+ const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
762
+ const DEFAULT_MAX_BACKOFF_MS = 6e4;
763
+ const defaultSleep = (ms) => new Promise((r) => {
764
+ setTimeout(r, ms);
765
+ });
766
+ /**
767
+ * Owns the running listener instances and their lifecycle.
768
+ *
769
+ * Lives in the gateway process and is the only place that calls
770
+ * `listener.start()` / `listener.stop()`. Each entry is keyed by
771
+ * `${channelName}/${connectorName}` so the same connector name can exist in
772
+ * multiple channels without colliding.
773
+ *
774
+ * Periodically polls each running listener's `isAlive()` and auto-restarts
775
+ * dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
776
+ * the backoff counter on successful restart.
777
+ */
778
+ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
779
+ channels;
780
+ notify;
781
+ logger;
782
+ onError;
783
+ running = /* @__PURE__ */ new Map();
784
+ failureCounts = /* @__PURE__ */ new Map();
785
+ stats = /* @__PURE__ */ new Map();
786
+ healthCheckIntervalMs;
787
+ maxBackoffMs;
788
+ sleep;
789
+ now;
790
+ healthCheckTimer = null;
791
+ healthCheckInFlight = false;
792
+ constructor(deps) {
793
+ this.channels = deps.channels;
794
+ this.notify = deps.notify;
795
+ this.logger = deps.logger;
796
+ this.onError = deps.onError ?? defaultOnError$1;
797
+ this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
798
+ this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
799
+ this.sleep = deps.sleep ?? defaultSleep;
800
+ this.now = deps.now ?? (() => Date.now());
801
+ }
802
+ static keyOf(channelName, connectorName) {
803
+ return `${channelName}/${connectorName}`;
804
+ }
805
+ isRunning(channelName, connectorName) {
806
+ return this.running.has(FunnelListenerSupervisor.keyOf(channelName, connectorName));
807
+ }
808
+ list() {
809
+ return [...this.running.entries()].map(([key, entry]) => {
810
+ const stats = this.stats.get(key);
811
+ return {
812
+ channelName: entry.channelName,
813
+ channelId: entry.channelId,
814
+ name: entry.config.name,
815
+ type: entry.config.type,
816
+ alive: entry.listener.isAlive(),
817
+ events: stats?.events ?? 0,
818
+ errors: stats?.errors ?? 0,
819
+ failureCount: this.failureCounts.get(key) ?? 0,
820
+ lastEventAt: stats?.lastEventAt ?? null
821
+ };
822
+ });
823
+ }
824
+ async start(channelName, connectorName) {
825
+ const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
826
+ if (this.running.has(key)) return {
827
+ ok: true,
828
+ reason: "already running"
829
+ };
830
+ const created = this.channels.createListener(channelName, connectorName);
831
+ if (!created) return {
832
+ ok: false,
833
+ reason: `connector "${connectorName}" not found in channel "${channelName}"`
834
+ };
835
+ const bind = async (content, meta) => {
836
+ try {
837
+ await this.notify(channelName, connectorName, content, meta);
838
+ this.recordEvent(key);
839
+ } catch (error) {
840
+ this.recordError(key);
841
+ throw error;
842
+ }
843
+ };
844
+ try {
845
+ await created.listener.start(bind);
846
+ this.running.set(key, {
847
+ config: created.config,
848
+ channelName,
849
+ channelId: created.channelId,
850
+ listener: created.listener
851
+ });
852
+ this.ensureStats(key);
853
+ this.logger?.info(`${created.config.type} listener started`, {
854
+ channel: channelName,
855
+ connector: connectorName
856
+ });
857
+ return { ok: true };
858
+ } catch (error) {
859
+ const err = error instanceof Error ? error : new Error(String(error));
860
+ this.logger?.error(`${created.config.type} listener failed to start`, {
861
+ channel: channelName,
862
+ connector: connectorName,
863
+ error: err.message
864
+ });
865
+ this.onError(err, {
866
+ component: "listener-supervisor.start",
867
+ channel: channelName,
868
+ connector: connectorName,
869
+ type: created.config.type
870
+ });
871
+ return {
872
+ ok: false,
873
+ reason: err.message
874
+ };
875
+ }
876
+ }
877
+ async stop(channelName, connectorName) {
878
+ const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
879
+ const entry = this.running.get(key);
880
+ if (!entry) return {
881
+ ok: true,
882
+ reason: "not running"
883
+ };
884
+ try {
885
+ await entry.listener.stop();
886
+ this.running.delete(key);
887
+ this.failureCounts.delete(key);
888
+ this.logger?.info(`${entry.config.type} listener stopped`, {
889
+ channel: channelName,
890
+ connector: connectorName
891
+ });
892
+ return { ok: true };
893
+ } catch (error) {
894
+ const err = error instanceof Error ? error : new Error(String(error));
895
+ this.logger?.error(`${entry.config.type} listener failed to stop`, {
896
+ channel: channelName,
897
+ connector: connectorName,
898
+ error: err.message
899
+ });
900
+ this.onError(err, {
901
+ component: "listener-supervisor.stop",
902
+ channel: channelName,
903
+ connector: connectorName,
904
+ type: entry.config.type
905
+ });
906
+ return {
907
+ ok: false,
908
+ reason: err.message
909
+ };
910
+ }
911
+ }
912
+ async restart(channelName, connectorName) {
913
+ const stopped = await this.stop(channelName, connectorName);
914
+ if (!stopped.ok) return stopped;
915
+ return await this.start(channelName, connectorName);
916
+ }
917
+ async startAll() {
918
+ const all = this.channels.listAllConnectors();
919
+ for (const view of all) await this.start(view.channelName, view.name);
920
+ this.startHealthCheck();
921
+ }
922
+ async stopAll() {
923
+ this.stopHealthCheck();
924
+ for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
925
+ }
926
+ ensureStats(key) {
927
+ const existing = this.stats.get(key);
928
+ if (existing) return existing;
929
+ const fresh = {
930
+ events: 0,
931
+ errors: 0,
932
+ failureCount: 0,
933
+ lastEventAt: null
934
+ };
935
+ this.stats.set(key, fresh);
936
+ return fresh;
937
+ }
938
+ recordEvent(key) {
939
+ const stats = this.ensureStats(key);
940
+ stats.events += 1;
941
+ stats.lastEventAt = new Date(this.now()).toISOString();
942
+ }
943
+ recordError(key) {
944
+ this.ensureStats(key).errors += 1;
945
+ }
946
+ startHealthCheck() {
947
+ if (this.healthCheckTimer) return;
948
+ this.healthCheckTimer = setInterval(() => {
949
+ this.runHealthCheck();
950
+ }, this.healthCheckIntervalMs);
951
+ this.healthCheckTimer.unref();
952
+ }
953
+ stopHealthCheck() {
954
+ if (!this.healthCheckTimer) return;
955
+ clearInterval(this.healthCheckTimer);
956
+ this.healthCheckTimer = null;
957
+ }
958
+ async runHealthCheck() {
959
+ if (this.healthCheckInFlight) return;
960
+ this.healthCheckInFlight = true;
961
+ try {
962
+ for (const [key, entry] of [...this.running.entries()]) {
963
+ if (entry.listener.isAlive()) {
964
+ this.failureCounts.delete(key);
965
+ continue;
966
+ }
967
+ await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
968
+ }
969
+ } finally {
970
+ this.healthCheckInFlight = false;
971
+ }
972
+ }
973
+ async recoverDead(channelName, connectorName, type) {
974
+ const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
975
+ const failureCount = this.failureCounts.get(key) ?? 0;
976
+ const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
977
+ this.logger?.warn(`${type} listener unhealthy, restarting`, {
978
+ channel: channelName,
979
+ connector: connectorName,
980
+ attempt: failureCount + 1,
981
+ backoffMs
982
+ });
983
+ await this.stop(channelName, connectorName);
984
+ await this.sleep(backoffMs);
985
+ if ((await this.start(channelName, connectorName)).ok) {
986
+ this.failureCounts.delete(key);
987
+ this.logger?.info(`${type} listener recovered`, {
988
+ channel: channelName,
989
+ connector: connectorName
990
+ });
991
+ } else this.failureCounts.set(key, failureCount + 1);
992
+ }
993
+ };
994
+ //#endregion
995
+ //#region lib/gateway/kill-competing-slack-gateways.ts
996
+ const defaultProcess = new NodeFunnelProcessRunner();
997
+ const titleFor = (dir) => `funnel-gateway[${dir}]`;
998
+ /**
999
+ * Kills other funnel daemon processes that share the SAME funnel home dir,
1000
+ * which is the only situation that causes a real conflict (duplicate Slack
1001
+ * Socket Mode connections with the same tokens). Daemons rooted at a
1002
+ * different `~/.funnel/` are left alone — they hold different tokens and
1003
+ * speak to different Slack apps. The daemon advertises its dir via the
1004
+ * `funnel-gateway[<dir>]` marker appended to argv (also assigned to
1005
+ * `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
1006
+ * absorbs the POSIX/Windows enumeration difference behind the marker match.
1007
+ */
1008
+ const killCompetingSlackGateways = async (props) => {
1009
+ const runner = props.process ?? defaultProcess;
1010
+ const logger = props.logger;
1011
+ const expectedTitle = titleFor(props.dir);
1012
+ const snapshots = runner.listProcessesContaining(expectedTitle);
1013
+ const killed = [];
1014
+ for (const snapshot of snapshots) {
1015
+ if (snapshot.pid === props.selfPid) continue;
1016
+ runner.kill(snapshot.pid, "SIGTERM");
1017
+ killed.push(snapshot.pid);
1018
+ logger?.info("killed competing Slack gateway process", {
1019
+ pid: snapshot.pid,
1020
+ args: snapshot.command.slice(0, 160)
1021
+ });
1022
+ }
1023
+ return killed;
1024
+ };
1025
+ //#endregion
1026
+ //#region lib/gateway/routes/validator.ts
1027
+ /**
1028
+ * Path-param validator for gateway routes. On failure it answers with the same
1029
+ * `{ ok: false, reason }` shape the listener routes already use, so
1030
+ * `FunnelListenersClient` can surface the message without special-casing.
1031
+ */
1032
+ const zParam = (schema) => zValidator("param", schema, (result, c) => {
1033
+ if (result.success) return;
1034
+ const issue = result.error.issues[0];
1035
+ const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid request";
1036
+ return c.json({
1037
+ ok: false,
1038
+ reason
1039
+ }, 400);
1040
+ });
1041
+ //#endregion
1042
+ //#region lib/gateway/routes/channels.connectors.call.ts
1043
+ const bodySchema = z.object({
1044
+ method: z.string().min(1),
1045
+ path: z.string().min(1),
1046
+ body: z.unknown().optional()
1047
+ });
1048
+ /**
1049
+ * POST /channels/:channel/connectors/:connector/call
1050
+ *
1051
+ * Generic adapter call. Used by the funnel MCP server (running in the Claude
1052
+ * Code process) to send replies/reactions/etc. without spawning a CLI
1053
+ * subprocess. Mirrors the CLI's `funnel channels <c> connectors <conn> request
1054
+ * --method=...` but with a structured JSON body and no shell.
1055
+ */
1056
+ const channelsConnectorsCallHandler = factory.createHandlers(zParam(z.object({
1057
+ channel: z.string().min(1),
1058
+ connector: z.string().min(1)
1059
+ })), async (c) => {
1060
+ const param = c.req.valid("param");
1061
+ const raw = await c.req.json().catch(() => null);
1062
+ const parsed = bodySchema.safeParse(raw);
1063
+ if (!parsed.success) throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid body" });
1064
+ const result = await c.var.deps.channels.call(param.channel, param.connector, {
1065
+ method: parsed.data.method,
1066
+ path: parsed.data.path,
1067
+ body: parsed.data.body ?? {}
1068
+ });
1069
+ return c.json({
1070
+ ok: true,
1071
+ result
1072
+ });
1073
+ });
1074
+ //#endregion
1075
+ //#region lib/gateway/routes/channels.publish.ts
1076
+ /**
1077
+ * POST /channels/:channel/publish
1078
+ *
1079
+ * Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
1080
+ * path: events go through `broadcaster.broadcast` + `eventLog.record`, so
1081
+ * subscribers see them exactly as if a listener had produced them.
1082
+ *
1083
+ * Body validation is Zod-shared with the client (`publishRequestSchema`); the
1084
+ * response (`publishResponseSchema`) carries the assigned offset so callers can
1085
+ * correlate with the persistent event store.
1086
+ */
1087
+ const channelsPublishHandler = factory.createHandlers(zParam(z.object({ channel: z.string().min(1) })), zValidator("json", publishRequestSchema, (result, c) => {
1088
+ if (result.success) return;
1089
+ const issue = result.error.issues[0];
1090
+ const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body";
1091
+ return c.json({
1092
+ ok: false,
1093
+ reason
1094
+ }, 400);
1095
+ }), (c) => {
1096
+ const param = c.req.valid("param");
1097
+ const body = c.req.valid("json");
1098
+ const meta = body.target ? {
1099
+ ...body.meta,
1100
+ target: body.target
1101
+ } : body.meta;
1102
+ const response = {
1103
+ ok: true,
1104
+ offset: c.var.deps.emit({
1105
+ channel: param.channel,
1106
+ connector: body.connector,
1107
+ content: body.content,
1108
+ meta
1109
+ }).offset
1110
+ };
1111
+ return c.json(response);
1112
+ });
1113
+ //#endregion
1114
+ //#region lib/gateway/connector-diagnostic-sql-reader.ts
1115
+ /**
1116
+ * Read-only SQL surface over the three diagnostic tables, for Claude to query
1117
+ * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
1118
+ * three views — `raw`, `processed`, `connection` — that hide the storage
1119
+ * details (the physical table is `leuco_log` and each row's columns live
1120
+ * inside a JSON `event` blob): the views surface the columns as plain fields,
1121
+ * with `payload` already pulled out of the nested JSON.
1122
+ *
1123
+ * The tables are separate files. `raw` and `processed` share an `event_id`,
1124
+ * so a `JOIN` answers "the event arrived, but what verdict did it get?";
1125
+ * `connection` answers the other half — "did the listener ever connect at
1126
+ * all?". Writes are impossible: the connection is read-only and `query`
1127
+ * rejects anything but a single `SELECT`.
1128
+ */
1129
+ var ConnectorDiagnosticSqlReader = class {
1130
+ db;
1131
+ constructor(props) {
1132
+ const db = new Database(props.rawPath, { readonly: true });
1133
+ try {
1134
+ db.run("PRAGMA busy_timeout = 500");
1135
+ db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
1136
+ db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
1137
+ db.run(rawViewSql);
1138
+ db.run(processedViewSql);
1139
+ db.run(connectionViewSql);
1140
+ } catch (error) {
1141
+ db.close();
1142
+ throw error;
1143
+ }
1144
+ this.db = db;
1145
+ Object.freeze(this);
1146
+ }
1147
+ /**
1148
+ * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
1149
+ * than throwing) for a non-SELECT statement or a SQL error, so the caller
1150
+ * can surface the message without a stack trace.
1151
+ */
1152
+ query(sql, params = []) {
1153
+ const trimmed = sql.trim().replace(/;$/, "").trim();
1154
+ if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
1155
+ if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
1156
+ try {
1157
+ return this.db.prepare(trimmed).all(...params);
1158
+ } catch (error) {
1159
+ return error instanceof Error ? error : new Error(String(error));
1160
+ }
1161
+ }
1162
+ close() {
1163
+ this.db.close();
1164
+ }
1165
+ };
1166
+ const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
1167
+ seq,
1168
+ ts,
1169
+ json_extract(event, '$.event_id') AS event_id,
1170
+ json_extract(event, '$.type') AS type,
1171
+ json_extract(event, '$.connector_id') AS connector_id,
1172
+ json_extract(event, '$.channel_id') AS channel_id,
1173
+ json_extract(event, '$.payload') AS payload
1174
+ FROM main.leuco_log`;
1175
+ const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
1176
+ seq,
1177
+ ts,
1178
+ json_extract(event, '$.event_id') AS event_id,
1179
+ json_extract(event, '$.type') AS type,
1180
+ json_extract(event, '$.connector_id') AS connector_id,
1181
+ json_extract(event, '$.channel_id') AS channel_id,
1182
+ json_extract(event, '$.outcome') AS outcome,
1183
+ json_extract(event, '$.payload') AS payload
1184
+ FROM processeddb.leuco_log`;
1185
+ const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
1186
+ seq,
1187
+ ts,
1188
+ json_extract(event, '$.type') AS type,
1189
+ json_extract(event, '$.connector_id') AS connector_id,
1190
+ json_extract(event, '$.channel_id') AS channel_id,
1191
+ json_extract(event, '$.status') AS status,
1192
+ json_extract(event, '$.detail') AS detail
1193
+ FROM connectiondb.leuco_log`;
1194
+ //#endregion
1195
+ //#region lib/gateway/routes/debug.ts
1196
+ const extractPreview = (payload) => {
1197
+ if (typeof payload !== "string" || payload.length === 0) return null;
1198
+ try {
1199
+ const parsed = JSON.parse(payload);
1200
+ if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
1201
+ const text = String(parsed.text);
1202
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
1203
+ }
1204
+ } catch {
1205
+ return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
1206
+ }
1207
+ return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
1208
+ };
1209
+ const buildChannelDiagnosis = (channel) => {
1210
+ const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
1211
+ if (channel.connectors.length === 0) return {
1212
+ status: "warn",
1213
+ message: "no connectors configured on this channel",
1214
+ nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
1215
+ rootCause: null
1216
+ };
1217
+ if (!channel.listener) return {
1218
+ status: "error",
1219
+ message: "no listener running for this channel",
1220
+ nextActions: ["fnl gateway restart"],
1221
+ rootCause
1222
+ };
1223
+ if (!channel.listener.alive) return {
1224
+ status: "error",
1225
+ message: "listener is dead",
1226
+ nextActions: ["fnl gateway logs", "fnl gateway restart"],
1227
+ rootCause
1228
+ };
1229
+ if (channel.claudeClients === 0) return {
1230
+ status: "warn",
1231
+ message: "no Claude connected to this channel",
1232
+ nextActions: [`fnl claude --channel ${channel.name}`],
1233
+ rootCause: null
1234
+ };
1235
+ if (channel.listener.errors > 0) return {
1236
+ status: "warn",
1237
+ message: "listener has errors",
1238
+ nextActions: ["fnl gateway logs"],
1239
+ rootCause
1240
+ };
1241
+ return {
1242
+ status: "ok",
1243
+ message: "healthy",
1244
+ nextActions: [],
1245
+ rootCause: null
1246
+ };
1247
+ };
1248
+ /** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
1249
+ const debugHandler = factory.createHandlers(async (c) => {
1250
+ const deps = c.var.deps;
1251
+ const channelFilter = c.req.query("channel") ?? null;
1252
+ const allChannels = deps.channels.list();
1253
+ const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
1254
+ const gatewayListeners = deps.supervisor.list();
1255
+ const gatewayClients = deps.broadcaster.listChannels();
1256
+ const metrics = deps.broadcaster.getMetrics();
1257
+ const tmpDir = funnelTmpDir();
1258
+ const rawPath = join(tmpDir, "connector-raw.db");
1259
+ const processedPath = join(tmpDir, "connector-processed.db");
1260
+ const connectionPath = join(tmpDir, "connector-connection.db");
1261
+ const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
1262
+ const channels = targetChannels.map((ch) => {
1263
+ const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
1264
+ const listener = listenerEntry ? {
1265
+ alive: listenerEntry.alive,
1266
+ events: listenerEntry.events,
1267
+ errors: listenerEntry.errors,
1268
+ lastEventAt: listenerEntry.lastEventAt
1269
+ } : null;
1270
+ const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
1271
+ const recentEvents = [];
1272
+ const connectionErrors = [];
1273
+ if (hasStore) {
1274
+ const reader = new ConnectorDiagnosticSqlReader({
1275
+ rawPath,
1276
+ processedPath,
1277
+ connectionPath
1278
+ });
1279
+ const rows = (() => {
1280
+ try {
1281
+ return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
1282
+ } finally {
1283
+ reader.close();
1284
+ }
1285
+ })();
1286
+ if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
1287
+ const rawPayload = typeof row.payload === "string" ? row.payload : null;
1288
+ let payloadParsed = null;
1289
+ if (rawPayload) try {
1290
+ const parsed = JSON.parse(rawPayload);
1291
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
1292
+ } catch {
1293
+ payloadParsed = null;
1294
+ }
1295
+ recentEvents.push({
1296
+ seq: typeof row.seq === "number" ? row.seq : null,
1297
+ ts: typeof row.ts === "number" ? row.ts : null,
1298
+ type: typeof row.type === "string" ? row.type : "?",
1299
+ outcome: typeof row.outcome === "string" ? row.outcome : "?",
1300
+ payload: rawPayload,
1301
+ payloadParsed,
1302
+ preview: extractPreview(row.payload)
1303
+ });
1304
+ }
1305
+ if (listener && (!listener.alive || listener.errors > 0) || !listener) {
1306
+ const errReader = new ConnectorDiagnosticSqlReader({
1307
+ rawPath,
1308
+ processedPath,
1309
+ connectionPath
1310
+ });
1311
+ const errRows = (() => {
1312
+ try {
1313
+ return errReader.query("SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [ch.id]);
1314
+ } finally {
1315
+ errReader.close();
1316
+ }
1317
+ })();
1318
+ if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
1319
+ ts: typeof row.ts === "number" ? row.ts : null,
1320
+ type: typeof row.type === "string" ? row.type : "?",
1321
+ status: typeof row.status === "string" ? row.status : "?",
1322
+ detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
1323
+ });
1324
+ }
1325
+ }
1326
+ const base = {
1327
+ id: ch.id,
1328
+ name: ch.name,
1329
+ connectors: ch.connectors.map((conn) => conn.name),
1330
+ listener,
1331
+ claudeClients,
1332
+ recentEvents,
1333
+ connectionErrors
1334
+ };
1335
+ return {
1336
+ ...base,
1337
+ diagnosis: buildChannelDiagnosis(base)
1338
+ };
1339
+ });
1340
+ return c.json({
1341
+ pid: deps.selfPid,
1342
+ uptimeMs: deps.uptimeMs(),
1343
+ eventsBroadcast: metrics.eventsBroadcast,
1344
+ channels
1345
+ });
1346
+ });
1347
+ //#endregion
1348
+ //#region lib/gateway/routes/health.ts
1349
+ /** GET /health — liveness + listener registry snapshot. */
1350
+ const healthHandler = factory.createHandlers((c) => {
1351
+ const deps = c.var.deps;
1352
+ return c.json({
1353
+ ok: true,
1354
+ pid: deps.selfPid,
1355
+ clients: deps.broadcaster.getClientCount(),
1356
+ listeners: deps.supervisor.list()
1357
+ });
1358
+ });
1359
+ //#endregion
1360
+ //#region lib/gateway/routes/listeners.list.ts
1361
+ /** GET /listeners — running connector listeners with alive/dead status. */
1362
+ const listenersListHandler = factory.createHandlers((c) => {
1363
+ return c.json({ listeners: c.var.deps.supervisor.list() });
1364
+ });
1365
+ //#endregion
1366
+ //#region lib/gateway/routes/listeners.restart.ts
1367
+ /** POST /listeners/:channel/:connector/restart — stop + start a connector listener. */
1368
+ const listenersRestartHandler = factory.createHandlers(zParam(z.object({
1369
+ channel: z.string().min(1),
1370
+ connector: z.string().min(1)
1371
+ })), async (c) => {
1372
+ const param = c.req.valid("param");
1373
+ const result = await c.var.deps.supervisor.restart(param.channel, param.connector);
1374
+ return c.json(result, result.ok ? 200 : 400);
1375
+ });
1376
+ //#endregion
1377
+ //#region lib/gateway/routes/listeners.start.ts
1378
+ /** POST /listeners/:channel/:connector/start — start a connector listener. */
1379
+ const listenersStartHandler = factory.createHandlers(zParam(z.object({
1380
+ channel: z.string().min(1),
1381
+ connector: z.string().min(1)
1382
+ })), async (c) => {
1383
+ const param = c.req.valid("param");
1384
+ const result = await c.var.deps.supervisor.start(param.channel, param.connector);
1385
+ return c.json(result, result.ok ? 200 : 400);
1386
+ });
1387
+ //#endregion
1388
+ //#region lib/gateway/routes/listeners.stop.ts
1389
+ /** DELETE /listeners/:channel/:connector — stop a connector listener. */
1390
+ const listenersStopHandler = factory.createHandlers(zParam(z.object({
1391
+ channel: z.string().min(1),
1392
+ connector: z.string().min(1)
1393
+ })), async (c) => {
1394
+ const param = c.req.valid("param");
1395
+ const result = await c.var.deps.supervisor.stop(param.channel, param.connector);
1396
+ return c.json(result, result.ok ? 200 : 400);
1397
+ });
1398
+ //#endregion
1399
+ //#region lib/gateway/routes/status.ts
1400
+ /** GET /status — listener registry, connected channels, and broadcaster metrics. */
1401
+ const statusHandler = factory.createHandlers((c) => {
1402
+ const deps = c.var.deps;
1403
+ return c.json({
1404
+ ok: true,
1405
+ pid: deps.selfPid,
1406
+ uptimeMs: deps.uptimeMs(),
1407
+ clients: deps.broadcaster.listChannels(),
1408
+ listeners: deps.supervisor.list(),
1409
+ broadcaster: deps.broadcaster.getMetrics()
1410
+ });
1411
+ });
1412
+ //#endregion
1413
+ //#region lib/gateway/routes/index.ts
1414
+ function buildGatewayRoutes() {
1415
+ return factory.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler).get("/debug", ...debugHandler).get("/listeners", ...listenersListHandler).post("/listeners/:channel/:connector/start", ...listenersStartHandler).delete("/listeners/:channel/:connector", ...listenersStopHandler).post("/listeners/:channel/:connector/restart", ...listenersRestartHandler).post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler).post("/channels/:channel/publish", ...channelsPublishHandler);
1416
+ }
1417
+ const gatewayRoutes = buildGatewayRoutes();
1418
+ //#endregion
1419
+ //#region lib/gateway/gateway-server.ts
1420
+ const DEFAULT_HOST = "127.0.0.1";
1421
+ const LOOPBACK_HOSTS = new Set([
1422
+ "127.0.0.1",
1423
+ "localhost",
1424
+ "::1",
1425
+ "::ffff:127.0.0.1"
1426
+ ]);
1427
+ const defaultDbPath = () => join(funnelTmpDir(), "events.db");
1428
+ const defaultOnError = () => {};
1429
+ /**
1430
+ * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
1431
+ * listeners through `FunnelListenerSupervisor`, fans events out via
1432
+ * `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
1433
+ * System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
1434
+ * instead — keeping the SQLite seq space exclusive to broadcaster traffic so
1435
+ * the broadcaster's offset counter and `getMaxSeq()` stay aligned without
1436
+ * per-event coordination. Exposes `/listeners` HTTP for runtime
1437
+ * start/stop/restart of individual connectors.
1438
+ */
1439
+ var FunnelGatewayServer = class {
1440
+ channels;
1441
+ port;
1442
+ hostname;
1443
+ dbPath;
1444
+ process;
1445
+ logger;
1446
+ onError;
1447
+ selfPid;
1448
+ dir;
1449
+ killCompetingSlack;
1450
+ token;
1451
+ broadcaster;
1452
+ eventLog;
1453
+ supervisor;
1454
+ nowMs;
1455
+ extraRoutes;
1456
+ startedAt = null;
1457
+ server = null;
1458
+ constructor(deps) {
1459
+ this.channels = deps.channels;
1460
+ this.port = deps.port ?? resolveFunnelPort();
1461
+ this.hostname = deps.hostname ?? DEFAULT_HOST;
1462
+ this.dbPath = deps.dbPath ?? defaultDbPath();
1463
+ this.process = deps.process;
1464
+ this.logger = deps.logger;
1465
+ this.onError = deps.onError ?? defaultOnError;
1466
+ this.selfPid = deps.selfPid ?? globalThis.process.pid;
1467
+ this.dir = deps.dir ?? FUNNEL_DIR;
1468
+ this.killCompetingSlack = deps.killCompetingSlack ?? true;
1469
+ this.token = deps.token ?? "";
1470
+ this.extraRoutes = deps.extraRoutes ?? null;
1471
+ const clock = deps.clock;
1472
+ this.nowMs = clock ? () => clock.millis() : () => Date.now();
1473
+ if (deps.eventLog) this.eventLog = deps.eventLog;
1474
+ else {
1475
+ const dbDir = dirname(this.dbPath);
1476
+ if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
1477
+ this.eventLog = new SqliteFunnelEventLog({
1478
+ path: this.dbPath,
1479
+ now: this.nowMs
1480
+ });
1481
+ }
1482
+ this.broadcaster = new FunnelBroadcaster({
1483
+ logger: this.logger,
1484
+ onError: this.onError,
1485
+ now: this.nowMs,
1486
+ persistentReplay: this.eventLog
1487
+ });
1488
+ this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
1489
+ this.supervisor = new FunnelListenerSupervisor({
1490
+ channels: this.channels,
1491
+ logger: this.logger,
1492
+ onError: this.onError,
1493
+ notify: async (channelName, connectorName, content, meta) => {
1494
+ this.emit({
1495
+ channel: channelName,
1496
+ connector: connectorName,
1497
+ content,
1498
+ meta
1499
+ });
1500
+ },
1501
+ now: this.nowMs
1502
+ });
1503
+ }
1504
+ async start() {
1505
+ if (this.server) return this.server;
1506
+ if (!this.token && !LOOPBACK_HOSTS.has(this.hostname)) this.logger?.warn("gateway auth is disabled on a non-loopback bind — every endpoint is reachable without a token", { hostname: this.hostname });
1507
+ const app = this.buildApp();
1508
+ this.startedAt = this.nowMs();
1509
+ this.server = Bun.serve({
1510
+ port: this.port,
1511
+ hostname: this.hostname,
1512
+ development: false,
1513
+ fetch: (request, server) => this.handleFetch(request, server, app),
1514
+ websocket: {
1515
+ open: (ws) => this.handleWsOpen(ws),
1516
+ close: (ws) => this.handleWsClose(ws),
1517
+ message() {}
1518
+ }
1519
+ });
1520
+ this.logServerStarted();
1521
+ await this.bootListeners();
1522
+ return this.server;
1523
+ }
1524
+ async stop() {
1525
+ await this.supervisor.stopAll();
1526
+ if (this.server) {
1527
+ this.server.stop();
1528
+ this.server = null;
1529
+ }
1530
+ }
1531
+ getStatus() {
1532
+ return {
1533
+ clients: this.broadcaster.getClientCount(),
1534
+ channels: this.broadcaster.listChannels()
1535
+ };
1536
+ }
1537
+ getBroadcaster() {
1538
+ return this.broadcaster;
1539
+ }
1540
+ getSupervisor() {
1541
+ return this.supervisor;
1542
+ }
1543
+ getEventLog() {
1544
+ return this.eventLog;
1545
+ }
1546
+ /**
1547
+ * Register an in-process observer for every broadcast event. Fires after
1548
+ * the event is fanned out to WS clients and recorded in the event log.
1549
+ * Returns an unsubscribe function. Only meaningful in-process (embedded
1550
+ * hosts / `new Funnel(...)` running their own gateway-server); a separate
1551
+ * daemon process cannot be observed this way — use a WS client for that.
1552
+ */
1553
+ onEvent(handler) {
1554
+ return this.broadcaster.subscribe(handler);
1555
+ }
1556
+ handleFetch(request, server, app) {
1557
+ const url = new URL(request.url);
1558
+ if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
1559
+ if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
1560
+ const requestedChannel = url.searchParams.get("channel") ?? "";
1561
+ const channel = requestedChannel ? this.resolveChannel(requestedChannel) : null;
1562
+ const channelId = channel?.id ?? requestedChannel;
1563
+ const channelName = channel?.name ?? null;
1564
+ const connectors = channel?.connectors ?? [];
1565
+ const delivery = channel?.delivery ?? "fanout";
1566
+ const sinceRaw = url.searchParams.get("since");
1567
+ const sinceParsed = sinceRaw === null ? NaN : Number.parseInt(sinceRaw, 10);
1568
+ const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : void 0;
1569
+ const subscriberId = url.searchParams.get("id") ?? void 0;
1570
+ if (server.upgrade(request, { data: {
1571
+ channel: channelId,
1572
+ channelName,
1573
+ connectors,
1574
+ delivery,
1575
+ subscriberId,
1576
+ since
1577
+ } })) return void 0;
1578
+ return new Response("WebSocket upgrade failed", { status: 400 });
1579
+ }
1580
+ return app.fetch(request);
1581
+ }
1582
+ handleWsOpen(ws) {
1583
+ if (typeof ws.data.since === "number") {
1584
+ const replay = this.broadcaster.replaySince(ws.data.since, ws.data);
1585
+ for (const event of replay) ws.send(JSON.stringify(event));
1586
+ }
1587
+ this.broadcaster.addClient(ws, ws.data);
1588
+ this.logger?.info("channel connected", {
1589
+ event_type: "system",
1590
+ action: "channel_connect",
1591
+ channel: ws.data.channelName ?? "",
1592
+ channelId: ws.data.channel,
1593
+ connectors: ws.data.connectors.join(","),
1594
+ total: String(this.broadcaster.getClientCount())
1595
+ });
1596
+ }
1597
+ handleWsClose(ws) {
1598
+ this.broadcaster.removeClient(ws);
1599
+ this.logger?.info("channel disconnected", {
1600
+ event_type: "system",
1601
+ action: "channel_disconnect",
1602
+ channel: ws.data.channelName ?? "",
1603
+ channelId: ws.data.channel,
1604
+ total: String(this.broadcaster.getClientCount())
1605
+ });
1606
+ }
1607
+ logServerStarted() {
1608
+ this.logger?.info("gateway started", {
1609
+ event_type: "system",
1610
+ action: "gateway_start",
1611
+ port: String(this.port),
1612
+ pid: String(this.selfPid)
1613
+ });
1614
+ this.logger?.info("funnel gateway listening", {
1615
+ url: `http://localhost:${this.port}`,
1616
+ websocket: `ws://localhost:${this.port}/ws`,
1617
+ health: `http://localhost:${this.port}/health`
1618
+ });
1619
+ }
1620
+ buildApp() {
1621
+ const base = factory.createApp();
1622
+ base.use((c, next) => {
1623
+ c.set("deps", {
1624
+ selfPid: this.selfPid,
1625
+ broadcaster: this.broadcaster,
1626
+ supervisor: this.supervisor,
1627
+ channels: this.channels,
1628
+ uptimeMs: () => this.startedAt ? this.nowMs() - this.startedAt : 0,
1629
+ emit: (input) => this.emit(input)
1630
+ });
1631
+ return next();
1632
+ });
1633
+ if (this.token) {
1634
+ base.use("/listeners/*", requireBearerToken({ expected: this.token }));
1635
+ base.use("/status", requireBearerToken({ expected: this.token }));
1636
+ base.use("/debug", requireBearerToken({ expected: this.token }));
1637
+ base.use("/channels/*", requireBearerToken({ expected: this.token }));
1638
+ }
1639
+ return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
1640
+ }
1641
+ /**
1642
+ * Reads the bearer token from the WebSocket upgrade request. Accepts:
1643
+ * - `Sec-WebSocket-Protocol: funnel.token.<value>` (preferred — header, never logged in URLs)
1644
+ * - `Authorization: Bearer <value>` (also header-based)
1645
+ * Returns true on a constant-time match against the daemon token.
1646
+ */
1647
+ tokenMatchesUpgrade(request) {
1648
+ const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1649
+ for (const proto of protocols) if (proto.startsWith("funnel.token.") && constantTimeEqual(proto.slice(13), this.token)) return true;
1650
+ const match = (request.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i);
1651
+ if (match && constantTimeEqual(match[1] ?? "", this.token)) return true;
1652
+ return false;
1653
+ }
1654
+ resolveChannel(requested) {
1655
+ const channel = this.channels.get(requested) ?? this.channels.getById(requested);
1656
+ if (!channel) return null;
1657
+ return {
1658
+ id: channel.id,
1659
+ name: channel.name,
1660
+ connectors: channel.connectors.map((c) => c.name),
1661
+ delivery: channel.delivery
1662
+ };
1663
+ }
1664
+ async bootListeners() {
1665
+ const allConnectors = this.channels.listAllConnectors();
1666
+ if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
1667
+ const killed = await killCompetingSlackGateways({
1668
+ selfPid: this.selfPid,
1669
+ dir: this.dir,
1670
+ process: this.process,
1671
+ logger: this.logger
1672
+ });
1673
+ if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
1674
+ event_type: "system",
1675
+ action: "kill_competing",
1676
+ pids: killed.join(",")
1677
+ });
1678
+ }
1679
+ await this.supervisor.startAll();
1680
+ for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
1681
+ event_type: "system",
1682
+ action: `${entry.type}_connect`,
1683
+ channel: entry.channelName,
1684
+ connector: entry.name
1685
+ });
1686
+ this.logger?.info(`event store: ${this.dbPath}`);
1687
+ this.logger?.info("funnel gateway running");
1688
+ }
1689
+ /**
1690
+ * Broadcast `content` to subscribers of `channel`, persisting the event in
1691
+ * the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
1692
+ * when they resolve. Used by both the connector-listener path (via the
1693
+ * supervisor's `notify` callback) and the public `/channels/:channel/publish`
1694
+ * route. Returns the assigned event offset.
1695
+ */
1696
+ emit(input) {
1697
+ const channelId = this.lookupChannelId(input.channel);
1698
+ const connectorId = channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null;
1699
+ const enriched = {
1700
+ ...input.meta,
1701
+ channel: input.channel
1702
+ };
1703
+ if (input.connector) enriched.connector = input.connector;
1704
+ if (channelId) enriched.channelId = channelId;
1705
+ if (connectorId) enriched.connectorId = connectorId;
1706
+ const event = this.broadcaster.broadcast(input.content, enriched);
1707
+ this.eventLog.record({
1708
+ content: input.content,
1709
+ channelId: channelId ?? null,
1710
+ connectorId: connectorId ?? null,
1711
+ meta: enriched,
1712
+ offset: event.offset
1713
+ });
1714
+ return { offset: event.offset };
1715
+ }
1716
+ lookupChannelId(channelName) {
1717
+ return this.channels.get(channelName)?.id ?? null;
1718
+ }
1719
+ lookupConnectorId(channelId, connectorName) {
1720
+ return this.channels.getById(channelId)?.connectors.find((c) => c.name === connectorName)?.id ?? null;
1721
+ }
1722
+ };
1723
+ //#endregion
1724
+ //#region lib/gateway/gateway-token.ts
1725
+ const TOKEN_FILE_NAME = "gateway.token";
1726
+ const TOKEN_BYTES = 32;
1727
+ const defaultFs = new NodeFunnelFileSystem();
1728
+ const defaultGenerate = () => {
1729
+ const buf = new Uint8Array(TOKEN_BYTES);
1730
+ crypto.getRandomValues(buf);
1731
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
1732
+ };
1733
+ /**
1734
+ * Reads / generates the gateway daemon token used to authenticate
1735
+ * `/listeners*`, `/status`, and `/ws` connections.
1736
+ *
1737
+ * Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
1738
+ * written with mode 0600. Clients on the same machine as the daemon read
1739
+ * the file directly; the token never leaves the user's home directory.
1740
+ */
1741
+ var FunnelGatewayToken = class {
1742
+ fs;
1743
+ path;
1744
+ generate;
1745
+ constructor(deps = {}) {
1746
+ this.fs = deps.fs ?? defaultFs;
1747
+ this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME);
1748
+ this.generate = deps.generate ?? defaultGenerate;
1749
+ Object.freeze(this);
1750
+ }
1751
+ read() {
1752
+ if (!this.fs.existsSync(this.path)) return null;
1753
+ const value = this.fs.readFileSync(this.path).trim();
1754
+ return value.length > 0 ? value : null;
1755
+ }
1756
+ /**
1757
+ * Returns the existing token or, if missing, generates one and writes it with mode 0600.
1758
+ *
1759
+ * NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
1760
+ * itself before the PID lock is acquired) could each generate independent tokens. The
1761
+ * gateway PID file makes this practically a non-issue; if you need stronger guarantees,
1762
+ * take a file lock around this call externally.
1763
+ */
1764
+ ensure() {
1765
+ const existing = this.read();
1766
+ if (existing) return existing;
1767
+ const token = this.generate();
1768
+ this.fs.mkdirSync(dirname(this.path), { recursive: true });
1769
+ this.fs.writeSecretFileSync(this.path, `${token}\n`);
1770
+ return token;
1771
+ }
1772
+ getPath() {
1773
+ return this.path;
1774
+ }
1775
+ };
1776
+ const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME);
1777
+ //#endregion
1778
+ //#region lib/gateway/memory-funnel-event-log.ts
1779
+ /**
1780
+ * In-process `FunnelEventLog` backed by a plain array. Used by tests and by
1781
+ * embedders that do not need durability — replay works within the process
1782
+ * lifetime but is lost when the process exits. Unlike the SQLite log it does
1783
+ * not truncate content or prune, so it is not meant for unbounded production
1784
+ * traffic.
1785
+ */
1786
+ var MemoryFunnelEventLog = class extends FunnelEventLog {
1787
+ events = [];
1788
+ constructor() {
1789
+ super();
1790
+ Object.freeze(this);
1791
+ }
1792
+ record(record) {
1793
+ this.events.push({
1794
+ offset: record.offset,
1795
+ content: record.content,
1796
+ meta: record.meta ?? void 0,
1797
+ channelId: record.channelId,
1798
+ connectorId: record.connectorId
1799
+ });
1800
+ }
1801
+ loadSince(since) {
1802
+ const out = [];
1803
+ for (const event of this.events) if (event.offset > since) out.push({
1804
+ content: event.content,
1805
+ meta: event.meta,
1806
+ offset: event.offset
1807
+ });
1808
+ return out;
1809
+ }
1810
+ findMaxOffset() {
1811
+ let max = 0;
1812
+ for (const event of this.events) if (event.offset > max) max = event.offset;
1813
+ return max;
1814
+ }
1815
+ clear() {
1816
+ this.events.length = 0;
1817
+ }
1818
+ close() {}
1819
+ };
1820
+ //#endregion
1821
+ //#region lib/gateway/connector-diagnostic-log.ts
1822
+ /**
1823
+ * Points in the listener's connection lifecycle. The single source of truth
1824
+ * for the value set: the `status` column schema, the `ConnectorConnectionStatus`
1825
+ * union, and the runtime Set used to narrow on read-back all derive from this
1826
+ * array, so adding a status is a one-line change that cannot drift out of sync.
1827
+ *
1828
+ * started start() was called
1829
+ * connected the socket opened and events can flow
1830
+ * disconnected the socket was closed by a stop() call (a clean teardown)
1831
+ * auth-failed the token was rejected before the socket opened
1832
+ * stopped the listener was fully torn down (always follows a stop(),
1833
+ * paired with the disconnected/error that preceded it)
1834
+ * error start/stop threw, or Bolt surfaced an error frame — this is
1835
+ * also where an unsolicited socket drop shows up when Bolt
1836
+ * reports it (an `error` with no following `stopped` means the
1837
+ * supervisor recycled the listener, not a clean stop)
1838
+ *
1839
+ * A connection row is independent of any single inbound event, so it carries
1840
+ * no `eventId`. This is how "no notification arrived because the listener
1841
+ * never connected (or dropped, or failed auth)" becomes visible: the
1842
+ * raw/processed tables only hold events that *did* arrive.
1843
+ */
1844
+ const CONNECTOR_CONNECTION_STATUSES = [
1845
+ "started",
1846
+ "connected",
1847
+ "disconnected",
1848
+ "auth-failed",
1849
+ "stopped",
1850
+ "error"
1851
+ ];
1852
+ /**
1853
+ * Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
1854
+ * carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
1855
+ * connectors land in the same tables without a schema change. `event_id` is
1856
+ * the correlation key the listener mints once per inbound event and stamps
1857
+ * onto both the raw and processed rows, so the two are joinable even though
1858
+ * they live in separate tables with independent `seq` counters.
1859
+ *
1860
+ * These schemas mirror the stored shape (snake_case columns) the way
1861
+ * `FunnelEvent` does for the replay log; they exist for `z.infer` and to
1862
+ * document the column set, not as a parse boundary.
1863
+ */
1864
+ const connectorRawEventSchema = z.object({
1865
+ event_id: z.string(),
1866
+ type: z.string(),
1867
+ connector_id: z.string().nullable(),
1868
+ channel_id: z.string().nullable(),
1869
+ payload: z.string()
1870
+ });
1871
+ const connectorProcessedEventSchema = z.object({
1872
+ event_id: z.string(),
1873
+ type: z.string(),
1874
+ connector_id: z.string().nullable(),
1875
+ channel_id: z.string().nullable(),
1876
+ outcome: z.string(),
1877
+ payload: z.string()
1878
+ });
1879
+ const connectorConnectionEventSchema = z.object({
1880
+ type: z.string(),
1881
+ connector_id: z.string().nullable(),
1882
+ channel_id: z.string().nullable(),
1883
+ status: z.enum(CONNECTOR_CONNECTION_STATUSES),
1884
+ detail: z.string()
1885
+ });
1886
+ /**
1887
+ * Three-table diagnostic log of everything a connector listener does, so
1888
+ * "why was there no notification?" is answerable whichever way it failed:
1889
+ * - `raw` — every inbound event, before any filtering, with the listener's
1890
+ * untouched payload (the Slack Bolt event, the GH webhook, …)
1891
+ * - `processed` — the verdict for that event: `outcome` (emitted, or the
1892
+ * reason it was dropped) and, when emitted, the body that was delivered.
1893
+ * Shares an `eventId` with its raw row, so the two join into one story.
1894
+ * - `connection` — the listener's lifecycle (started, connected, dropped,
1895
+ * auth-failed, stopped, errored). This is the half the event tables can't
1896
+ * show: an event that never arrived leaves no raw row, but a listener that
1897
+ * never connected leaves a `connection` trail that says so.
1898
+ *
1899
+ * The three are physically separate (independent retention and payload-size
1900
+ * policy) so a query never crosses them by accident and a huge raw payload
1901
+ * never bloats the verdict or lifecycle trails. None flow to WS clients or the
1902
+ * MCP channel — this is a separate store from `FunnelEventLog` (replay) and
1903
+ * exists solely for debugging.
1904
+ *
1905
+ * Implementations:
1906
+ * - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
1907
+ * bounded by per-table row/age caps.
1908
+ * - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
1909
+ */
1910
+ var ConnectorDiagnosticLog = class {};
1911
+ //#endregion
1912
+ //#region lib/gateway/sqlite-connector-diagnostic-log.ts
1913
+ /**
1914
+ * Cap on a raw payload kept verbatim. The point of the raw table is to see
1915
+ * what Slack/Discord actually sent, and a typical event is a few KB — so 256
1916
+ * KiB keeps essentially everything intact while bounding the rare giant
1917
+ * payload (a huge Block Kit message, a file dump) that would otherwise let a
1918
+ * single row bloat the debug database without limit.
1919
+ */
1920
+ const RAW_PAYLOAD_CAP = 256 * 1024;
1921
+ /**
1922
+ * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
1923
+ * per table (raw / processed / connection), in separate files. Each sink
1924
+ * indexes the columns its queries filter on — `event_id` / `connector_id` /
1925
+ * `channel_id` for raw, plus `outcome` for processed and `status` for
1926
+ * connection — so those lookups are indexed scans (`type` is a fixed column
1927
+ * the sink extracts separately, not an index, so filtering by it is a scan).
1928
+ *
1929
+ * The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
1930
+ * truncating mid-string (which yields unparseable JSON), it replaces the
1931
+ * body with a small JSON object that keeps the diagnostic essentials and
1932
+ * records the dropped size under `_funnel_oversized`. Every stored payload
1933
+ * therefore stays valid JSON.
1934
+ */
1935
+ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1936
+ raw;
1937
+ processed;
1938
+ connection;
1939
+ now;
1940
+ logger;
1941
+ constructor(props) {
1942
+ super();
1943
+ this.now = props.now ?? (() => Date.now());
1944
+ this.logger = props.logger;
1945
+ const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
1946
+ const verdictCap = {
1947
+ now: this.now,
1948
+ ...ageCap,
1949
+ ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
1950
+ };
1951
+ const rawMax = props.rawMaxRows ?? props.maxRows;
1952
+ const rawCap = {
1953
+ now: this.now,
1954
+ ...ageCap,
1955
+ ...rawMax !== void 0 ? { maxRows: rawMax } : {}
1956
+ };
1957
+ this.raw = new LeucoLoggerSqliteSink({
1958
+ path: props.rawPath,
1959
+ indexes: [
1960
+ "event_id",
1961
+ "connector_id",
1962
+ "channel_id"
1963
+ ],
1964
+ extractIndexes: (event) => ({
1965
+ event_id: event.event_id,
1966
+ connector_id: event.connector_id,
1967
+ channel_id: event.channel_id
1968
+ }),
1969
+ ...rawCap
1970
+ });
1971
+ this.processed = new LeucoLoggerSqliteSink({
1972
+ path: props.processedPath,
1973
+ indexes: [
1974
+ "event_id",
1975
+ "connector_id",
1976
+ "channel_id",
1977
+ "outcome"
1978
+ ],
1979
+ extractIndexes: (event) => ({
1980
+ event_id: event.event_id,
1981
+ connector_id: event.connector_id,
1982
+ channel_id: event.channel_id,
1983
+ outcome: event.outcome
1984
+ }),
1985
+ ...verdictCap
1986
+ });
1987
+ this.connection = new LeucoLoggerSqliteSink({
1988
+ path: props.connectionPath,
1989
+ indexes: [
1990
+ "connector_id",
1991
+ "channel_id",
1992
+ "status"
1993
+ ],
1994
+ extractIndexes: (event) => ({
1995
+ connector_id: event.connector_id,
1996
+ channel_id: event.channel_id,
1997
+ status: event.status
1998
+ }),
1999
+ ...verdictCap
2000
+ });
2001
+ restrictPermissions(props.rawPath);
2002
+ restrictPermissions(props.processedPath);
2003
+ restrictPermissions(props.connectionPath);
2004
+ Object.freeze(this);
2005
+ }
2006
+ recordRaw(record) {
2007
+ const event = {
2008
+ event_id: record.eventId,
2009
+ type: record.type,
2010
+ connector_id: record.connectorId,
2011
+ channel_id: record.channelId,
2012
+ payload: capPayload(record.payload, record.type)
2013
+ };
2014
+ this.report("raw", this.raw.insert({
2015
+ ts: this.now(),
2016
+ event
2017
+ }));
2018
+ }
2019
+ recordProcessed(record) {
2020
+ const event = {
2021
+ event_id: record.eventId,
2022
+ type: record.type,
2023
+ connector_id: record.connectorId,
2024
+ channel_id: record.channelId,
2025
+ outcome: record.outcome,
2026
+ payload: record.payload
2027
+ };
2028
+ this.report("processed", this.processed.insert({
2029
+ ts: this.now(),
2030
+ event
2031
+ }));
2032
+ }
2033
+ recordConnection(record) {
2034
+ const event = {
2035
+ type: record.type,
2036
+ connector_id: record.connectorId,
2037
+ channel_id: record.channelId,
2038
+ status: record.status,
2039
+ detail: record.detail
2040
+ };
2041
+ this.report("connection", this.connection.insert({
2042
+ ts: this.now(),
2043
+ event
2044
+ }));
2045
+ }
2046
+ report(table, result) {
2047
+ if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
2048
+ table,
2049
+ error: result.message
2050
+ });
2051
+ }
2052
+ queryRaw(query) {
2053
+ return this.raw.getRecords({
2054
+ ...query.type !== void 0 ? { type: query.type } : {},
2055
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
2056
+ where: buildWhere(query),
2057
+ order: "desc"
2058
+ }).map((record) => ({
2059
+ seq: record.seq,
2060
+ ts: record.ts,
2061
+ eventId: record.event.event_id,
2062
+ type: record.event.type,
2063
+ connectorId: record.event.connector_id,
2064
+ channelId: record.event.channel_id,
2065
+ payload: record.event.payload
2066
+ }));
2067
+ }
2068
+ queryProcessed(query) {
2069
+ const where = buildWhere(query);
2070
+ if (query.outcome !== void 0) where.outcome = query.outcome;
2071
+ return this.processed.getRecords({
2072
+ ...query.type !== void 0 ? { type: query.type } : {},
2073
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
2074
+ where,
2075
+ order: "desc"
2076
+ }).map((record) => ({
2077
+ seq: record.seq,
2078
+ ts: record.ts,
2079
+ eventId: record.event.event_id,
2080
+ type: record.event.type,
2081
+ connectorId: record.event.connector_id,
2082
+ channelId: record.event.channel_id,
2083
+ outcome: record.event.outcome,
2084
+ payload: record.event.payload
2085
+ }));
2086
+ }
2087
+ queryConnection(query) {
2088
+ const where = buildWhere(query);
2089
+ if (query.status !== void 0) where.status = query.status;
2090
+ return this.connection.getRecords({
2091
+ ...query.type !== void 0 ? { type: query.type } : {},
2092
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
2093
+ where,
2094
+ order: "desc"
2095
+ }).map((record) => ({
2096
+ seq: record.seq,
2097
+ ts: record.ts,
2098
+ type: record.event.type,
2099
+ connectorId: record.event.connector_id,
2100
+ channelId: record.event.channel_id,
2101
+ status: statusOf(record.event.status),
2102
+ detail: record.event.detail
2103
+ }));
2104
+ }
2105
+ clear() {
2106
+ this.raw.clear();
2107
+ this.processed.clear();
2108
+ this.connection.clear();
2109
+ }
2110
+ close() {
2111
+ this.raw.close();
2112
+ this.processed.close();
2113
+ this.connection.close();
2114
+ }
2115
+ };
2116
+ const restrictPermissions = (path) => {
2117
+ if (path === ":memory:") return;
2118
+ for (const suffix of [
2119
+ "",
2120
+ "-wal",
2121
+ "-shm"
2122
+ ]) try {
2123
+ chmodSync(`${path}${suffix}`, 384);
2124
+ } catch {}
2125
+ };
2126
+ const buildWhere = (query) => {
2127
+ const where = {};
2128
+ if (query.connectorId !== void 0) where.connector_id = query.connectorId;
2129
+ if (query.channelId !== void 0) where.channel_id = query.channelId;
2130
+ return where;
2131
+ };
2132
+ const statusField = connectorConnectionEventSchema.shape.status;
2133
+ const statusOf = (value) => {
2134
+ const parsed = statusField.safeParse(value);
2135
+ return parsed.success ? parsed.data : "error";
2136
+ };
2137
+ const capPayload = (payload, type) => {
2138
+ const size = Buffer.byteLength(payload, "utf8");
2139
+ if (size <= RAW_PAYLOAD_CAP) return payload;
2140
+ return JSON.stringify({
2141
+ ...headFields(payload),
2142
+ _funnel_oversized: size,
2143
+ _funnel_type: type
2144
+ });
2145
+ };
2146
+ const HEAD_KEYS = [
2147
+ "type",
2148
+ "subtype",
2149
+ "ts",
2150
+ "channel",
2151
+ "channel_type",
2152
+ "user",
2153
+ "bot_id"
2154
+ ];
2155
+ const headFields = (payload) => {
2156
+ try {
2157
+ const parsed = JSON.parse(payload);
2158
+ if (typeof parsed !== "object" || parsed === null) return {};
2159
+ const source = parsed;
2160
+ const head = {};
2161
+ for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
2162
+ return head;
2163
+ } catch {
2164
+ return {};
2165
+ }
2166
+ };
2167
+ //#endregion
2168
+ //#region lib/gateway/memory-connector-diagnostic-log.ts
2169
+ /**
2170
+ * In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
2171
+ * and embedders that do not need durability. Like the SQLite log it keeps
2172
+ * `seq` per-table (each array's 1-based position) and returns the most recent
2173
+ * `limit` rows oldest-first; unlike it, it never prunes and never offloads
2174
+ * oversized payloads — it keeps whatever the caller hands it, which is fine
2175
+ * for the bounded volumes a test produces. Payload-validity is therefore a
2176
+ * SQLite-only guarantee; do not write a test that leans on this double
2177
+ * rejecting a malformed payload.
2178
+ */
2179
+ var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2180
+ raws = [];
2181
+ processeds = [];
2182
+ connections = [];
2183
+ constructor(now = () => Date.now()) {
2184
+ super();
2185
+ this.now = now;
2186
+ Object.freeze(this);
2187
+ }
2188
+ recordRaw(record) {
2189
+ this.raws.push({
2190
+ ...record,
2191
+ seq: this.raws.length + 1,
2192
+ ts: this.now()
2193
+ });
2194
+ }
2195
+ recordProcessed(record) {
2196
+ this.processeds.push({
2197
+ ...record,
2198
+ seq: this.processeds.length + 1,
2199
+ ts: this.now()
2200
+ });
2201
+ }
2202
+ recordConnection(record) {
2203
+ this.connections.push({
2204
+ ...record,
2205
+ seq: this.connections.length + 1,
2206
+ ts: this.now()
2207
+ });
2208
+ }
2209
+ queryRaw(query) {
2210
+ return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
2211
+ }
2212
+ queryProcessed(query) {
2213
+ return takeRecent(this.processeds.filter((event) => {
2214
+ if (!matches(event, query)) return false;
2215
+ if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
2216
+ return true;
2217
+ }), query.limit);
2218
+ }
2219
+ queryConnection(query) {
2220
+ return takeRecent(this.connections.filter((event) => {
2221
+ if (!matches(event, query)) return false;
2222
+ if (query.status !== void 0 && event.status !== query.status) return false;
2223
+ return true;
2224
+ }), query.limit);
2225
+ }
2226
+ clear() {
2227
+ this.raws.length = 0;
2228
+ this.processeds.length = 0;
2229
+ this.connections.length = 0;
2230
+ }
2231
+ close() {}
2232
+ };
2233
+ const matches = (event, query) => {
2234
+ if (query.type !== void 0 && event.type !== query.type) return false;
2235
+ if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
2236
+ if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
2237
+ return true;
2238
+ };
2239
+ const takeRecent = (events, limit) => {
2240
+ if (limit === void 0) return events;
2241
+ if (limit <= 0) return [];
2242
+ return events.slice(-limit);
2243
+ };
2244
+ //#endregion
2245
+ export { FunnelBroadcaster as _, connectorConnectionEventSchema as a, publishResponseSchema as b, MemoryFunnelEventLog as c, FunnelGatewayServer as d, ConnectorDiagnosticSqlReader as f, funnelEventSchema as g, FunnelEventLog as h, ConnectorDiagnosticLog as i, DEFAULT_GATEWAY_TOKEN_PATH as l, SqliteFunnelEventLog as m, SqliteConnectorDiagnosticLog as n, connectorProcessedEventSchema as o, FunnelListenerSupervisor as p, CONNECTOR_CONNECTION_STATUSES as r, connectorRawEventSchema as s, MemoryConnectorDiagnosticLog as t, FunnelGatewayToken as u, FunnelChannelPublisher as v, funnelTmpDir as x, publishRequestSchema as y };