@interactive-inc/claude-funnel 0.60.1 → 0.63.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/README.md +2 -2
- package/dist/bin.js +428 -761
- package/dist/{channels-2g_BU1N0.d.ts → channels-B8RQPrVq.d.ts} +17 -16
- package/dist/claude.d.ts +5 -7
- package/dist/claude.js +143 -36
- package/dist/{connector-descriptor-6SXJoszo.d.ts → connector-descriptor-ClEEbuW3.d.ts} +50 -11
- package/dist/connector-diagnostics-recorder-COtNEmUp.js +42 -0
- package/dist/connectors/discord.d.ts +31 -37
- package/dist/connectors/discord.js +3 -3
- package/dist/connectors/gh.d.ts +37 -33
- package/dist/connectors/gh.js +3 -3
- package/dist/connectors/schedule.d.ts +9 -57
- package/dist/connectors/schedule.js +3 -3
- package/dist/connectors/slack.d.ts +71 -131
- package/dist/connectors/slack.js +4 -3
- package/dist/diagnostics.d.ts +1 -1
- package/dist/diagnostics.js +1 -1
- package/dist/discord-connector-DIFkYBbi.js +250 -0
- package/dist/discord-connector-schema-D-bOVAKt.d.ts +22 -0
- package/dist/docs.js +1 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/{file-process-guard-C_PLxfUX.d.ts → file-process-guard-DGHxALfI.d.ts} +6 -6
- package/dist/{file-system-o51IsM0W.d.ts → file-system-VhwwXZbm.d.ts} +8 -0
- package/dist/flume-source-listener-Dim5szHG.d.ts +133 -0
- package/dist/{funnel-diagnostics-CSiJmPlZ.js → funnel-diagnostics-Cvk6Sk4x.js} +193 -43
- package/dist/{funnel-diagnostics-DpXOsCty.d.ts → funnel-diagnostics-b9ar0Ing.d.ts} +67 -5
- package/dist/{funnel-docs-BxXZ9Ksx.js → funnel-docs-C-ge0MuB.js} +42 -6
- package/dist/{funnel-doctor-CZf_0Luq.d.ts → funnel-doctor-CnRQi4kM.d.ts} +2 -2
- package/dist/{funnel-doctor-DiJCjHsg.js → funnel-doctor-XrI2GBH8.js} +1 -1
- package/dist/funnel-error-0t1MK1R6.js +75 -0
- package/dist/{funnel-recovery-DnLrdWO9.d.ts → funnel-recovery-CMhY8Jfk.d.ts} +1 -1
- package/dist/gateway/daemon.js +167 -527
- package/dist/gateway.d.ts +3 -3
- package/dist/gateway.js +3 -3
- package/dist/gh-connector-BUGCOEWS.js +187 -0
- package/dist/{gh-connector-schema-Rzwc1c1N.js → gh-connector-schema-CAqIhzGr.js} +7 -0
- package/dist/gh-connector-schema-DWQaB6gX.d.ts +16 -0
- package/dist/{index-CgY8NdMz.d.ts → index-DxRikYmu.d.ts} +37 -19
- package/dist/index.d.ts +182 -22
- package/dist/index.js +363 -173
- package/dist/{local-config-json-schema-JyLqOQNX.js → local-config-json-schema-DexV8vX3.js} +24 -4
- package/dist/local-config.d.ts +39 -2
- package/dist/local-config.js +53 -2
- package/dist/logger.js +1 -1
- package/dist/loopback-fetch-CVNuN3YZ.js +40 -0
- package/dist/{local-config-sync-Dh1Croqe.d.ts → memory-token-prompter-DP_YV9xX.d.ts} +30 -3
- package/dist/node-file-system-BOXIHW_Q.js +174 -0
- package/dist/{profiles-DSzTeKQw.js → profiles-ZHLONml4.js} +49 -49
- package/dist/{profiles-Cy5wXQ0L.d.ts → profiles-cVZQkM69.d.ts} +3 -3
- package/dist/profiles.d.ts +1 -1
- package/dist/profiles.js +1 -1
- package/dist/recovery.d.ts +1 -1
- package/dist/recovery.js +1 -1
- package/dist/resolve-connector-token-DxDG9mhf.js +22 -0
- package/dist/{schedule-connector-L4uzg5M8.js → schedule-connector-9k3gOIgl.js} +54 -55
- package/dist/schedule-connector-schema-Z0RXLgPI.d.ts +49 -0
- package/dist/settings-reader-BNxjsxCB.d.ts +27 -0
- package/dist/{settings-store-CUKSeTXC.js → settings-store-C2QdOH-t.js} +23 -4
- package/dist/slack-connector-BU86fIge.js +359 -0
- package/dist/slack-event-processor-BhCf5Wiy.d.ts +95 -0
- package/dist/slack-event-processor-xFDG3US0.js +176 -0
- package/dist/slot-fields-D-pvMgTK.js +249 -0
- package/dist/{memory-diagnostic-log-CI60kNfB.js → sqlite-diagnostic-log-DOTPW-tG.js} +373 -249
- package/dist/{yaml-render-93pX7EF7.js → yaml-render--J1_3BSA.js} +25 -21
- package/package.json +2 -4
- package/dist/discord-connector-BL36yvbL.js +0 -250
- package/dist/gateway-base-url-Dy4Ykuoh.js +0 -14
- package/dist/gh-connector-DpiixfQZ.js +0 -226
- package/dist/http-client-oICicjuO.d.ts +0 -18
- package/dist/memory-token-prompter-B4sjyaAq.d.ts +0 -57
- package/dist/memory-token-prompter-CZde7e6y.js +0 -61
- package/dist/node-file-system-Blr8pAir.js +0 -48
- package/dist/settings-reader-BIFB_j2f.d.ts +0 -18
- package/dist/slack-connector-DQIFPdBF.js +0 -484
- package/dist/slot-fields-CMoRpwuy.js +0 -45
- /package/dist/{connector-adapter-DU9Rvyec.js → connector-adapter-Dvs8N7ew.js} +0 -0
- /package/dist/{connector-listener-DR3aKOuK.js → connector-listener-mPGZYa8e.js} +0 -0
- /package/dist/{diagnostic-sql-reader-C9zR-Csp.js → diagnostic-sql-reader-oXZnWFf_.js} +0 -0
- /package/dist/{discord-connector-schema-B_N6IXLz.js → discord-connector-schema-B4YpWpR3.js} +0 -0
- /package/dist/{error-message-of-Byi4y0Uf.js → error-message-of-ColuYmAk.js} +0 -0
- /package/dist/{funnel-log-sqlite-sink-kqJbx2H7.js → funnel-log-sqlite-sink-DLYkY0pZ.js} +0 -0
- /package/dist/{funnel-recovery-BFdPjL6Z.js → funnel-recovery-DKnEutUS.js} +0 -0
- /package/dist/{node-http-client-lowp60Oa.js → node-http-client-u00atiKx.js} +0 -0
- /package/dist/{schedule-connector-schema-CfyuMCMh.js → schedule-connector-schema-DKEPZnVv.js} +0 -0
- /package/dist/{settings-reader-CtQ-Ix8_.js → settings-reader-9FcX3qS1.js} +0 -0
- /package/dist/{settings-schema-D1xcOqRu.d.ts → settings-schema-BL_c2Udm.d.ts} +0 -0
- /package/dist/{slack-connector-schema-C1zEf4TG.js → slack-connector-schema-Dem8to4P.js} +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as NodeFunnelFileSystem } from "./node-file-system-
|
|
1
|
+
import { n as gatewayLoopbackUrl, t as loopbackFetch } from "./loopback-fetch-CVNuN3YZ.js";
|
|
2
|
+
import { t as NodeFunnelFileSystem } from "./node-file-system-BOXIHW_Q.js";
|
|
3
3
|
import { t as NodeFunnelProcessRunner } from "./node-process-runner-DxTvycoK.js";
|
|
4
|
-
import { n as FUNNEL_DIR, o as resolveFunnelPort } from "./settings-store-
|
|
5
|
-
import { t as
|
|
6
|
-
import { t as
|
|
4
|
+
import { n as FUNNEL_DIR, o as resolveFunnelPort } from "./settings-store-C2QdOH-t.js";
|
|
5
|
+
import { t as FunnelAuthFailedError } from "./funnel-error-0t1MK1R6.js";
|
|
6
|
+
import { t as ConnectorDiagnosticSqlReader } from "./diagnostic-sql-reader-oXZnWFf_.js";
|
|
7
|
+
import { t as FunnelLogSqliteSink } from "./funnel-log-sqlite-sink-DLYkY0pZ.js";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
9
10
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -24,6 +25,174 @@ function funnelTmpDir() {
|
|
|
24
25
|
return join(tmpdir(), "funnel");
|
|
25
26
|
}
|
|
26
27
|
//#endregion
|
|
28
|
+
//#region lib/engine/diagnostic-log/diagnostic-log.ts
|
|
29
|
+
/**
|
|
30
|
+
* Points in the listener's connection lifecycle. The single source of truth
|
|
31
|
+
* for the value set: the `status` column schema, the `ConnectorConnectionStatus`
|
|
32
|
+
* union, and the runtime Set used to narrow on read-back all derive from this
|
|
33
|
+
* array, so adding a status is a one-line change that cannot drift out of sync.
|
|
34
|
+
*
|
|
35
|
+
* started start() was called
|
|
36
|
+
* connected the socket opened and events can flow
|
|
37
|
+
* disconnected the socket was closed by a stop() call (a clean teardown)
|
|
38
|
+
* auth-failed the token was rejected before the socket opened
|
|
39
|
+
* stopped the listener was fully torn down (always follows a stop(),
|
|
40
|
+
* paired with the disconnected/error that preceded it)
|
|
41
|
+
* error start/stop threw, or Bolt surfaced an error frame — this is
|
|
42
|
+
* also where an unsolicited socket drop shows up when Bolt
|
|
43
|
+
* reports it (an `error` with no following `stopped` means the
|
|
44
|
+
* supervisor recycled the listener, not a clean stop)
|
|
45
|
+
*
|
|
46
|
+
* A connection row is independent of any single inbound event, so it carries
|
|
47
|
+
* no `eventId`. This is how "no notification arrived because the listener
|
|
48
|
+
* never connected (or dropped, or failed auth)" becomes visible: the
|
|
49
|
+
* raw/processed tables only hold events that *did* arrive.
|
|
50
|
+
*/
|
|
51
|
+
const CONNECTOR_CONNECTION_STATUSES = [
|
|
52
|
+
"started",
|
|
53
|
+
"connected",
|
|
54
|
+
"disconnected",
|
|
55
|
+
"auth-failed",
|
|
56
|
+
"stopped",
|
|
57
|
+
"error"
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
|
|
61
|
+
* carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
|
|
62
|
+
* connectors land in the same tables without a schema change. `event_id` is
|
|
63
|
+
* the correlation key the listener mints once per inbound event and stamps
|
|
64
|
+
* onto both the raw and processed rows, so the two are joinable even though
|
|
65
|
+
* they live in separate tables with independent `seq` counters.
|
|
66
|
+
*
|
|
67
|
+
* These schemas mirror the stored shape (snake_case columns) the way
|
|
68
|
+
* `FunnelEvent` does for the replay log; they exist for `z.infer` and to
|
|
69
|
+
* document the column set, not as a parse boundary.
|
|
70
|
+
*/
|
|
71
|
+
const connectorRawEventSchema = z.object({
|
|
72
|
+
event_id: z.string(),
|
|
73
|
+
type: z.string(),
|
|
74
|
+
connector_id: z.string().nullable(),
|
|
75
|
+
channel_id: z.string().nullable(),
|
|
76
|
+
payload: z.string()
|
|
77
|
+
});
|
|
78
|
+
const connectorProcessedEventSchema = z.object({
|
|
79
|
+
event_id: z.string(),
|
|
80
|
+
type: z.string(),
|
|
81
|
+
connector_id: z.string().nullable(),
|
|
82
|
+
channel_id: z.string().nullable(),
|
|
83
|
+
outcome: z.string(),
|
|
84
|
+
payload: z.string()
|
|
85
|
+
});
|
|
86
|
+
const connectorConnectionEventSchema = z.object({
|
|
87
|
+
type: z.string(),
|
|
88
|
+
connector_id: z.string().nullable(),
|
|
89
|
+
channel_id: z.string().nullable(),
|
|
90
|
+
status: z.enum(CONNECTOR_CONNECTION_STATUSES),
|
|
91
|
+
detail: z.string()
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Three-table diagnostic log of everything a connector listener does, so
|
|
95
|
+
* "why was there no notification?" is answerable whichever way it failed:
|
|
96
|
+
* - `raw` — every inbound event, before any filtering, with the listener's
|
|
97
|
+
* untouched payload (the Slack Bolt event, the GH webhook, …)
|
|
98
|
+
* - `processed` — the verdict for that event: `outcome` (emitted, or the
|
|
99
|
+
* reason it was dropped) and, when emitted, the body that was delivered.
|
|
100
|
+
* Shares an `eventId` with its raw row, so the two join into one story.
|
|
101
|
+
* - `connection` — the listener's lifecycle (started, connected, dropped,
|
|
102
|
+
* auth-failed, stopped, errored). This is the half the event tables can't
|
|
103
|
+
* show: an event that never arrived leaves no raw row, but a listener that
|
|
104
|
+
* never connected leaves a `connection` trail that says so.
|
|
105
|
+
*
|
|
106
|
+
* The three are physically separate (independent retention and payload-size
|
|
107
|
+
* policy) so a query never crosses them by accident and a huge raw payload
|
|
108
|
+
* never bloats the verdict or lifecycle trails. None flow to WS clients or the
|
|
109
|
+
* MCP channel — this is a separate store from `FunnelEventLog` (replay) and
|
|
110
|
+
* exists solely for debugging.
|
|
111
|
+
*
|
|
112
|
+
* Implementations:
|
|
113
|
+
* - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
|
|
114
|
+
* bounded by per-table row/age caps.
|
|
115
|
+
* - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
|
|
116
|
+
*/
|
|
117
|
+
var ConnectorDiagnosticLog = class {};
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region lib/engine/diagnostic-log/memory-diagnostic-log.ts
|
|
120
|
+
/**
|
|
121
|
+
* In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
|
|
122
|
+
* and embedders that do not need durability. Like the SQLite log it keeps
|
|
123
|
+
* `seq` per-table (each array's 1-based position) and returns the most recent
|
|
124
|
+
* `limit` rows oldest-first; unlike it, it never prunes and never offloads
|
|
125
|
+
* oversized payloads — it keeps whatever the caller hands it, which is fine
|
|
126
|
+
* for the bounded volumes a test produces. Payload-validity is therefore a
|
|
127
|
+
* SQLite-only guarantee; do not write a test that leans on this double
|
|
128
|
+
* rejecting a malformed payload.
|
|
129
|
+
*/
|
|
130
|
+
var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
|
|
131
|
+
raws = [];
|
|
132
|
+
processeds = [];
|
|
133
|
+
connections = [];
|
|
134
|
+
constructor(now = () => Date.now()) {
|
|
135
|
+
super();
|
|
136
|
+
this.now = now;
|
|
137
|
+
Object.freeze(this);
|
|
138
|
+
}
|
|
139
|
+
recordRaw(record) {
|
|
140
|
+
this.raws.push({
|
|
141
|
+
...record,
|
|
142
|
+
seq: this.raws.length + 1,
|
|
143
|
+
ts: this.now()
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
recordProcessed(record) {
|
|
147
|
+
this.processeds.push({
|
|
148
|
+
...record,
|
|
149
|
+
seq: this.processeds.length + 1,
|
|
150
|
+
ts: this.now()
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
recordConnection(record) {
|
|
154
|
+
this.connections.push({
|
|
155
|
+
...record,
|
|
156
|
+
seq: this.connections.length + 1,
|
|
157
|
+
ts: this.now()
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
queryRaw(query) {
|
|
161
|
+
return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
|
|
162
|
+
}
|
|
163
|
+
queryProcessed(query) {
|
|
164
|
+
return takeRecent(this.processeds.filter((event) => {
|
|
165
|
+
if (!matches(event, query)) return false;
|
|
166
|
+
if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
|
|
167
|
+
return true;
|
|
168
|
+
}), query.limit);
|
|
169
|
+
}
|
|
170
|
+
queryConnection(query) {
|
|
171
|
+
return takeRecent(this.connections.filter((event) => {
|
|
172
|
+
if (!matches(event, query)) return false;
|
|
173
|
+
if (query.status !== void 0 && event.status !== query.status) return false;
|
|
174
|
+
return true;
|
|
175
|
+
}), query.limit);
|
|
176
|
+
}
|
|
177
|
+
clear() {
|
|
178
|
+
this.raws.length = 0;
|
|
179
|
+
this.processeds.length = 0;
|
|
180
|
+
this.connections.length = 0;
|
|
181
|
+
}
|
|
182
|
+
close() {}
|
|
183
|
+
};
|
|
184
|
+
const matches = (event, query) => {
|
|
185
|
+
if (query.type !== void 0 && event.type !== query.type) return false;
|
|
186
|
+
if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
|
|
187
|
+
if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
|
|
188
|
+
return true;
|
|
189
|
+
};
|
|
190
|
+
const takeRecent = (events, limit) => {
|
|
191
|
+
if (limit === void 0) return events;
|
|
192
|
+
if (limit <= 0) return [];
|
|
193
|
+
return events.slice(-limit);
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
27
196
|
//#region lib/gateway/publish-schema.ts
|
|
28
197
|
/**
|
|
29
198
|
* Shared schema for `POST /channels/:channel/publish` — used by both the
|
|
@@ -67,8 +236,7 @@ var FunnelChannelPublisher = class {
|
|
|
67
236
|
async publish(channelName, request) {
|
|
68
237
|
if (!this.isDaemonRunning()) return OFFLINE;
|
|
69
238
|
try {
|
|
70
|
-
const
|
|
71
|
-
const res = await fetch(url, {
|
|
239
|
+
const res = await loopbackFetch(`${gatewayLoopbackUrl(this.port)}/channels/${encodeURIComponent(channelName)}/publish`, {
|
|
72
240
|
method: "POST",
|
|
73
241
|
headers: {
|
|
74
242
|
...this.authHeaders(),
|
|
@@ -308,19 +476,35 @@ var FunnelBroadcaster = class {
|
|
|
308
476
|
this.droppedSlowClients += 1;
|
|
309
477
|
continue;
|
|
310
478
|
}
|
|
311
|
-
|
|
479
|
+
try {
|
|
480
|
+
ws.send(payload);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
483
|
+
const data = this.clients.get(ws);
|
|
484
|
+
this.logger?.warn("ws.send failed; dropping client", {
|
|
485
|
+
channel: data?.channel,
|
|
486
|
+
error: err.message
|
|
487
|
+
});
|
|
488
|
+
this.clients.delete(ws);
|
|
489
|
+
}
|
|
312
490
|
}
|
|
313
|
-
for (const handler of this.subscribers)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
491
|
+
for (const handler of this.subscribers) {
|
|
492
|
+
const captureError = (error) => {
|
|
493
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
494
|
+
this.logger?.error("broadcast subscriber threw", { error: err.message });
|
|
495
|
+
this.onError(err, {
|
|
496
|
+
component: "broadcaster.subscriber",
|
|
497
|
+
offset: event.offset,
|
|
498
|
+
connector: event.meta?.connector ?? null,
|
|
499
|
+
channel: event.meta?.channel ?? null
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
try {
|
|
503
|
+
const result = handler(event);
|
|
504
|
+
if (result instanceof Promise) result.catch(captureError);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
captureError(error);
|
|
507
|
+
}
|
|
324
508
|
}
|
|
325
509
|
return event;
|
|
326
510
|
}
|
|
@@ -384,10 +568,12 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
384
568
|
sink;
|
|
385
569
|
now;
|
|
386
570
|
logger;
|
|
571
|
+
onError;
|
|
387
572
|
constructor(props) {
|
|
388
573
|
super();
|
|
389
574
|
this.now = props.now ?? (() => Date.now());
|
|
390
575
|
this.logger = props.logger;
|
|
576
|
+
this.onError = props.onError;
|
|
391
577
|
this.sink = new FunnelLogSqliteSink({
|
|
392
578
|
path: props.path,
|
|
393
579
|
indexes: ["channel_id", "connector_id"],
|
|
@@ -420,10 +606,19 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
420
606
|
ts: this.now(),
|
|
421
607
|
event
|
|
422
608
|
});
|
|
423
|
-
if (result instanceof Error)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
609
|
+
if (result instanceof Error) {
|
|
610
|
+
this.logger?.error("event log write failed", {
|
|
611
|
+
offset: record.offset,
|
|
612
|
+
error: result.message
|
|
613
|
+
});
|
|
614
|
+
this.onError?.(result, {
|
|
615
|
+
component: "sqlite-event-log",
|
|
616
|
+
op: "record",
|
|
617
|
+
offset: record.offset,
|
|
618
|
+
channelId: record.channelId,
|
|
619
|
+
connectorId: record.connectorId
|
|
620
|
+
});
|
|
621
|
+
}
|
|
427
622
|
}
|
|
428
623
|
/**
|
|
429
624
|
* Returns events with offset > since. Filtering by channel/connector is
|
|
@@ -476,7 +671,7 @@ function truncate(content) {
|
|
|
476
671
|
return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
|
|
477
672
|
}
|
|
478
673
|
//#endregion
|
|
479
|
-
//#region lib/gateway/listener-
|
|
674
|
+
//#region lib/gateway/listener-registry.ts
|
|
480
675
|
const defaultOnError$1 = () => {};
|
|
481
676
|
const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
|
|
482
677
|
const DEFAULT_MAX_BACKOFF_MS = 6e4;
|
|
@@ -496,13 +691,14 @@ const defaultSleep$1 = (ms) => new Promise((r) => {
|
|
|
496
691
|
* dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
|
|
497
692
|
* the backoff counter on successful restart.
|
|
498
693
|
*/
|
|
499
|
-
var
|
|
694
|
+
var FunnelListenerRegistry = class FunnelListenerRegistry {
|
|
500
695
|
channels;
|
|
501
696
|
notify;
|
|
502
697
|
logger;
|
|
503
698
|
onError;
|
|
504
699
|
running = /* @__PURE__ */ new Map();
|
|
505
700
|
failureCounts = /* @__PURE__ */ new Map();
|
|
701
|
+
starting = /* @__PURE__ */ new Set();
|
|
506
702
|
stats = /* @__PURE__ */ new Map();
|
|
507
703
|
healthCheckIntervalMs;
|
|
508
704
|
maxBackoffMs;
|
|
@@ -528,7 +724,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
528
724
|
return `${channelName}/${connectorName}`;
|
|
529
725
|
}
|
|
530
726
|
isRunning(channelName, connectorName) {
|
|
531
|
-
return this.running.has(
|
|
727
|
+
return this.running.has(FunnelListenerRegistry.keyOf(channelName, connectorName));
|
|
532
728
|
}
|
|
533
729
|
list() {
|
|
534
730
|
return [...this.running.entries()].map(([key, entry]) => {
|
|
@@ -547,11 +743,24 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
547
743
|
});
|
|
548
744
|
}
|
|
549
745
|
async start(channelName, connectorName) {
|
|
550
|
-
const key =
|
|
746
|
+
const key = FunnelListenerRegistry.keyOf(channelName, connectorName);
|
|
551
747
|
if (this.running.has(key)) return {
|
|
552
748
|
ok: true,
|
|
553
749
|
reason: "already running"
|
|
554
750
|
};
|
|
751
|
+
if (this.starting.has(key)) return {
|
|
752
|
+
ok: false,
|
|
753
|
+
reason: "already starting",
|
|
754
|
+
retriable: true
|
|
755
|
+
};
|
|
756
|
+
this.starting.add(key);
|
|
757
|
+
try {
|
|
758
|
+
return await this.startLocked(channelName, connectorName, key);
|
|
759
|
+
} finally {
|
|
760
|
+
this.starting.delete(key);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async startLocked(channelName, connectorName, key) {
|
|
555
764
|
const created = this.channels.createListener(channelName, connectorName);
|
|
556
765
|
if (!created) return {
|
|
557
766
|
ok: false,
|
|
@@ -585,25 +794,30 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
585
794
|
return { ok: true };
|
|
586
795
|
} catch (error) {
|
|
587
796
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
797
|
+
try {
|
|
798
|
+
await created.listener.stop();
|
|
799
|
+
} catch {}
|
|
588
800
|
this.logger?.error(`${created.config.type} listener failed to start`, {
|
|
589
801
|
channel: channelName,
|
|
590
802
|
connector: connectorName,
|
|
591
803
|
error: err.message
|
|
592
804
|
});
|
|
593
805
|
this.onError(err, {
|
|
594
|
-
component: "listener-
|
|
806
|
+
component: "listener-registry.start",
|
|
595
807
|
channel: channelName,
|
|
596
808
|
connector: connectorName,
|
|
597
809
|
type: created.config.type
|
|
598
810
|
});
|
|
811
|
+
const retriable = !(err instanceof FunnelAuthFailedError);
|
|
599
812
|
return {
|
|
600
813
|
ok: false,
|
|
601
|
-
reason: err.message
|
|
814
|
+
reason: err.message,
|
|
815
|
+
retriable
|
|
602
816
|
};
|
|
603
817
|
}
|
|
604
818
|
}
|
|
605
819
|
async stop(channelName, connectorName) {
|
|
606
|
-
const key =
|
|
820
|
+
const key = FunnelListenerRegistry.keyOf(channelName, connectorName);
|
|
607
821
|
const entry = this.running.get(key);
|
|
608
822
|
if (!entry) return {
|
|
609
823
|
ok: true,
|
|
@@ -624,7 +838,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
624
838
|
error: err.message
|
|
625
839
|
});
|
|
626
840
|
this.onError(err, {
|
|
627
|
-
component: "listener-
|
|
841
|
+
component: "listener-registry.stop",
|
|
628
842
|
channel: channelName,
|
|
629
843
|
connector: connectorName,
|
|
630
844
|
type: entry.config.type
|
|
@@ -649,8 +863,17 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
649
863
|
for (let i = 0; i < results.length; i++) {
|
|
650
864
|
const result = results[i];
|
|
651
865
|
const view = all[i];
|
|
652
|
-
if (result.status === "rejected"
|
|
653
|
-
const key =
|
|
866
|
+
if (result.status === "rejected") {
|
|
867
|
+
const key = FunnelListenerRegistry.keyOf(view.channelName, view.name);
|
|
868
|
+
this.pendingRetry.set(key, {
|
|
869
|
+
channelName: view.channelName,
|
|
870
|
+
connectorName: view.name
|
|
871
|
+
});
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (result.status === "fulfilled" && !result.value.ok) {
|
|
875
|
+
if (result.value.retriable === false) continue;
|
|
876
|
+
const key = FunnelListenerRegistry.keyOf(view.channelName, view.name);
|
|
654
877
|
this.pendingRetry.set(key, {
|
|
655
878
|
channelName: view.channelName,
|
|
656
879
|
connectorName: view.name
|
|
@@ -687,7 +910,11 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
687
910
|
startHealthCheck() {
|
|
688
911
|
if (this.healthCheckTimer) return;
|
|
689
912
|
this.healthCheckTimer = setInterval(() => {
|
|
690
|
-
this.runHealthCheck()
|
|
913
|
+
this.runHealthCheck().catch((error) => {
|
|
914
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
915
|
+
this.logger?.error("health check pass failed", { error: err.message });
|
|
916
|
+
this.onError(err, { component: "listener-registry.health-check" });
|
|
917
|
+
});
|
|
691
918
|
}, this.healthCheckIntervalMs);
|
|
692
919
|
this.healthCheckTimer.unref();
|
|
693
920
|
}
|
|
@@ -704,36 +931,64 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
704
931
|
if (this.healthCheckInFlight) return;
|
|
705
932
|
this.healthCheckInFlight = true;
|
|
706
933
|
try {
|
|
934
|
+
const dead = [];
|
|
707
935
|
for (const [key, entry] of [...this.running.entries()]) {
|
|
708
936
|
if (entry.listener.isAlive()) {
|
|
709
937
|
this.failureCounts.delete(key);
|
|
710
938
|
continue;
|
|
711
939
|
}
|
|
712
|
-
|
|
940
|
+
dead.push({
|
|
941
|
+
channelName: entry.channelName,
|
|
942
|
+
connectorName: entry.config.name,
|
|
943
|
+
type: entry.config.type
|
|
944
|
+
});
|
|
713
945
|
}
|
|
946
|
+
await Promise.all(dead.map((target) => this.recoverDead(target.channelName, target.connectorName, target.type)));
|
|
947
|
+
const retries = [];
|
|
714
948
|
for (const [key, pending] of [...this.pendingRetry.entries()]) {
|
|
715
949
|
if (this.running.has(key)) {
|
|
716
950
|
this.pendingRetry.delete(key);
|
|
717
951
|
continue;
|
|
718
952
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
953
|
+
retries.push({
|
|
954
|
+
key,
|
|
955
|
+
channelName: pending.channelName,
|
|
956
|
+
connectorName: pending.connectorName
|
|
722
957
|
});
|
|
723
|
-
const failureCount = this.failureCounts.get(key) ?? 0;
|
|
724
|
-
const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
|
|
725
|
-
await this.sleep(backoffMs);
|
|
726
|
-
if ((await this.start(pending.channelName, pending.connectorName)).ok) {
|
|
727
|
-
this.pendingRetry.delete(key);
|
|
728
|
-
this.failureCounts.delete(key);
|
|
729
|
-
} else this.failureCounts.set(key, failureCount + 1);
|
|
730
958
|
}
|
|
959
|
+
await Promise.all(retries.map((retry) => this.attemptRetry(retry)));
|
|
731
960
|
} finally {
|
|
732
961
|
this.healthCheckInFlight = false;
|
|
733
962
|
}
|
|
734
963
|
}
|
|
964
|
+
async attemptRetry(retry) {
|
|
965
|
+
this.logger?.info("retrying failed listener", {
|
|
966
|
+
channel: retry.channelName,
|
|
967
|
+
connector: retry.connectorName
|
|
968
|
+
});
|
|
969
|
+
const failureCount = this.failureCounts.get(retry.key) ?? 0;
|
|
970
|
+
const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
|
|
971
|
+
await this.sleep(backoffMs);
|
|
972
|
+
const result = await this.start(retry.channelName, retry.connectorName);
|
|
973
|
+
if (result.ok) {
|
|
974
|
+
this.pendingRetry.delete(retry.key);
|
|
975
|
+
this.failureCounts.delete(retry.key);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (result.retriable === false) {
|
|
979
|
+
this.pendingRetry.delete(retry.key);
|
|
980
|
+
this.failureCounts.delete(retry.key);
|
|
981
|
+
this.logger?.warn("dropping listener from retry queue (non-retriable)", {
|
|
982
|
+
channel: retry.channelName,
|
|
983
|
+
connector: retry.connectorName,
|
|
984
|
+
reason: result.reason
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
this.failureCounts.set(retry.key, failureCount + 1);
|
|
989
|
+
}
|
|
735
990
|
async recoverDead(channelName, connectorName, type) {
|
|
736
|
-
const key =
|
|
991
|
+
const key = FunnelListenerRegistry.keyOf(channelName, connectorName);
|
|
737
992
|
const failureCount = this.failureCounts.get(key) ?? 0;
|
|
738
993
|
const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
|
|
739
994
|
this.logger?.warn(`${type} listener unhealthy, restarting`, {
|
|
@@ -744,12 +999,20 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
744
999
|
});
|
|
745
1000
|
await this.stop(channelName, connectorName);
|
|
746
1001
|
await this.sleep(backoffMs);
|
|
747
|
-
|
|
1002
|
+
const result = await this.start(channelName, connectorName);
|
|
1003
|
+
if (result.ok) {
|
|
748
1004
|
this.failureCounts.delete(key);
|
|
749
1005
|
this.logger?.info(`${type} listener recovered`, {
|
|
750
1006
|
channel: channelName,
|
|
751
1007
|
connector: connectorName
|
|
752
1008
|
});
|
|
1009
|
+
} else if (result.retriable === false) {
|
|
1010
|
+
this.failureCounts.delete(key);
|
|
1011
|
+
this.logger?.warn(`${type} listener cannot recover (non-retriable)`, {
|
|
1012
|
+
channel: channelName,
|
|
1013
|
+
connector: connectorName,
|
|
1014
|
+
reason: result.reason
|
|
1015
|
+
});
|
|
753
1016
|
} else this.failureCounts.set(key, failureCount + 1);
|
|
754
1017
|
}
|
|
755
1018
|
};
|
|
@@ -859,7 +1122,12 @@ const channelsConnectorsCallHandler = factory.createHandlers(zParam(z.object({
|
|
|
859
1122
|
connector: z.string().min(1)
|
|
860
1123
|
})), async (c) => {
|
|
861
1124
|
const param = c.req.valid("param");
|
|
862
|
-
|
|
1125
|
+
let raw = null;
|
|
1126
|
+
try {
|
|
1127
|
+
raw = await c.req.json();
|
|
1128
|
+
} catch {
|
|
1129
|
+
raw = null;
|
|
1130
|
+
}
|
|
863
1131
|
const parsed = bodySchema.safeParse(raw);
|
|
864
1132
|
if (!parsed.success) throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
865
1133
|
const result = await c.var.deps.channels.call(param.channel, param.connector, {
|
|
@@ -971,7 +1239,7 @@ const debugHandler = factory.createHandlers(async (c) => {
|
|
|
971
1239
|
const channelFilter = c.req.query("channel") ?? null;
|
|
972
1240
|
const allChannels = deps.channels.list();
|
|
973
1241
|
const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
|
|
974
|
-
const gatewayListeners = deps.
|
|
1242
|
+
const gatewayListeners = deps.registry.list();
|
|
975
1243
|
const gatewayClients = deps.broadcaster.listChannels();
|
|
976
1244
|
const metrics = deps.broadcaster.getMetrics();
|
|
977
1245
|
const tmpDir = funnelTmpDir();
|
|
@@ -1074,14 +1342,14 @@ const healthHandler = factory.createHandlers((c) => {
|
|
|
1074
1342
|
pid: deps.selfPid,
|
|
1075
1343
|
funnelDir: deps.dir,
|
|
1076
1344
|
clients: deps.broadcaster.getClientCount(),
|
|
1077
|
-
listeners: deps.
|
|
1345
|
+
listeners: deps.registry.list()
|
|
1078
1346
|
});
|
|
1079
1347
|
});
|
|
1080
1348
|
//#endregion
|
|
1081
1349
|
//#region lib/gateway/routes/listeners.list.ts
|
|
1082
1350
|
/** GET /listeners — running connector listeners with alive/dead status. */
|
|
1083
1351
|
const listenersListHandler = factory.createHandlers((c) => {
|
|
1084
|
-
return c.json({ listeners: c.var.deps.
|
|
1352
|
+
return c.json({ listeners: c.var.deps.registry.list() });
|
|
1085
1353
|
});
|
|
1086
1354
|
//#endregion
|
|
1087
1355
|
//#region lib/gateway/routes/listeners.restart.ts
|
|
@@ -1091,7 +1359,7 @@ const listenersRestartHandler = factory.createHandlers(zParam(z.object({
|
|
|
1091
1359
|
connector: z.string().min(1)
|
|
1092
1360
|
})), async (c) => {
|
|
1093
1361
|
const param = c.req.valid("param");
|
|
1094
|
-
const result = await c.var.deps.
|
|
1362
|
+
const result = await c.var.deps.registry.restart(param.channel, param.connector);
|
|
1095
1363
|
return c.json(result, result.ok ? 200 : 400);
|
|
1096
1364
|
});
|
|
1097
1365
|
//#endregion
|
|
@@ -1102,7 +1370,7 @@ const listenersStartHandler = factory.createHandlers(zParam(z.object({
|
|
|
1102
1370
|
connector: z.string().min(1)
|
|
1103
1371
|
})), async (c) => {
|
|
1104
1372
|
const param = c.req.valid("param");
|
|
1105
|
-
const result = await c.var.deps.
|
|
1373
|
+
const result = await c.var.deps.registry.start(param.channel, param.connector);
|
|
1106
1374
|
return c.json(result, result.ok ? 200 : 400);
|
|
1107
1375
|
});
|
|
1108
1376
|
//#endregion
|
|
@@ -1113,7 +1381,7 @@ const listenersStopHandler = factory.createHandlers(zParam(z.object({
|
|
|
1113
1381
|
connector: z.string().min(1)
|
|
1114
1382
|
})), async (c) => {
|
|
1115
1383
|
const param = c.req.valid("param");
|
|
1116
|
-
const result = await c.var.deps.
|
|
1384
|
+
const result = await c.var.deps.registry.stop(param.channel, param.connector);
|
|
1117
1385
|
return c.json(result, result.ok ? 200 : 400);
|
|
1118
1386
|
});
|
|
1119
1387
|
//#endregion
|
|
@@ -1127,7 +1395,7 @@ const statusHandler = factory.createHandlers((c) => {
|
|
|
1127
1395
|
funnelDir: deps.dir,
|
|
1128
1396
|
uptimeMs: deps.uptimeMs(),
|
|
1129
1397
|
clients: deps.broadcaster.listChannels(),
|
|
1130
|
-
listeners: deps.
|
|
1398
|
+
listeners: deps.registry.list(),
|
|
1131
1399
|
broadcaster: deps.broadcaster.getMetrics()
|
|
1132
1400
|
});
|
|
1133
1401
|
});
|
|
@@ -1154,7 +1422,7 @@ const defaultDbPath = () => join(funnelTmpDir(), "events.db");
|
|
|
1154
1422
|
const defaultOnError = () => {};
|
|
1155
1423
|
/**
|
|
1156
1424
|
* In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
|
|
1157
|
-
* listeners through `
|
|
1425
|
+
* listeners through `FunnelListenerRegistry`, fans events out via
|
|
1158
1426
|
* `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
|
|
1159
1427
|
* System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
|
|
1160
1428
|
* instead — keeping the SQLite seq space exclusive to broadcaster traffic so
|
|
@@ -1177,11 +1445,13 @@ var FunnelGatewayServer = class {
|
|
|
1177
1445
|
allowInsecureHost;
|
|
1178
1446
|
broadcaster;
|
|
1179
1447
|
eventLog;
|
|
1180
|
-
|
|
1448
|
+
registry;
|
|
1181
1449
|
nowMs;
|
|
1182
1450
|
extraRoutes;
|
|
1451
|
+
ownsEventLog;
|
|
1183
1452
|
startedAt = null;
|
|
1184
1453
|
server = null;
|
|
1454
|
+
disposed = false;
|
|
1185
1455
|
constructor(deps) {
|
|
1186
1456
|
this.channels = deps.channels;
|
|
1187
1457
|
this.configuredPort = deps.port ?? resolveFunnelPort();
|
|
@@ -1198,15 +1468,19 @@ var FunnelGatewayServer = class {
|
|
|
1198
1468
|
this.extraRoutes = deps.extraRoutes ?? null;
|
|
1199
1469
|
const clock = deps.clock;
|
|
1200
1470
|
this.nowMs = clock ? () => clock.millis() : () => Date.now();
|
|
1201
|
-
if (deps.eventLog)
|
|
1202
|
-
|
|
1471
|
+
if (deps.eventLog) {
|
|
1472
|
+
this.eventLog = deps.eventLog;
|
|
1473
|
+
this.ownsEventLog = false;
|
|
1474
|
+
} else {
|
|
1203
1475
|
const dbDir = dirname(this.dbPath);
|
|
1204
1476
|
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
1205
1477
|
this.eventLog = new SqliteFunnelEventLog({
|
|
1206
1478
|
path: this.dbPath,
|
|
1207
1479
|
now: this.nowMs,
|
|
1208
|
-
logger: this.logger
|
|
1480
|
+
logger: this.logger,
|
|
1481
|
+
onError: this.onError
|
|
1209
1482
|
});
|
|
1483
|
+
this.ownsEventLog = true;
|
|
1210
1484
|
}
|
|
1211
1485
|
this.broadcaster = new FunnelBroadcaster({
|
|
1212
1486
|
logger: this.logger,
|
|
@@ -1215,7 +1489,7 @@ var FunnelGatewayServer = class {
|
|
|
1215
1489
|
persistentReplay: this.eventLog
|
|
1216
1490
|
});
|
|
1217
1491
|
this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
|
|
1218
|
-
this.
|
|
1492
|
+
this.registry = new FunnelListenerRegistry({
|
|
1219
1493
|
channels: this.channels,
|
|
1220
1494
|
logger: this.logger,
|
|
1221
1495
|
onError: this.onError,
|
|
@@ -1242,6 +1516,7 @@ var FunnelGatewayServer = class {
|
|
|
1242
1516
|
return this.server?.hostname ?? this.configuredHostname;
|
|
1243
1517
|
}
|
|
1244
1518
|
async start() {
|
|
1519
|
+
if (this.disposed) throw new Error("FunnelGatewayServer is single-use: construct a new instance to start again");
|
|
1245
1520
|
if (this.server) return;
|
|
1246
1521
|
if (!this.token && !LOOPBACK_HOSTS.has(this.configuredHostname) && !this.allowInsecureHost) throw new Error(`refusing to start gateway: hostname "${this.configuredHostname}" is reachable off-box but no token is set. Set a token, bind to loopback (127.0.0.1), or pass allowInsecureHost: true.`);
|
|
1247
1522
|
const app = this.buildApp();
|
|
@@ -1259,14 +1534,22 @@ var FunnelGatewayServer = class {
|
|
|
1259
1534
|
}
|
|
1260
1535
|
});
|
|
1261
1536
|
this.logServerStarted();
|
|
1262
|
-
|
|
1537
|
+
try {
|
|
1538
|
+
await this.bootListeners();
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
this.server.stop();
|
|
1541
|
+
this.server = null;
|
|
1542
|
+
throw error;
|
|
1543
|
+
}
|
|
1263
1544
|
}
|
|
1264
1545
|
async stop() {
|
|
1265
|
-
await this.
|
|
1546
|
+
await this.registry.stopAll();
|
|
1266
1547
|
if (this.server) {
|
|
1267
1548
|
this.server.stop();
|
|
1268
1549
|
this.server = null;
|
|
1269
1550
|
}
|
|
1551
|
+
if (this.ownsEventLog) this.eventLog.close();
|
|
1552
|
+
this.disposed = true;
|
|
1270
1553
|
}
|
|
1271
1554
|
getStatus() {
|
|
1272
1555
|
return {
|
|
@@ -1277,8 +1560,8 @@ var FunnelGatewayServer = class {
|
|
|
1277
1560
|
getBroadcaster() {
|
|
1278
1561
|
return this.broadcaster;
|
|
1279
1562
|
}
|
|
1280
|
-
|
|
1281
|
-
return this.
|
|
1563
|
+
getRegistry() {
|
|
1564
|
+
return this.registry;
|
|
1282
1565
|
}
|
|
1283
1566
|
getEventLog() {
|
|
1284
1567
|
return this.eventLog;
|
|
@@ -1323,7 +1606,14 @@ var FunnelGatewayServer = class {
|
|
|
1323
1606
|
handleWsOpen(ws) {
|
|
1324
1607
|
if (typeof ws.data.since === "number") {
|
|
1325
1608
|
const replay = this.broadcaster.replaySince(ws.data.since, ws.data);
|
|
1326
|
-
|
|
1609
|
+
try {
|
|
1610
|
+
for (const event of replay) ws.send(JSON.stringify(event));
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1613
|
+
this.logger?.warn("replay send failed during ws.open", { error: err.message });
|
|
1614
|
+
this.onError(err, { component: "gateway-server.replay" });
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1327
1617
|
}
|
|
1328
1618
|
this.broadcaster.addClient(ws, ws.data);
|
|
1329
1619
|
this.logger?.info("channel connected", {
|
|
@@ -1365,7 +1655,7 @@ var FunnelGatewayServer = class {
|
|
|
1365
1655
|
selfPid: this.selfPid,
|
|
1366
1656
|
dir: this.dir,
|
|
1367
1657
|
broadcaster: this.broadcaster,
|
|
1368
|
-
|
|
1658
|
+
registry: this.registry,
|
|
1369
1659
|
channels: this.channels,
|
|
1370
1660
|
uptimeMs: () => this.startedAt ? this.nowMs() - this.startedAt : 0,
|
|
1371
1661
|
emit: (input) => this.emit(input)
|
|
@@ -1419,8 +1709,8 @@ var FunnelGatewayServer = class {
|
|
|
1419
1709
|
});
|
|
1420
1710
|
}
|
|
1421
1711
|
async bootListeners() {
|
|
1422
|
-
await this.
|
|
1423
|
-
for (const entry of this.
|
|
1712
|
+
await this.registry.startAll();
|
|
1713
|
+
for (const entry of this.registry.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
|
|
1424
1714
|
event_type: "system",
|
|
1425
1715
|
action: `${entry.type}_connect`,
|
|
1426
1716
|
channel: entry.channelName,
|
|
@@ -1501,20 +1791,22 @@ var FunnelGatewayToken = class {
|
|
|
1501
1791
|
return value.length > 0 ? value : null;
|
|
1502
1792
|
}
|
|
1503
1793
|
/**
|
|
1504
|
-
* Returns the existing token or, if missing, generates one and writes it
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1794
|
+
* Returns the existing token or, if missing, generates one and writes it
|
|
1795
|
+
* with mode 0600. Read+write runs inside an exclusive lock so two
|
|
1796
|
+
* concurrent `ensure()` calls (a daemon spawn racing a CLI helper that
|
|
1797
|
+
* reads the token before the gateway PID lock is acquired) cannot each
|
|
1798
|
+
* persist a different token and leave one side authenticating against a
|
|
1799
|
+
* value the other never sees.
|
|
1510
1800
|
*/
|
|
1511
1801
|
ensure() {
|
|
1512
|
-
const existing = this.read();
|
|
1513
|
-
if (existing) return existing;
|
|
1514
|
-
const token = this.generate();
|
|
1515
1802
|
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
1516
|
-
this.fs.
|
|
1517
|
-
|
|
1803
|
+
return this.fs.withFileLock(`${this.path}.lock`, () => {
|
|
1804
|
+
const existing = this.read();
|
|
1805
|
+
if (existing) return existing;
|
|
1806
|
+
const token = this.generate();
|
|
1807
|
+
this.fs.writeSecretFileSync(this.path, `${token}\n`);
|
|
1808
|
+
return token;
|
|
1809
|
+
});
|
|
1518
1810
|
}
|
|
1519
1811
|
getPath() {
|
|
1520
1812
|
return this.path;
|
|
@@ -1586,97 +1878,6 @@ var MemoryFunnelEventLog = class extends FunnelEventLog {
|
|
|
1586
1878
|
close() {}
|
|
1587
1879
|
};
|
|
1588
1880
|
//#endregion
|
|
1589
|
-
//#region lib/engine/diagnostic-log/diagnostic-log.ts
|
|
1590
|
-
/**
|
|
1591
|
-
* Points in the listener's connection lifecycle. The single source of truth
|
|
1592
|
-
* for the value set: the `status` column schema, the `ConnectorConnectionStatus`
|
|
1593
|
-
* union, and the runtime Set used to narrow on read-back all derive from this
|
|
1594
|
-
* array, so adding a status is a one-line change that cannot drift out of sync.
|
|
1595
|
-
*
|
|
1596
|
-
* started start() was called
|
|
1597
|
-
* connected the socket opened and events can flow
|
|
1598
|
-
* disconnected the socket was closed by a stop() call (a clean teardown)
|
|
1599
|
-
* auth-failed the token was rejected before the socket opened
|
|
1600
|
-
* stopped the listener was fully torn down (always follows a stop(),
|
|
1601
|
-
* paired with the disconnected/error that preceded it)
|
|
1602
|
-
* error start/stop threw, or Bolt surfaced an error frame — this is
|
|
1603
|
-
* also where an unsolicited socket drop shows up when Bolt
|
|
1604
|
-
* reports it (an `error` with no following `stopped` means the
|
|
1605
|
-
* supervisor recycled the listener, not a clean stop)
|
|
1606
|
-
*
|
|
1607
|
-
* A connection row is independent of any single inbound event, so it carries
|
|
1608
|
-
* no `eventId`. This is how "no notification arrived because the listener
|
|
1609
|
-
* never connected (or dropped, or failed auth)" becomes visible: the
|
|
1610
|
-
* raw/processed tables only hold events that *did* arrive.
|
|
1611
|
-
*/
|
|
1612
|
-
const CONNECTOR_CONNECTION_STATUSES = [
|
|
1613
|
-
"started",
|
|
1614
|
-
"connected",
|
|
1615
|
-
"disconnected",
|
|
1616
|
-
"auth-failed",
|
|
1617
|
-
"stopped",
|
|
1618
|
-
"error"
|
|
1619
|
-
];
|
|
1620
|
-
/**
|
|
1621
|
-
* Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
|
|
1622
|
-
* carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
|
|
1623
|
-
* connectors land in the same tables without a schema change. `event_id` is
|
|
1624
|
-
* the correlation key the listener mints once per inbound event and stamps
|
|
1625
|
-
* onto both the raw and processed rows, so the two are joinable even though
|
|
1626
|
-
* they live in separate tables with independent `seq` counters.
|
|
1627
|
-
*
|
|
1628
|
-
* These schemas mirror the stored shape (snake_case columns) the way
|
|
1629
|
-
* `FunnelEvent` does for the replay log; they exist for `z.infer` and to
|
|
1630
|
-
* document the column set, not as a parse boundary.
|
|
1631
|
-
*/
|
|
1632
|
-
const connectorRawEventSchema = z.object({
|
|
1633
|
-
event_id: z.string(),
|
|
1634
|
-
type: z.string(),
|
|
1635
|
-
connector_id: z.string().nullable(),
|
|
1636
|
-
channel_id: z.string().nullable(),
|
|
1637
|
-
payload: z.string()
|
|
1638
|
-
});
|
|
1639
|
-
const connectorProcessedEventSchema = z.object({
|
|
1640
|
-
event_id: z.string(),
|
|
1641
|
-
type: z.string(),
|
|
1642
|
-
connector_id: z.string().nullable(),
|
|
1643
|
-
channel_id: z.string().nullable(),
|
|
1644
|
-
outcome: z.string(),
|
|
1645
|
-
payload: z.string()
|
|
1646
|
-
});
|
|
1647
|
-
const connectorConnectionEventSchema = z.object({
|
|
1648
|
-
type: z.string(),
|
|
1649
|
-
connector_id: z.string().nullable(),
|
|
1650
|
-
channel_id: z.string().nullable(),
|
|
1651
|
-
status: z.enum(CONNECTOR_CONNECTION_STATUSES),
|
|
1652
|
-
detail: z.string()
|
|
1653
|
-
});
|
|
1654
|
-
/**
|
|
1655
|
-
* Three-table diagnostic log of everything a connector listener does, so
|
|
1656
|
-
* "why was there no notification?" is answerable whichever way it failed:
|
|
1657
|
-
* - `raw` — every inbound event, before any filtering, with the listener's
|
|
1658
|
-
* untouched payload (the Slack Bolt event, the GH webhook, …)
|
|
1659
|
-
* - `processed` — the verdict for that event: `outcome` (emitted, or the
|
|
1660
|
-
* reason it was dropped) and, when emitted, the body that was delivered.
|
|
1661
|
-
* Shares an `eventId` with its raw row, so the two join into one story.
|
|
1662
|
-
* - `connection` — the listener's lifecycle (started, connected, dropped,
|
|
1663
|
-
* auth-failed, stopped, errored). This is the half the event tables can't
|
|
1664
|
-
* show: an event that never arrived leaves no raw row, but a listener that
|
|
1665
|
-
* never connected leaves a `connection` trail that says so.
|
|
1666
|
-
*
|
|
1667
|
-
* The three are physically separate (independent retention and payload-size
|
|
1668
|
-
* policy) so a query never crosses them by accident and a huge raw payload
|
|
1669
|
-
* never bloats the verdict or lifecycle trails. None flow to WS clients or the
|
|
1670
|
-
* MCP channel — this is a separate store from `FunnelEventLog` (replay) and
|
|
1671
|
-
* exists solely for debugging.
|
|
1672
|
-
*
|
|
1673
|
-
* Implementations:
|
|
1674
|
-
* - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
|
|
1675
|
-
* bounded by per-table row/age caps.
|
|
1676
|
-
* - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
|
|
1677
|
-
*/
|
|
1678
|
-
var ConnectorDiagnosticLog = class {};
|
|
1679
|
-
//#endregion
|
|
1680
1881
|
//#region lib/engine/diagnostic-log/sqlite-diagnostic-log.ts
|
|
1681
1882
|
/**
|
|
1682
1883
|
* Cap on a raw payload kept verbatim. The point of the raw table is to see
|
|
@@ -1933,81 +2134,4 @@ const headFields = (payload) => {
|
|
|
1933
2134
|
}
|
|
1934
2135
|
};
|
|
1935
2136
|
//#endregion
|
|
1936
|
-
|
|
1937
|
-
/**
|
|
1938
|
-
* In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
|
|
1939
|
-
* and embedders that do not need durability. Like the SQLite log it keeps
|
|
1940
|
-
* `seq` per-table (each array's 1-based position) and returns the most recent
|
|
1941
|
-
* `limit` rows oldest-first; unlike it, it never prunes and never offloads
|
|
1942
|
-
* oversized payloads — it keeps whatever the caller hands it, which is fine
|
|
1943
|
-
* for the bounded volumes a test produces. Payload-validity is therefore a
|
|
1944
|
-
* SQLite-only guarantee; do not write a test that leans on this double
|
|
1945
|
-
* rejecting a malformed payload.
|
|
1946
|
-
*/
|
|
1947
|
-
var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
|
|
1948
|
-
raws = [];
|
|
1949
|
-
processeds = [];
|
|
1950
|
-
connections = [];
|
|
1951
|
-
constructor(now = () => Date.now()) {
|
|
1952
|
-
super();
|
|
1953
|
-
this.now = now;
|
|
1954
|
-
Object.freeze(this);
|
|
1955
|
-
}
|
|
1956
|
-
recordRaw(record) {
|
|
1957
|
-
this.raws.push({
|
|
1958
|
-
...record,
|
|
1959
|
-
seq: this.raws.length + 1,
|
|
1960
|
-
ts: this.now()
|
|
1961
|
-
});
|
|
1962
|
-
}
|
|
1963
|
-
recordProcessed(record) {
|
|
1964
|
-
this.processeds.push({
|
|
1965
|
-
...record,
|
|
1966
|
-
seq: this.processeds.length + 1,
|
|
1967
|
-
ts: this.now()
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
recordConnection(record) {
|
|
1971
|
-
this.connections.push({
|
|
1972
|
-
...record,
|
|
1973
|
-
seq: this.connections.length + 1,
|
|
1974
|
-
ts: this.now()
|
|
1975
|
-
});
|
|
1976
|
-
}
|
|
1977
|
-
queryRaw(query) {
|
|
1978
|
-
return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
|
|
1979
|
-
}
|
|
1980
|
-
queryProcessed(query) {
|
|
1981
|
-
return takeRecent(this.processeds.filter((event) => {
|
|
1982
|
-
if (!matches(event, query)) return false;
|
|
1983
|
-
if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
|
|
1984
|
-
return true;
|
|
1985
|
-
}), query.limit);
|
|
1986
|
-
}
|
|
1987
|
-
queryConnection(query) {
|
|
1988
|
-
return takeRecent(this.connections.filter((event) => {
|
|
1989
|
-
if (!matches(event, query)) return false;
|
|
1990
|
-
if (query.status !== void 0 && event.status !== query.status) return false;
|
|
1991
|
-
return true;
|
|
1992
|
-
}), query.limit);
|
|
1993
|
-
}
|
|
1994
|
-
clear() {
|
|
1995
|
-
this.raws.length = 0;
|
|
1996
|
-
this.processeds.length = 0;
|
|
1997
|
-
this.connections.length = 0;
|
|
1998
|
-
}
|
|
1999
|
-
close() {}
|
|
2000
|
-
};
|
|
2001
|
-
const matches = (event, query) => {
|
|
2002
|
-
if (query.type !== void 0 && event.type !== query.type) return false;
|
|
2003
|
-
if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
|
|
2004
|
-
if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
|
|
2005
|
-
return true;
|
|
2006
|
-
};
|
|
2007
|
-
const takeRecent = (events, limit) => {
|
|
2008
|
-
if (limit === void 0) return events;
|
|
2009
|
-
if (limit <= 0) return [];
|
|
2010
|
-
return events.slice(-limit);
|
|
2011
|
-
};
|
|
2012
|
-
//#endregion
|
|
2013
|
-
export { funnelTmpDir as C, publishResponseSchema as S, funnelEventSchema as _, connectorConnectionEventSchema as a, FunnelChannelPublisher as b, MemoryFunnelEventLog as c, DEFAULT_GATEWAY_TOKEN_PATH as d, FunnelGatewayToken as f, FunnelEventLog as g, SqliteFunnelEventLog as h, ConnectorDiagnosticLog as i, channelWsProtocols as l, FunnelListenerSupervisor as m, SqliteConnectorDiagnosticLog as n, connectorProcessedEventSchema as o, FunnelGatewayServer as p, CONNECTOR_CONNECTION_STATUSES as r, connectorRawEventSchema as s, MemoryConnectorDiagnosticLog as t, channelWsUrl as u, FunnelBroadcaster as v, publishRequestSchema as x, requireBearerToken as y };
|
|
2137
|
+
export { funnelTmpDir as C, connectorRawEventSchema as S, MemoryConnectorDiagnosticLog as _, DEFAULT_GATEWAY_TOKEN_PATH as a, connectorConnectionEventSchema as b, FunnelListenerRegistry as c, funnelEventSchema as d, FunnelBroadcaster as f, publishResponseSchema as g, publishRequestSchema as h, channelWsUrl as i, SqliteFunnelEventLog as l, FunnelChannelPublisher as m, MemoryFunnelEventLog as n, FunnelGatewayToken as o, requireBearerToken as p, channelWsProtocols as r, FunnelGatewayServer as s, SqliteConnectorDiagnosticLog as t, FunnelEventLog as u, CONNECTOR_CONNECTION_STATUSES as v, connectorProcessedEventSchema as x, ConnectorDiagnosticLog as y };
|