@interactive-inc/claude-funnel 0.49.0 → 0.50.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.
- package/dist/bin.js +1 -1
- package/dist/claude-CB1WkV77.d.ts +115 -0
- package/dist/claude.d.ts +59 -0
- package/dist/claude.js +322 -0
- package/dist/{connector-diagnostic-log-OPpPi9V9.d.ts → connector-diagnostic-log-yTOojKUR.d.ts} +14 -14
- package/dist/{logger-Czli2OKh.js → connector-listener-DU54DN-f.js} +1 -9
- package/dist/connectors/discord.d.ts +3 -3
- package/dist/connectors/discord.js +2 -1
- package/dist/connectors/gh.d.ts +4 -3
- package/dist/connectors/gh.js +2 -1
- package/dist/connectors/schedule.d.ts +1 -1
- package/dist/connectors/schedule.js +2 -1
- package/dist/connectors/slack.d.ts +2 -2
- package/dist/connectors/slack.js +2 -1
- package/dist/discord-connector-schema-CBDyGdOI.js +21 -0
- package/dist/{discord-connector-schema-BeThExJp.js → discord-listener-_jSE3HsQ.js} +2 -22
- package/dist/file-system-BeOKXjlV.d.ts +26 -0
- package/dist/file-system-PWKKU7lA.js +9 -0
- package/dist/gateway.d.ts +3 -0
- package/dist/gateway.js +2 -0
- package/dist/gh-connector-schema-eoTtHbY6.d.ts +14 -0
- package/dist/{gh-connector-schema-eYE4g77K.js → gh-connector-schema-o3Q1-ojL.js} +1 -176
- package/dist/gh-listener-DH-fClQm.js +178 -0
- package/dist/index-ChomoTZ5.d.ts +3404 -0
- package/dist/index.d.ts +11 -4083
- package/dist/index.js +247 -3459
- package/dist/local-config-json-schema-8IHjS4Q7.js +439 -0
- package/dist/local-config-sync-BdsrDZOu.d.ts +381 -0
- package/dist/local-config.d.ts +3 -0
- package/dist/local-config.js +3 -0
- package/dist/logger-BP6SisKt.js +9 -0
- package/dist/mcp-Dr-nIBwN.js +253 -0
- package/dist/memory-connector-diagnostic-log-CrW1ltLM.js +2245 -0
- package/dist/memory-token-prompter-B5FFCsGP.d.ts +57 -0
- package/dist/memory-token-prompter-CLerGsgM.js +61 -0
- package/dist/node-file-system-BcrmWN9I.js +48 -0
- package/dist/{gh-connector-schema-CQmEWzdV.d.ts → process-runner-DfniuWVU.d.ts} +1 -14
- package/dist/profiles-f0mNmEyP.d.ts +64 -0
- package/dist/profiles-wMRnjSid.js +129 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +2 -0
- package/dist/schedule-connector-schema-iCI61gzU.js +31 -0
- package/dist/{schedule-listener-3M6WkH1Y.d.ts → schedule-listener-CUyUFFR1.d.ts} +22 -46
- package/dist/{schedule-connector-schema-CM-sRkac.js → schedule-listener-ePAjians.js} +3 -86
- package/dist/settings-reader-BSU6JyvM.d.ts +167 -0
- package/dist/settings-reader-DPqrpV7s.js +11 -0
- package/dist/settings-store-D2XSXTyt.js +186 -0
- package/dist/slack-connector-schema-BCNWluHM.js +32 -0
- package/dist/{slack-listener-9UdAn_ui.d.ts → slack-listener-Bv5xI9gC.d.ts} +31 -31
- package/dist/{slack-connector-schema-DDbSGPZn.js → slack-listener-ClQuHhEF.js} +2 -32
- package/package.json +1 -1
- /package/dist/{connector-adapter-VA6undzc.d.ts → connector-adapter-DKgsVuMH.d.ts} +0 -0
- /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 };
|