@interactive-inc/claude-funnel 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-BM0-f6KL.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 +6 -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
package/dist/index.js
CHANGED
|
@@ -1,209 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { r as FunnelDiscordAdapter, t as FunnelDiscordListener } from "./discord-listener-_jSE3HsQ.js";
|
|
2
|
+
import { t as FunnelConnectorListener } from "./connector-listener-DU54DN-f.js";
|
|
3
|
+
import { t as FunnelLogger } from "./logger-BP6SisKt.js";
|
|
4
|
+
import { n as NodeFunnelProcessRunner, r as FunnelProcessRunner, t as ghConnectorSchema } from "./gh-connector-schema-o3Q1-ojL.js";
|
|
5
|
+
import { n as FunnelGhAdapter, t as FunnelGhListener } from "./gh-listener-DH-fClQm.js";
|
|
6
|
+
import { n as ScheduleStateStore, t as FunnelScheduleListener } from "./schedule-listener-ePAjians.js";
|
|
7
|
+
import { t as FunnelFileSystem } from "./file-system-PWKKU7lA.js";
|
|
8
|
+
import { t as NodeFunnelFileSystem } from "./node-file-system-BcrmWN9I.js";
|
|
9
|
+
import { n as FunnelSlackEventProcessor, r as FunnelSlackAdapter, t as FunnelSlackListener } from "./slack-listener-ClQuHhEF.js";
|
|
10
|
+
import { n as FunnelIdGenerator, t as FunnelSettingsReader } from "./settings-reader-DPqrpV7s.js";
|
|
11
|
+
import { a as resolveFunnelDir, c as channelConfigSchema, d as settingsSchema, f as connectorConfigSchema, i as SETTINGS_PATH, l as channelDeliveryModeSchema, n as FUNNEL_DIR, o as resolveFunnelPort, p as NodeFunnelIdGenerator, r as FunnelSettingsStore, s as SETTINGS_VERSION, t as DEFAULT_GATEWAY_PORT, u as profileConfigSchema } from "./settings-store-D2XSXTyt.js";
|
|
12
|
+
import { t as discordConnectorSchema } from "./discord-connector-schema-CBDyGdOI.js";
|
|
13
|
+
import { n as scheduleConnectorSchema, r as scheduleEntrySchema, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-iCI61gzU.js";
|
|
14
|
+
import { t as slackConnectorSchema } from "./slack-connector-schema-BCNWluHM.js";
|
|
15
|
+
import { a as FileProcessGuard, i as FunnelMcp, o as FunnelClaude } from "./mcp-Dr-nIBwN.js";
|
|
16
|
+
import { a as FunnelLocalConfig, n as NodeFunnelTokenPrompter, r as FunnelLocalConfigSync, t as funnelJsonSchema } from "./local-config-json-schema-8IHjS4Q7.js";
|
|
17
|
+
import { t as FunnelProfiles } from "./profiles-wMRnjSid.js";
|
|
18
|
+
import { _ as FunnelBroadcaster, a as connectorConnectionEventSchema, b as publishResponseSchema, c as MemoryFunnelEventLog, d as FunnelGatewayServer, f as ConnectorDiagnosticSqlReader, g as funnelEventSchema, h as FunnelEventLog, i as ConnectorDiagnosticLog, l as DEFAULT_GATEWAY_TOKEN_PATH, m as SqliteFunnelEventLog, n as SqliteConnectorDiagnosticLog, o as connectorProcessedEventSchema, p as FunnelListenerSupervisor, r as CONNECTOR_CONNECTION_STATUSES, s as connectorRawEventSchema, t as MemoryConnectorDiagnosticLog, u as FunnelGatewayToken, v as FunnelChannelPublisher, x as funnelTmpDir, y as publishRequestSchema } from "./memory-connector-diagnostic-log-CrW1ltLM.js";
|
|
6
19
|
import { dirname, join, resolve } from "node:path";
|
|
7
20
|
import { hc } from "hono/client";
|
|
8
|
-
import { appendFileSync,
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
22
|
import { z } from "zod";
|
|
10
|
-
import { homedir, tmpdir } from "node:os";
|
|
11
|
-
import { stderr, stdin } from "node:process";
|
|
12
23
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { timingSafeEqual } from "node:crypto";
|
|
14
24
|
import { createFactory } from "hono/factory";
|
|
15
|
-
import { Database } from "bun:sqlite";
|
|
16
25
|
import { HTTPException } from "hono/http-exception";
|
|
17
26
|
import { zValidator } from "@hono/zod-validator";
|
|
18
|
-
//#region lib/engine/id/id-generator.ts
|
|
19
|
-
/**
|
|
20
|
-
* ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
|
|
21
|
-
* MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
|
|
22
|
-
*/
|
|
23
|
-
var FunnelIdGenerator = class {};
|
|
24
|
-
//#endregion
|
|
25
|
-
//#region lib/engine/id/node-id-generator.ts
|
|
26
|
-
var NodeFunnelIdGenerator = class extends FunnelIdGenerator {
|
|
27
|
-
generate() {
|
|
28
|
-
return crypto.randomUUID();
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
//#endregion
|
|
32
|
-
//#region lib/engine/settings/settings-reader.ts
|
|
33
|
-
var FunnelSettingsReader = class {};
|
|
34
|
-
//#endregion
|
|
35
|
-
//#region lib/connectors/connector-config-schema.ts
|
|
36
|
-
const connectorConfigSchema = z.discriminatedUnion("type", [
|
|
37
|
-
slackConnectorSchema,
|
|
38
|
-
ghConnectorSchema,
|
|
39
|
-
discordConnectorSchema,
|
|
40
|
-
scheduleConnectorSchema
|
|
41
|
-
]);
|
|
42
|
-
//#endregion
|
|
43
|
-
//#region lib/engine/settings/settings-schema.ts
|
|
44
|
-
/**
|
|
45
|
-
* Routing mode when multiple WS clients are subscribed to the same channel.
|
|
46
|
-
*
|
|
47
|
-
* - `fanout` (default): every connected client receives every event. Right when each
|
|
48
|
-
* subscriber has its own job (e.g., TUI mirrors, distinct Claude profiles each running
|
|
49
|
-
* their own pipeline against the same source).
|
|
50
|
-
* - `exclusive`: each event is delivered to exactly one connected client, picked
|
|
51
|
-
* round-robin per channel. Right when subscribers are interchangeable workers and you
|
|
52
|
-
* want each event handled once. Tap=all clients (TUI dashboard) always receive,
|
|
53
|
-
* regardless of mode, so they can passively observe.
|
|
54
|
-
*/
|
|
55
|
-
const channelDeliveryModeSchema = z.enum(["fanout", "exclusive"]);
|
|
56
|
-
const channelConfigSchema = z.object({
|
|
57
|
-
id: z.string(),
|
|
58
|
-
name: z.string(),
|
|
59
|
-
delivery: channelDeliveryModeSchema.default("fanout"),
|
|
60
|
-
connectors: z.array(connectorConfigSchema).default([])
|
|
61
|
-
});
|
|
62
|
-
const profileConfigSchema = z.object({
|
|
63
|
-
/** Stable identity (uuid). The primary key everything internal resolves to;
|
|
64
|
-
* survives renames. CLI surfaces still address profiles by `name`. */
|
|
65
|
-
id: z.string(),
|
|
66
|
-
/** Human-facing label used only at the CLI/TUI surface (`--profile <name>`).
|
|
67
|
-
* Renameable; never used as a storage key. */
|
|
68
|
-
name: z.string(),
|
|
69
|
-
path: z.string(),
|
|
70
|
-
channelId: z.string(),
|
|
71
|
-
/** Args prepended to the claude argv on every launch through this profile. */
|
|
72
|
-
options: z.array(z.string()).default([]),
|
|
73
|
-
/** Env vars layered under the launched claude process. process.env wins on collision. */
|
|
74
|
-
env: z.record(z.string(), z.string()).default({}),
|
|
75
|
-
/**
|
|
76
|
-
* When true (the default), funnel resumes this profile's previous claude
|
|
77
|
-
* session via `--session-id`/`--resume`. The id lives in `sessionId` below,
|
|
78
|
-
* scoped to this profile so an unrelated session in the same repo can't bleed
|
|
79
|
-
* in. Set to false for profiles that should always start fresh.
|
|
80
|
-
*/
|
|
81
|
-
resume: z.boolean().default(true),
|
|
82
|
-
/**
|
|
83
|
-
* Execution state, not config: the claude session id this profile last
|
|
84
|
-
* launched. Written by the launcher, read on the next resume. Absent until
|
|
85
|
-
* the first launch; kept inside the profile (rather than a separate file) so
|
|
86
|
-
* the session belongs to the profile by identity and the transport layer
|
|
87
|
-
* (channels) never has to know profiles exist.
|
|
88
|
-
*/
|
|
89
|
-
sessionId: z.string().optional()
|
|
90
|
-
});
|
|
91
|
-
const SETTINGS_VERSION = 1;
|
|
92
|
-
const settingsSchema = z.object({
|
|
93
|
-
/** Schema version. New files always write the current version; older files without one are read as v1. */
|
|
94
|
-
version: z.literal(1).default(1),
|
|
95
|
-
channels: z.array(channelConfigSchema).default([]),
|
|
96
|
-
profiles: z.array(profileConfigSchema).default([])
|
|
97
|
-
});
|
|
98
|
-
//#endregion
|
|
99
|
-
//#region lib/engine/settings/settings-store.ts
|
|
100
|
-
/**
|
|
101
|
-
* Resolves the funnel home dir. Defaults to `~/.funnel`, overridable via
|
|
102
|
-
* `FUNNEL_DIR` so a funnel.json-scoped launch can point everything (settings,
|
|
103
|
-
* gateway pid/token, claude pids) at a repo-local `<repo>/.funnel` and never
|
|
104
|
-
* touch the global home. Read at call time, not module load, so a daemon
|
|
105
|
-
* spawned with the env set resolves the override.
|
|
106
|
-
*/
|
|
107
|
-
function resolveFunnelDir() {
|
|
108
|
-
const override = process.env.FUNNEL_DIR;
|
|
109
|
-
if (override && override.length > 0) return override;
|
|
110
|
-
return join(homedir(), ".funnel");
|
|
111
|
-
}
|
|
112
|
-
const DEFAULT_GATEWAY_PORT = 9742;
|
|
113
|
-
/**
|
|
114
|
-
* Resolves the gateway port. Defaults to 9742 — the port a programmatically
|
|
115
|
-
* hosted gateway (`new Funnel().gatewayServer()`) uses. The `funnel` CLI entry
|
|
116
|
-
* sets `FUNNEL_PORT` to a distinct default so a CLI launch never collides with
|
|
117
|
-
* an embedding app's gateway on 9742. Read at call time so a daemon spawned
|
|
118
|
-
* with the env set resolves the override.
|
|
119
|
-
*/
|
|
120
|
-
function resolveFunnelPort() {
|
|
121
|
-
return Number(process.env.FUNNEL_PORT) || 9742;
|
|
122
|
-
}
|
|
123
|
-
const FUNNEL_DIR = join(homedir(), ".funnel");
|
|
124
|
-
const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
|
|
125
|
-
const defaultFs$6 = new NodeFunnelFileSystem();
|
|
126
|
-
const defaultIdGenerator$2 = new NodeFunnelIdGenerator();
|
|
127
|
-
var FunnelSettingsStore = class extends FunnelSettingsReader {
|
|
128
|
-
path;
|
|
129
|
-
fs;
|
|
130
|
-
idGenerator;
|
|
131
|
-
constructor(deps = {}) {
|
|
132
|
-
super();
|
|
133
|
-
this.path = deps.path ?? SETTINGS_PATH;
|
|
134
|
-
this.fs = deps.fs ?? defaultFs$6;
|
|
135
|
-
this.idGenerator = deps.idGenerator ?? defaultIdGenerator$2;
|
|
136
|
-
Object.freeze(this);
|
|
137
|
-
}
|
|
138
|
-
read() {
|
|
139
|
-
if (!this.fs.existsSync(this.path)) return {
|
|
140
|
-
version: 1,
|
|
141
|
-
channels: [],
|
|
142
|
-
profiles: []
|
|
143
|
-
};
|
|
144
|
-
const content = this.fs.readFileSync(this.path);
|
|
145
|
-
const parsed = JSON.parse(content);
|
|
146
|
-
if (this.looksLikeLegacy(parsed)) throw new Error(`legacy settings.json detected at ${this.path}. The schema changed (channel.connectors are now nested objects with ids; profile fields renamed). Migration is intentionally not provided. Back up and remove the old file:\n mv ${this.path} ${this.path}.bak`);
|
|
147
|
-
if (parsed && typeof parsed === "object" && "version" in parsed && parsed.version !== 1) throw new Error(`unsupported settings.json version (${this.path}): expected 1, got ${String(parsed.version)}`);
|
|
148
|
-
const minted = this.backfillProfileIds(parsed);
|
|
149
|
-
const result = settingsSchema.safeParse(parsed);
|
|
150
|
-
if (!result.success) throw new Error(`invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`);
|
|
151
|
-
if (minted) this.write(result.data);
|
|
152
|
-
return result.data;
|
|
153
|
-
}
|
|
154
|
-
looksLikeLegacy(parsed) {
|
|
155
|
-
if (!parsed || typeof parsed !== "object") return false;
|
|
156
|
-
const obj = parsed;
|
|
157
|
-
if (Array.isArray(obj.channels)) for (const channel of obj.channels) {
|
|
158
|
-
if (!channel || typeof channel !== "object") continue;
|
|
159
|
-
const ch = channel;
|
|
160
|
-
if (Array.isArray(ch.connectors) && ch.connectors.some((x) => typeof x === "string")) return true;
|
|
161
|
-
if (!("id" in ch) && "name" in ch) return true;
|
|
162
|
-
}
|
|
163
|
-
if (Array.isArray(obj.connectors)) return true;
|
|
164
|
-
if (Array.isArray(obj.repositories)) return true;
|
|
165
|
-
if (Array.isArray(obj.profiles)) for (const profile of obj.profiles) {
|
|
166
|
-
if (!profile || typeof profile !== "object") continue;
|
|
167
|
-
const p = profile;
|
|
168
|
-
if ("repository" in p || "envFiles" in p || "channel" in p && !("channelId" in p)) return true;
|
|
169
|
-
}
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Non-destructive migration for profiles written before `id` existed. Mints a
|
|
174
|
-
* uuid for each profile lacking one and returns whether anything was minted, so
|
|
175
|
-
* `read` can persist it immediately — a profile id must be STABLE across reads,
|
|
176
|
-
* otherwise `setSessionId` (a second read) sees a different id and can't match
|
|
177
|
-
* the one the launch used. Mutates `parsed` in place (freshly JSON-parsed).
|
|
178
|
-
*/
|
|
179
|
-
backfillProfileIds(parsed) {
|
|
180
|
-
if (!parsed || typeof parsed !== "object") return false;
|
|
181
|
-
const obj = parsed;
|
|
182
|
-
if (!Array.isArray(obj.profiles)) return false;
|
|
183
|
-
let minted = false;
|
|
184
|
-
for (const profile of obj.profiles) {
|
|
185
|
-
if (!profile || typeof profile !== "object") continue;
|
|
186
|
-
const p = profile;
|
|
187
|
-
if (typeof p.id !== "string") {
|
|
188
|
-
p.id = this.idGenerator.generate();
|
|
189
|
-
minted = true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return minted;
|
|
193
|
-
}
|
|
194
|
-
write(settings) {
|
|
195
|
-
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
196
|
-
const versioned = {
|
|
197
|
-
...settings,
|
|
198
|
-
version: 1
|
|
199
|
-
};
|
|
200
|
-
this.fs.writeSecretFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`);
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
//#endregion
|
|
204
27
|
//#region lib/connectors/connector-factory.ts
|
|
205
|
-
const defaultFs$
|
|
206
|
-
const defaultProcess$
|
|
28
|
+
const defaultFs$1 = new NodeFunnelFileSystem();
|
|
29
|
+
const defaultProcess$1 = new NodeFunnelProcessRunner();
|
|
207
30
|
/**
|
|
208
31
|
* Pure factory for per-type listeners and adapters. The factory has no CRUD
|
|
209
32
|
* responsibility — connector configs live inside settings.json under their
|
|
@@ -225,8 +48,8 @@ var FunnelConnectorFactory = class {
|
|
|
225
48
|
slackListenerOptions;
|
|
226
49
|
scheduleListenerOptions;
|
|
227
50
|
constructor(deps = {}) {
|
|
228
|
-
this.fs = deps.fs ?? defaultFs$
|
|
229
|
-
this.process = deps.process ?? defaultProcess$
|
|
51
|
+
this.fs = deps.fs ?? defaultFs$1;
|
|
52
|
+
this.process = deps.process ?? defaultProcess$1;
|
|
230
53
|
this.logger = deps.logger;
|
|
231
54
|
this.diagnosticLog = deps.diagnosticLog;
|
|
232
55
|
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
@@ -361,7 +184,7 @@ const slotFields = (literalKey, envKey, fields, current) => {
|
|
|
361
184
|
return result;
|
|
362
185
|
};
|
|
363
186
|
const defaultClock$1 = new NodeFunnelClock();
|
|
364
|
-
const defaultIdGenerator
|
|
187
|
+
const defaultIdGenerator = new NodeFunnelIdGenerator();
|
|
365
188
|
/**
|
|
366
189
|
* Channels own their connectors. Each channel has a stable id (UUID); the
|
|
367
190
|
* `name` is the human-facing label used by the CLI. Connectors live nested
|
|
@@ -381,7 +204,7 @@ var FunnelChannels = class {
|
|
|
381
204
|
this.factory = deps.factory;
|
|
382
205
|
this.profileChecker = deps.profileChecker ?? null;
|
|
383
206
|
this.clock = deps.clock ?? defaultClock$1;
|
|
384
|
-
this.idGenerator = deps.idGenerator ?? defaultIdGenerator
|
|
207
|
+
this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
|
|
385
208
|
Object.freeze(this);
|
|
386
209
|
}
|
|
387
210
|
list() {
|
|
@@ -635,898 +458,110 @@ var FunnelChannels = class {
|
|
|
635
458
|
}
|
|
636
459
|
};
|
|
637
460
|
//#endregion
|
|
638
|
-
//#region lib/engine/
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
process;
|
|
654
|
-
idGenerator;
|
|
655
|
-
logger;
|
|
656
|
-
constructor(deps) {
|
|
657
|
-
this.channels = deps.channels;
|
|
658
|
-
this.mcp = deps.mcp;
|
|
659
|
-
this.gateway = deps.gateway;
|
|
660
|
-
this.sessions = deps.sessions;
|
|
661
|
-
this.guard = deps.guard;
|
|
662
|
-
this.process = deps.process ?? defaultProcess$3;
|
|
663
|
-
this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
|
|
664
|
-
this.logger = deps.logger;
|
|
665
|
-
Object.freeze(this);
|
|
461
|
+
//#region lib/engine/fs/memory-file-system.ts
|
|
462
|
+
const SECRET_MODE = 384;
|
|
463
|
+
var MemoryFunnelFileSystem = class extends FunnelFileSystem {
|
|
464
|
+
dirs;
|
|
465
|
+
files;
|
|
466
|
+
mtimes;
|
|
467
|
+
modes;
|
|
468
|
+
now;
|
|
469
|
+
constructor(props = {}) {
|
|
470
|
+
super();
|
|
471
|
+
this.dirs = new Set(props.dirs ?? []);
|
|
472
|
+
this.files = new Map(Object.entries(props.files ?? {}));
|
|
473
|
+
this.mtimes = new Map(Object.entries(props.mtimes ?? {}));
|
|
474
|
+
this.modes = new Map(Object.entries(props.modes ?? {}));
|
|
475
|
+
this.now = props.now ?? (() => Date.now());
|
|
666
476
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if ((options.installMcp ?? true) && !this.mcp.findInstalledName(cwd)) {
|
|
673
|
-
this.mcp.install(cwd);
|
|
674
|
-
this.logger?.info(`added funnel MCP to .mcp.json`, { cwd });
|
|
675
|
-
}
|
|
676
|
-
if (!this.gateway.isRunning()) {
|
|
677
|
-
this.logger?.info(`starting gateway automatically`);
|
|
678
|
-
await this.gateway.start();
|
|
679
|
-
}
|
|
680
|
-
if (options.profileId) this.guard.acquire(options.profileId);
|
|
681
|
-
const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
|
|
682
|
-
const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
|
|
683
|
-
const env = this.buildEnv(channel.id, options.env ?? {});
|
|
684
|
-
this.logger?.info(`claude launch`, {
|
|
685
|
-
channel: options.channel,
|
|
686
|
-
channelId: channel.id,
|
|
687
|
-
cwd
|
|
688
|
-
});
|
|
689
|
-
try {
|
|
690
|
-
return await this.process.attach(["claude", ...claudeArgs], {
|
|
691
|
-
cwd,
|
|
692
|
-
env,
|
|
693
|
-
onSpawned: options.onSpawned
|
|
694
|
-
});
|
|
695
|
-
} finally {
|
|
696
|
-
if (options.profileId) this.guard.release(options.profileId);
|
|
697
|
-
}
|
|
477
|
+
existsSync(path) {
|
|
478
|
+
return this.dirs.has(path) || this.files.has(path);
|
|
479
|
+
}
|
|
480
|
+
readFileSync(path) {
|
|
481
|
+
return this.files.get(path) ?? "";
|
|
698
482
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
else result.push("--session-id", session.id);
|
|
703
|
-
const mcpName = this.mcp.findInstalledName(cwd);
|
|
704
|
-
if (mcpName && !result.includes("--dangerously-load-development-channels") && !result.includes("--channels")) result.push("--dangerously-load-development-channels", `server:${mcpName}`);
|
|
705
|
-
return result;
|
|
483
|
+
writeFileSync(path, data) {
|
|
484
|
+
this.files.set(path, data);
|
|
485
|
+
this.touch(path);
|
|
706
486
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
* or override the explicit user intent.
|
|
712
|
-
*
|
|
713
|
-
* The session is owned by the profile (by id), not by cwd: two profiles
|
|
714
|
-
* pointing at the same repo each keep their own conversation, and a launch
|
|
715
|
-
* with no profile never resumes — so an unrelated session in the same repo
|
|
716
|
-
* can't bleed in. The channel never enters into it; sessions belong to the
|
|
717
|
-
* launch layer (profiles), keeping the transport layer ignorant of them.
|
|
718
|
-
*
|
|
719
|
-
* A persisted id is only resumed when its session jsonl still exists on
|
|
720
|
-
* disk. claude errors out on `--resume <id>` for a missing conversation, and
|
|
721
|
-
* a persisted id can outlive its jsonl (claude pruned it, or the very first
|
|
722
|
-
* launch was aborted after the id was written but before the jsonl
|
|
723
|
-
* appeared). When the file is gone we mint a fresh session instead, which
|
|
724
|
-
* overwrites the dangling entry — so the store self-heals.
|
|
725
|
-
*/
|
|
726
|
-
resolveSession(profileId, cwd, userArgs, recipeEnv) {
|
|
727
|
-
for (const arg of userArgs) {
|
|
728
|
-
if (arg === "-c" || arg === "--continue") return null;
|
|
729
|
-
if (arg === "--resume" || arg.startsWith("--resume=")) return null;
|
|
730
|
-
if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
|
|
731
|
-
}
|
|
732
|
-
const existing = this.sessions.getSessionId(profileId);
|
|
733
|
-
if (existing !== null && this.sessions.sessionFileExists(cwd, existing, recipeEnv)) return {
|
|
734
|
-
id: existing,
|
|
735
|
-
mode: "resume"
|
|
736
|
-
};
|
|
737
|
-
const fresh = this.idGenerator.generate();
|
|
738
|
-
this.sessions.setSessionId(profileId, fresh);
|
|
739
|
-
return {
|
|
740
|
-
id: fresh,
|
|
741
|
-
mode: "new"
|
|
742
|
-
};
|
|
487
|
+
writeSecretFileSync(path, data) {
|
|
488
|
+
this.files.set(path, data);
|
|
489
|
+
this.modes.set(path, SECRET_MODE);
|
|
490
|
+
this.touch(path);
|
|
743
491
|
}
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
env.FUNNEL_CHANNEL_ID = channelId;
|
|
749
|
-
env.FUNNEL_PORT = String(resolveFunnelPort());
|
|
750
|
-
return env;
|
|
492
|
+
appendFileSync(path, data) {
|
|
493
|
+
const prev = this.files.get(path) ?? "";
|
|
494
|
+
this.files.set(path, prev + data);
|
|
495
|
+
this.touch(path);
|
|
751
496
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
const defaultProcess$2 = new NodeFunnelProcessRunner();
|
|
757
|
-
var FileProcessGuard = class {
|
|
758
|
-
fs;
|
|
759
|
-
process;
|
|
760
|
-
pidDir;
|
|
761
|
-
constructor(deps = {}) {
|
|
762
|
-
this.fs = deps.fs ?? defaultFs$4;
|
|
763
|
-
this.process = deps.process ?? defaultProcess$2;
|
|
764
|
-
this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude");
|
|
765
|
-
Object.freeze(this);
|
|
497
|
+
unlink(path) {
|
|
498
|
+
this.files.delete(path);
|
|
499
|
+
this.mtimes.delete(path);
|
|
500
|
+
this.modes.delete(path);
|
|
766
501
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
502
|
+
mkdirSync(path, options) {
|
|
503
|
+
this.dirs.add(path);
|
|
504
|
+
}
|
|
505
|
+
readdirSync(path) {
|
|
506
|
+
const prefix = path.endsWith("/") ? path : `${path}/`;
|
|
507
|
+
const names = [];
|
|
508
|
+
for (const file of this.files.keys()) {
|
|
509
|
+
if (!file.startsWith(prefix)) continue;
|
|
510
|
+
const rest = file.slice(prefix.length);
|
|
511
|
+
if (!rest.includes("/")) names.push(rest);
|
|
512
|
+
}
|
|
513
|
+
return names;
|
|
771
514
|
}
|
|
772
|
-
|
|
773
|
-
this.
|
|
774
|
-
|
|
775
|
-
|
|
515
|
+
statSync(path) {
|
|
516
|
+
const mtimeMs = this.mtimes.get(path);
|
|
517
|
+
if (mtimeMs === void 0) throw new Error(`not found: ${path}`);
|
|
518
|
+
return {
|
|
519
|
+
mtimeMs,
|
|
520
|
+
mode: this.modes.get(path) ?? null
|
|
521
|
+
};
|
|
776
522
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
if (this.fs.existsSync(path)) this.fs.unlink(path);
|
|
523
|
+
setMtime(path, mtimeMs) {
|
|
524
|
+
this.mtimes.set(path, mtimeMs);
|
|
780
525
|
}
|
|
781
|
-
|
|
782
|
-
|
|
526
|
+
setMode(path, mode) {
|
|
527
|
+
this.modes.set(path, mode);
|
|
783
528
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
try {
|
|
788
|
-
const content = this.fs.readFileSync(path).trim();
|
|
789
|
-
const pid = Number(content);
|
|
790
|
-
if (!pid || pid <= 0) return null;
|
|
791
|
-
return pid;
|
|
792
|
-
} catch {
|
|
793
|
-
return null;
|
|
794
|
-
}
|
|
529
|
+
touch(path) {
|
|
530
|
+
if (!this.mtimes.has(path)) this.mtimes.set(path, this.now());
|
|
531
|
+
else this.mtimes.set(path, this.now());
|
|
795
532
|
}
|
|
796
533
|
};
|
|
797
534
|
//#endregion
|
|
798
|
-
//#region lib/engine/
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
* the repo's scoped settings (`~/.funnel/projects/<id>/settings.json`) on launch.
|
|
806
|
-
* Connectors carry no tokens here — a token absent from settings is prompted for
|
|
807
|
-
* at launch (TTY) and saved there, never in the repo.
|
|
808
|
-
*
|
|
809
|
-
* The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
|
|
810
|
-
* the channel: a channel only describes where events come from. `fnl claude`
|
|
811
|
-
* applies the first profile bound to the chosen channel; the recipe is passed
|
|
812
|
-
* straight to the launcher and is not persisted into the global profile list.
|
|
813
|
-
* These profiles are selected by their `channel` binding, not by name.
|
|
814
|
-
*/
|
|
815
|
-
const slackConnectorSpecSchema = z.object({
|
|
816
|
-
type: z.literal("slack"),
|
|
817
|
-
name: z.string(),
|
|
818
|
-
/** Shrink raw Slack events before fanout. Defaults to true. */
|
|
819
|
-
minify: z.boolean().optional()
|
|
820
|
-
});
|
|
821
|
-
const discordConnectorSpecSchema = z.object({
|
|
822
|
-
type: z.literal("discord"),
|
|
823
|
-
name: z.string()
|
|
824
|
-
});
|
|
825
|
-
const ghConnectorSpecSchema = z.object({
|
|
826
|
-
type: z.literal("gh"),
|
|
827
|
-
name: z.string(),
|
|
828
|
-
pollInterval: z.number().int().positive().optional()
|
|
829
|
-
});
|
|
830
|
-
const scheduleConnectorSpecSchema = z.object({
|
|
831
|
-
type: z.literal("schedule"),
|
|
832
|
-
name: z.string()
|
|
833
|
-
});
|
|
834
|
-
const connectorSpecSchema = z.discriminatedUnion("type", [
|
|
835
|
-
slackConnectorSpecSchema,
|
|
836
|
-
discordConnectorSpecSchema,
|
|
837
|
-
ghConnectorSpecSchema,
|
|
838
|
-
scheduleConnectorSpecSchema
|
|
839
|
-
]);
|
|
840
|
-
const channelSpecSchema = z.object({
|
|
841
|
-
name: z.string(),
|
|
842
|
-
connectors: z.array(connectorSpecSchema).optional()
|
|
843
|
-
});
|
|
844
|
-
const profileSpecSchema = z.object({
|
|
845
|
-
/** Handle for `fnl claude --profile <name>`. A profile is only launchable by this name. */
|
|
846
|
-
name: z.string(),
|
|
847
|
-
/** Name of the channel (declared in `channels[]`) this profile binds. The profile depends on the channel, never the reverse. */
|
|
848
|
-
channel: z.string(),
|
|
849
|
-
/** Args prepended to the claude argv on every launch through this profile. */
|
|
850
|
-
options: z.array(z.string()).optional(),
|
|
851
|
-
/** Env vars layered under the launched claude process. process.env wins on collision. */
|
|
852
|
-
env: z.record(z.string(), z.string()).optional(),
|
|
853
|
-
/**
|
|
854
|
-
* When true (the default), funnel injects `--session-id <uuid>` so that
|
|
855
|
-
* relaunching from the same cwd resumes the previous claude session
|
|
856
|
-
* without bleeding into other channels or workspaces. Set to false for
|
|
857
|
-
* profiles that should always start a fresh session.
|
|
858
|
-
*/
|
|
859
|
-
resume: z.boolean().optional()
|
|
860
|
-
});
|
|
861
|
-
const localConfigSchema = z.object({
|
|
862
|
-
$schema: z.string().optional(),
|
|
863
|
-
/**
|
|
864
|
-
* Stable per-repo identifier. funnel writes this on first launch when absent;
|
|
865
|
-
* all funnel state for this repo lives under `~/.funnel/projects/<id>/`, so the
|
|
866
|
-
* repo itself never holds settings or tokens. Committed alongside funnel.json.
|
|
867
|
-
*/
|
|
868
|
-
id: z.string().optional(),
|
|
869
|
-
/** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
|
|
870
|
-
channels: z.array(channelSpecSchema).min(1),
|
|
871
|
-
/** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
|
|
872
|
-
profiles: z.array(profileSpecSchema).optional()
|
|
873
|
-
});
|
|
874
|
-
const LOCAL_CONFIG_FILENAME = "funnel.json";
|
|
875
|
-
//#endregion
|
|
876
|
-
//#region lib/engine/local-config/local-config.ts
|
|
877
|
-
/**
|
|
878
|
-
* Reads `funnel.json` from a directory. Returns `null` when the file is
|
|
879
|
-
* absent so callers can fall through to other resolution paths (default
|
|
880
|
-
* profile, help). Throws on present-but-invalid files so misconfiguration
|
|
881
|
-
* surfaces loudly instead of silently launching the wrong channel.
|
|
882
|
-
*/
|
|
883
|
-
var FunnelLocalConfig = class {
|
|
884
|
-
fs;
|
|
885
|
-
constructor(deps) {
|
|
886
|
-
this.fs = deps.fs;
|
|
887
|
-
Object.freeze(this);
|
|
535
|
+
//#region lib/engine/id/memory-id-generator.ts
|
|
536
|
+
var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
|
|
537
|
+
counter = 0;
|
|
538
|
+
prefix;
|
|
539
|
+
constructor(props = {}) {
|
|
540
|
+
super();
|
|
541
|
+
this.prefix = props.prefix ?? "id";
|
|
888
542
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
const raw = this.fs.readFileSync(path);
|
|
893
|
-
const parsed = (() => {
|
|
894
|
-
try {
|
|
895
|
-
return JSON.parse(raw);
|
|
896
|
-
} catch (error) {
|
|
897
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
898
|
-
throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
|
|
899
|
-
}
|
|
900
|
-
})();
|
|
901
|
-
const result = localConfigSchema.safeParse(parsed);
|
|
902
|
-
if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
|
|
903
|
-
this.assertProfilesValid(result.data);
|
|
904
|
-
return result.data;
|
|
905
|
-
}
|
|
906
|
-
assertProfilesValid(config) {
|
|
907
|
-
const profiles = config.profiles ?? [];
|
|
908
|
-
if (profiles.length === 0) return;
|
|
909
|
-
const channelNames = new Set(config.channels.map((channel) => channel.name));
|
|
910
|
-
const seenNames = /* @__PURE__ */ new Set();
|
|
911
|
-
for (const profile of profiles) {
|
|
912
|
-
if (!channelNames.has(profile.channel)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: profile "${profile.name}" binds channel "${profile.channel}", which is not declared in channels[]`);
|
|
913
|
-
if (seenNames.has(profile.name)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: more than one profile is named "${profile.name}" — names must be unique`);
|
|
914
|
-
seenNames.add(profile.name);
|
|
915
|
-
}
|
|
543
|
+
generate() {
|
|
544
|
+
this.counter++;
|
|
545
|
+
return `${this.prefix}-${this.counter}`;
|
|
916
546
|
}
|
|
917
547
|
};
|
|
918
548
|
//#endregion
|
|
919
|
-
//#region lib/engine/
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
//#region lib/engine/local-config/local-config-sync.ts
|
|
929
|
-
/**
|
|
930
|
-
* Reconciles a single funnel.json channel spec with `~/.funnel/settings.json`.
|
|
931
|
-
* The spec is the source of truth for the channel it declares:
|
|
932
|
-
*
|
|
933
|
-
* - missing channel → created
|
|
934
|
-
* - declared connector matched by name → tokens reconciled
|
|
935
|
-
* - declared connector matched by token in the same channel under a
|
|
936
|
-
* different name → renamed in place (then tokens reconciled)
|
|
937
|
-
* - declared connector with no match → added
|
|
938
|
-
* - any connector left in the channel that the spec did not touch → removed
|
|
939
|
-
*
|
|
940
|
-
* Removal only fires when the channel spec has a `connectors` field. An
|
|
941
|
-
* absent field means "do not manage connectors from here" and leaves
|
|
942
|
-
* everything in `~/.funnel` alone. Other channels in funnel.json (not
|
|
943
|
-
* passed to this call) are untouched.
|
|
944
|
-
*
|
|
945
|
-
* Returns the per-connector change set so callers (e.g. the claude launcher)
|
|
946
|
-
* can drive listener hot-reload on the gateway after settings are written.
|
|
947
|
-
*/
|
|
948
|
-
var FunnelLocalConfigSync = class {
|
|
949
|
-
channels;
|
|
950
|
-
prompter;
|
|
951
|
-
constructor(deps) {
|
|
952
|
-
this.channels = deps.channels;
|
|
953
|
-
this.prompter = deps.prompter;
|
|
954
|
-
Object.freeze(this);
|
|
955
|
-
}
|
|
956
|
-
async ensure(channel) {
|
|
957
|
-
if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
|
|
958
|
-
if (channel.connectors === void 0) return {
|
|
959
|
-
touched: [],
|
|
960
|
-
removed: []
|
|
961
|
-
};
|
|
962
|
-
const touched = [];
|
|
963
|
-
const touchedIds = /* @__PURE__ */ new Set();
|
|
964
|
-
for (const spec of channel.connectors) {
|
|
965
|
-
const outcome = await this.ensureConnector(channel.name, spec);
|
|
966
|
-
touched.push({
|
|
967
|
-
name: outcome.name,
|
|
968
|
-
changed: outcome.changed
|
|
969
|
-
});
|
|
970
|
-
touchedIds.add(outcome.id);
|
|
971
|
-
}
|
|
972
|
-
return {
|
|
973
|
-
touched,
|
|
974
|
-
removed: this.removeExtras(channel.name, touchedIds)
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
async ensureConnector(channelName, spec) {
|
|
978
|
-
if (spec.type === "slack") return await this.ensureSlack(channelName, spec);
|
|
979
|
-
if (spec.type === "discord") return await this.ensureDiscord(channelName, spec);
|
|
980
|
-
if (spec.type === "gh") return this.ensureGh(channelName, spec);
|
|
981
|
-
return this.ensureSchedule(channelName, spec);
|
|
982
|
-
}
|
|
983
|
-
async ensureSlack(channelName, spec) {
|
|
984
|
-
const byName = this.findExistingSlack(channelName, spec.name);
|
|
985
|
-
const bot = await this.resolveSlot({
|
|
986
|
-
label: `${spec.name}.botToken`,
|
|
987
|
-
existingLiteral: byName?.botToken,
|
|
988
|
-
existingEnv: byName?.botTokenEnv
|
|
989
|
-
});
|
|
990
|
-
const app = await this.resolveSlot({
|
|
991
|
-
label: `${spec.name}.appToken`,
|
|
992
|
-
existingLiteral: byName?.appToken,
|
|
993
|
-
existingEnv: byName?.appTokenEnv
|
|
549
|
+
//#region lib/engine/logger/memory-logger.ts
|
|
550
|
+
var MemoryFunnelLogger = class extends FunnelLogger {
|
|
551
|
+
file = null;
|
|
552
|
+
entries = [];
|
|
553
|
+
info(message, meta) {
|
|
554
|
+
this.entries.push({
|
|
555
|
+
level: "info",
|
|
556
|
+
message,
|
|
557
|
+
meta
|
|
994
558
|
});
|
|
995
|
-
const update = {
|
|
996
|
-
botToken: bot.token,
|
|
997
|
-
botTokenEnv: bot.tokenEnv,
|
|
998
|
-
appToken: app.token,
|
|
999
|
-
appTokenEnv: app.tokenEnv
|
|
1000
|
-
};
|
|
1001
|
-
if (byName) {
|
|
1002
|
-
if (!(byName.botToken === bot.token && byName.botTokenEnv === bot.tokenEnv && byName.appToken === app.token && byName.appTokenEnv === app.tokenEnv)) {
|
|
1003
|
-
this.channels.updateSlackConnector(channelName, spec.name, update);
|
|
1004
|
-
return {
|
|
1005
|
-
id: byName.id,
|
|
1006
|
-
name: spec.name,
|
|
1007
|
-
changed: true
|
|
1008
|
-
};
|
|
1009
|
-
}
|
|
1010
|
-
return {
|
|
1011
|
-
id: byName.id,
|
|
1012
|
-
name: spec.name,
|
|
1013
|
-
changed: false
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
return {
|
|
1017
|
-
id: this.channels.addConnector(channelName, {
|
|
1018
|
-
type: "slack",
|
|
1019
|
-
name: spec.name,
|
|
1020
|
-
...update,
|
|
1021
|
-
...spec.minify !== void 0 ? { minify: spec.minify } : {}
|
|
1022
|
-
}).id,
|
|
1023
|
-
name: spec.name,
|
|
1024
|
-
changed: true
|
|
1025
|
-
};
|
|
1026
559
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
existingEnv: byName?.botTokenEnv
|
|
1033
|
-
});
|
|
1034
|
-
const update = {
|
|
1035
|
-
botToken: bot.token,
|
|
1036
|
-
botTokenEnv: bot.tokenEnv
|
|
1037
|
-
};
|
|
1038
|
-
if (byName) {
|
|
1039
|
-
if (byName.botToken !== bot.token || byName.botTokenEnv !== bot.tokenEnv) {
|
|
1040
|
-
this.channels.updateDiscordConnector(channelName, spec.name, update);
|
|
1041
|
-
return {
|
|
1042
|
-
id: byName.id,
|
|
1043
|
-
name: spec.name,
|
|
1044
|
-
changed: true
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
return {
|
|
1048
|
-
id: byName.id,
|
|
1049
|
-
name: spec.name,
|
|
1050
|
-
changed: false
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
return {
|
|
1054
|
-
id: this.channels.addConnector(channelName, {
|
|
1055
|
-
type: "discord",
|
|
1056
|
-
name: spec.name,
|
|
1057
|
-
...update
|
|
1058
|
-
}).id,
|
|
1059
|
-
name: spec.name,
|
|
1060
|
-
changed: true
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
ensureGh(channelName, spec) {
|
|
1064
|
-
const existing = this.channels.getConnector(channelName, spec.name);
|
|
1065
|
-
if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
|
|
1066
|
-
if (existing && existing.type === "gh") {
|
|
1067
|
-
if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) {
|
|
1068
|
-
this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
|
|
1069
|
-
return {
|
|
1070
|
-
id: existing.id,
|
|
1071
|
-
name: spec.name,
|
|
1072
|
-
changed: true
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
return {
|
|
1076
|
-
id: existing.id,
|
|
1077
|
-
name: spec.name,
|
|
1078
|
-
changed: false
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
return {
|
|
1082
|
-
id: this.channels.addConnector(channelName, {
|
|
1083
|
-
type: "gh",
|
|
1084
|
-
name: spec.name,
|
|
1085
|
-
...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
|
|
1086
|
-
}).id,
|
|
1087
|
-
name: spec.name,
|
|
1088
|
-
changed: true
|
|
1089
|
-
};
|
|
1090
|
-
}
|
|
1091
|
-
ensureSchedule(channelName, spec) {
|
|
1092
|
-
const existing = this.channels.getConnector(channelName, spec.name);
|
|
1093
|
-
if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
|
|
1094
|
-
if (existing && existing.type === "schedule") return {
|
|
1095
|
-
id: existing.id,
|
|
1096
|
-
name: spec.name,
|
|
1097
|
-
changed: false
|
|
1098
|
-
};
|
|
1099
|
-
return {
|
|
1100
|
-
id: this.channels.addConnector(channelName, {
|
|
1101
|
-
type: "schedule",
|
|
1102
|
-
name: spec.name
|
|
1103
|
-
}).id,
|
|
1104
|
-
name: spec.name,
|
|
1105
|
-
changed: true
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
findExistingSlack(channelName, connectorName) {
|
|
1109
|
-
const existing = this.channels.getConnector(channelName, connectorName);
|
|
1110
|
-
if (!existing) return null;
|
|
1111
|
-
if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
|
|
1112
|
-
return existing;
|
|
1113
|
-
}
|
|
1114
|
-
findExistingDiscord(channelName, connectorName) {
|
|
1115
|
-
const existing = this.channels.getConnector(channelName, connectorName);
|
|
1116
|
-
if (!existing) return null;
|
|
1117
|
-
if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
|
|
1118
|
-
return existing;
|
|
1119
|
-
}
|
|
1120
|
-
removeExtras(channelName, touched) {
|
|
1121
|
-
const channel = this.channels.get(channelName);
|
|
1122
|
-
if (!channel) return [];
|
|
1123
|
-
const stale = channel.connectors.filter((c) => !touched.has(c.id));
|
|
1124
|
-
for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
|
|
1125
|
-
return stale.map((c) => c.name);
|
|
1126
|
-
}
|
|
1127
|
-
/**
|
|
1128
|
-
* Decides how a single token slot is stored in settings.json. funnel.json
|
|
1129
|
-
* never carries tokens, so the only sources are a value already in
|
|
1130
|
-
* settings.json (carried over verbatim, whichever form it was — literal or an
|
|
1131
|
-
* `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
|
|
1132
|
-
* literal (throws when stdin is not a TTY). Either way the secret lands in the
|
|
1133
|
-
* repo-scoped settings, never in the repo itself.
|
|
1134
|
-
*/
|
|
1135
|
-
async resolveSlot(input) {
|
|
1136
|
-
if (input.existingEnv !== void 0) return {
|
|
1137
|
-
token: void 0,
|
|
1138
|
-
tokenEnv: input.existingEnv
|
|
1139
|
-
};
|
|
1140
|
-
if (input.existingLiteral !== void 0) return {
|
|
1141
|
-
token: input.existingLiteral,
|
|
1142
|
-
tokenEnv: void 0
|
|
1143
|
-
};
|
|
1144
|
-
return {
|
|
1145
|
-
token: await this.prompter.promptSecret(input.label),
|
|
1146
|
-
tokenEnv: void 0
|
|
1147
|
-
};
|
|
1148
|
-
}
|
|
1149
|
-
};
|
|
1150
|
-
const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
|
|
1151
|
-
const mcpEntrySchema = z.object({
|
|
1152
|
-
command: z.string().optional(),
|
|
1153
|
-
args: z.array(z.string()).optional()
|
|
1154
|
-
});
|
|
1155
|
-
const mcpConfigSchema = z.object({ mcpServers: z.record(z.string(), mcpEntrySchema).optional() });
|
|
1156
|
-
const defaultFs$3 = new NodeFunnelFileSystem();
|
|
1157
|
-
/**
|
|
1158
|
-
* Installs/uninstalls the funnel MCP entry into a target repository's
|
|
1159
|
-
* `.mcp.json`. Detects an existing entry by command match so renaming is
|
|
1160
|
-
* preserved across re-installs.
|
|
1161
|
-
*/
|
|
1162
|
-
var FunnelMcp = class {
|
|
1163
|
-
fs;
|
|
1164
|
-
constructor(deps = {}) {
|
|
1165
|
-
this.fs = deps.fs ?? defaultFs$3;
|
|
1166
|
-
Object.freeze(this);
|
|
1167
|
-
}
|
|
1168
|
-
install(repoPath) {
|
|
1169
|
-
if (!this.fs.existsSync(repoPath)) throw new Error(`repository does not exist: ${repoPath}`);
|
|
1170
|
-
const config = this.readConfig(repoPath);
|
|
1171
|
-
const servers = config.mcpServers ?? {};
|
|
1172
|
-
const targetName = this.findServerName(servers) ?? "funnel";
|
|
1173
|
-
servers[targetName] = {
|
|
1174
|
-
command: "bun",
|
|
1175
|
-
args: FUNNEL_MCP_ARGS
|
|
1176
|
-
};
|
|
1177
|
-
this.writeConfig(repoPath, {
|
|
1178
|
-
...config,
|
|
1179
|
-
mcpServers: servers
|
|
1180
|
-
});
|
|
1181
|
-
}
|
|
1182
|
-
uninstall(repoPath) {
|
|
1183
|
-
if (!this.fs.existsSync(repoPath)) return;
|
|
1184
|
-
const config = this.readConfig(repoPath);
|
|
1185
|
-
const servers = config.mcpServers ?? {};
|
|
1186
|
-
const name = this.findServerName(servers);
|
|
1187
|
-
if (!name) return;
|
|
1188
|
-
const next = { ...servers };
|
|
1189
|
-
delete next[name];
|
|
1190
|
-
this.writeConfig(repoPath, {
|
|
1191
|
-
...config,
|
|
1192
|
-
mcpServers: next
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
findInstalledName(cwd) {
|
|
1196
|
-
const config = this.readConfig(cwd);
|
|
1197
|
-
return this.findServerName(config.mcpServers ?? {});
|
|
1198
|
-
}
|
|
1199
|
-
findServerName(servers) {
|
|
1200
|
-
for (const entry of Object.entries(servers)) {
|
|
1201
|
-
const name = entry[0];
|
|
1202
|
-
const value = entry[1];
|
|
1203
|
-
if (this.isFunnelEntry(value)) return name;
|
|
1204
|
-
}
|
|
1205
|
-
return null;
|
|
1206
|
-
}
|
|
1207
|
-
isFunnelEntry(value) {
|
|
1208
|
-
if (!value) return false;
|
|
1209
|
-
if (value.command === "bun" && value.args?.[0] === "funnel") return true;
|
|
1210
|
-
if (value.command === "funnel") return true;
|
|
1211
|
-
return false;
|
|
1212
|
-
}
|
|
1213
|
-
readConfig(repoPath) {
|
|
1214
|
-
const mcpPath = join(repoPath, ".mcp.json");
|
|
1215
|
-
if (!this.fs.existsSync(mcpPath)) return {};
|
|
1216
|
-
const content = this.fs.readFileSync(mcpPath).trim();
|
|
1217
|
-
if (!content) return {};
|
|
1218
|
-
let parsed;
|
|
1219
|
-
try {
|
|
1220
|
-
parsed = JSON.parse(content);
|
|
1221
|
-
} catch (error) {
|
|
1222
|
-
throw new Error(`invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1223
|
-
}
|
|
1224
|
-
const result = mcpConfigSchema.safeParse(parsed);
|
|
1225
|
-
if (!result.success) throw new Error(`invalid .mcp.json (${mcpPath}): ${result.error.message}`);
|
|
1226
|
-
return result.data;
|
|
1227
|
-
}
|
|
1228
|
-
writeConfig(repoPath, config) {
|
|
1229
|
-
const mcpPath = join(repoPath, ".mcp.json");
|
|
1230
|
-
this.fs.writeFileSync(mcpPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1231
|
-
}
|
|
1232
|
-
};
|
|
1233
|
-
//#endregion
|
|
1234
|
-
//#region lib/engine/profiles/profiles.ts
|
|
1235
|
-
const defaultFs$2 = new NodeFunnelFileSystem();
|
|
1236
|
-
/**
|
|
1237
|
-
* Named launch presets for `fnl claude`. Each profile bundles a working
|
|
1238
|
-
* directory, the channel id its Claude instance subscribes to, and the launch
|
|
1239
|
-
* recipe (`options` prepended to the claude argv, `env` layered under the
|
|
1240
|
-
* process, `resume` toggling session reuse). Implements ProfileChannelChecker
|
|
1241
|
-
* so FunnelChannels can refuse to remove a channel that is still referenced.
|
|
1242
|
-
*
|
|
1243
|
-
* Each profile has a stable `id` (uuid) minted at `add`. That id is the unit
|
|
1244
|
-
* everything internal keys on — the PID file, the resumable session id — so a
|
|
1245
|
-
* rename never strands either. `name` is purely the CLI/TUI handle; the CRUD
|
|
1246
|
-
* methods here take it because that is what the user types, but resolve to the
|
|
1247
|
-
* id before touching id-keyed state. The first array entry is the default
|
|
1248
|
-
* profile; `asDefault` reorders to put one first.
|
|
1249
|
-
*
|
|
1250
|
-
* `channelId` always stores the channel's stable id (uuid). CLI surfaces
|
|
1251
|
-
* resolve channel name → id before calling `add`/`update` here.
|
|
1252
|
-
*/
|
|
1253
|
-
var FunnelProfiles = class {
|
|
1254
|
-
store;
|
|
1255
|
-
idGenerator;
|
|
1256
|
-
fs;
|
|
1257
|
-
constructor(deps) {
|
|
1258
|
-
this.store = deps.store;
|
|
1259
|
-
this.idGenerator = deps.idGenerator;
|
|
1260
|
-
this.fs = deps.fs ?? defaultFs$2;
|
|
1261
|
-
Object.freeze(this);
|
|
1262
|
-
}
|
|
1263
|
-
list() {
|
|
1264
|
-
return this.store.read().profiles;
|
|
1265
|
-
}
|
|
1266
|
-
get(name) {
|
|
1267
|
-
return this.list().find((p) => p.name === name) ?? null;
|
|
1268
|
-
}
|
|
1269
|
-
getById(id) {
|
|
1270
|
-
return this.list().find((p) => p.id === id) ?? null;
|
|
1271
|
-
}
|
|
1272
|
-
getDefault() {
|
|
1273
|
-
return this.list()[0] ?? null;
|
|
1274
|
-
}
|
|
1275
|
-
add(input) {
|
|
1276
|
-
const settings = this.store.read();
|
|
1277
|
-
if (settings.profiles.some((p) => p.name === input.name)) throw new Error(`profile "${input.name}" already exists`);
|
|
1278
|
-
if (!settings.channels.some((c) => c.id === input.channelId)) throw new Error(`channel id "${input.channelId}" not found`);
|
|
1279
|
-
settings.profiles.push({
|
|
1280
|
-
id: this.idGenerator.generate(),
|
|
1281
|
-
name: input.name,
|
|
1282
|
-
path: input.path,
|
|
1283
|
-
channelId: input.channelId,
|
|
1284
|
-
options: input.options ?? [],
|
|
1285
|
-
env: input.env ?? {},
|
|
1286
|
-
resume: input.resume ?? true
|
|
1287
|
-
});
|
|
1288
|
-
this.store.write(settings);
|
|
1289
|
-
}
|
|
1290
|
-
remove(name) {
|
|
1291
|
-
const settings = this.store.read();
|
|
1292
|
-
const index = settings.profiles.findIndex((p) => p.name === name);
|
|
1293
|
-
if (index < 0) throw new Error(`profile "${name}" not found`);
|
|
1294
|
-
settings.profiles.splice(index, 1);
|
|
1295
|
-
this.store.write(settings);
|
|
1296
|
-
}
|
|
1297
|
-
rename(oldName, newName) {
|
|
1298
|
-
const settings = this.store.read();
|
|
1299
|
-
const profile = settings.profiles.find((p) => p.name === oldName);
|
|
1300
|
-
if (!profile) throw new Error(`profile "${oldName}" not found`);
|
|
1301
|
-
if (settings.profiles.some((p) => p.name === newName)) throw new Error(`profile "${newName}" already exists`);
|
|
1302
|
-
profile.name = newName;
|
|
1303
|
-
this.store.write(settings);
|
|
1304
|
-
}
|
|
1305
|
-
asDefault(name) {
|
|
1306
|
-
const settings = this.store.read();
|
|
1307
|
-
const index = settings.profiles.findIndex((p) => p.name === name);
|
|
1308
|
-
if (index < 0) throw new Error(`profile "${name}" not found`);
|
|
1309
|
-
if (index === 0) return;
|
|
1310
|
-
const [profile] = settings.profiles.splice(index, 1);
|
|
1311
|
-
if (!profile) return;
|
|
1312
|
-
settings.profiles.unshift(profile);
|
|
1313
|
-
this.store.write(settings);
|
|
1314
|
-
}
|
|
1315
|
-
hasChannelRef(channelId) {
|
|
1316
|
-
return this.store.read().profiles.some((p) => p.channelId === channelId);
|
|
1317
|
-
}
|
|
1318
|
-
/** Resumable claude session id last launched by this profile (by id), or null. */
|
|
1319
|
-
getSessionId(id) {
|
|
1320
|
-
return this.getById(id)?.sessionId ?? null;
|
|
1321
|
-
}
|
|
1322
|
-
/** Records the claude session id this profile launched, overwriting any prior one. */
|
|
1323
|
-
setSessionId(id, sessionId) {
|
|
1324
|
-
const settings = this.store.read();
|
|
1325
|
-
const profile = settings.profiles.find((p) => p.id === id);
|
|
1326
|
-
if (!profile) throw new Error(`profile id "${id}" not found`);
|
|
1327
|
-
profile.sessionId = sessionId;
|
|
1328
|
-
this.store.write(settings);
|
|
1329
|
-
}
|
|
1330
|
-
/**
|
|
1331
|
-
* Mirrors claude's session storage path
|
|
1332
|
-
* (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
|
|
1333
|
-
* whether a recorded session still exists AND is non-empty. Reads the same
|
|
1334
|
-
* `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
|
|
1335
|
-
* wrong guess can only ever produce a false negative (start fresh), never a
|
|
1336
|
-
* bad resume.
|
|
1337
|
-
*/
|
|
1338
|
-
sessionFileExists(cwd, sessionId, env) {
|
|
1339
|
-
const path = join(env.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
|
|
1340
|
-
if (!this.fs.existsSync(path)) return false;
|
|
1341
|
-
return this.fs.readFileSync(path).trim().length > 0;
|
|
1342
|
-
}
|
|
1343
|
-
update(name, fields) {
|
|
1344
|
-
const settings = this.store.read();
|
|
1345
|
-
const profile = settings.profiles.find((p) => p.name === name);
|
|
1346
|
-
if (!profile) throw new Error(`profile "${name}" not found`);
|
|
1347
|
-
if (fields.channelId !== void 0) {
|
|
1348
|
-
if (!settings.channels.some((c) => c.id === fields.channelId)) throw new Error(`channel id "${fields.channelId}" not found`);
|
|
1349
|
-
profile.channelId = fields.channelId;
|
|
1350
|
-
}
|
|
1351
|
-
if (fields.path !== void 0) profile.path = fields.path;
|
|
1352
|
-
if (fields.options !== void 0) profile.options = fields.options;
|
|
1353
|
-
if (fields.env !== void 0) profile.env = fields.env;
|
|
1354
|
-
if (fields.resume !== void 0) profile.resume = fields.resume;
|
|
1355
|
-
this.store.write(settings);
|
|
1356
|
-
}
|
|
1357
|
-
};
|
|
1358
|
-
//#endregion
|
|
1359
|
-
//#region lib/engine/token-prompter/node-token-prompter.ts
|
|
1360
|
-
const STAR = "*";
|
|
1361
|
-
const CR = "\r";
|
|
1362
|
-
const LF = "\n";
|
|
1363
|
-
const BACKSPACE = String.fromCharCode(8);
|
|
1364
|
-
const DEL = String.fromCharCode(127);
|
|
1365
|
-
const CTRL_C = String.fromCharCode(3);
|
|
1366
|
-
const CTRL_D = String.fromCharCode(4);
|
|
1367
|
-
/**
|
|
1368
|
-
* Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
|
|
1369
|
-
* can see progress without exposing the token. Refuses to prompt when stdin
|
|
1370
|
-
* is not a TTY — callers should surface the resulting error with a hint
|
|
1371
|
-
* pointing at the corresponding env var or CLI command.
|
|
1372
|
-
*/
|
|
1373
|
-
var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
|
|
1374
|
-
async promptSecret(label) {
|
|
1375
|
-
if (!stdin.isTTY) throw new Error(`cannot prompt for "${label}": stdin is not a TTY. Set the matching env var or run \`fnl channels <ch> connectors add ...\` first.`);
|
|
1376
|
-
stderr.write(`${label}: `);
|
|
1377
|
-
const wasRaw = stdin.isRaw;
|
|
1378
|
-
stdin.setRawMode(true);
|
|
1379
|
-
stdin.resume();
|
|
1380
|
-
try {
|
|
1381
|
-
return await this.readSecret();
|
|
1382
|
-
} finally {
|
|
1383
|
-
stdin.setRawMode(wasRaw);
|
|
1384
|
-
stdin.pause();
|
|
1385
|
-
stderr.write(LF);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
readSecret() {
|
|
1389
|
-
return new Promise((resolve, reject) => {
|
|
1390
|
-
let buffer = "";
|
|
1391
|
-
const onData = (chunk) => {
|
|
1392
|
-
for (const byte of chunk) {
|
|
1393
|
-
const char = String.fromCharCode(byte);
|
|
1394
|
-
if (char === LF || char === CR) {
|
|
1395
|
-
stdin.off("data", onData);
|
|
1396
|
-
resolve(buffer);
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
if (char === CTRL_C) {
|
|
1400
|
-
stdin.off("data", onData);
|
|
1401
|
-
reject(/* @__PURE__ */ new Error("prompt cancelled"));
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
if (char === CTRL_D) {
|
|
1405
|
-
stdin.off("data", onData);
|
|
1406
|
-
if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
|
|
1407
|
-
else resolve(buffer);
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
if (char === BACKSPACE || char === DEL) {
|
|
1411
|
-
if (buffer.length > 0) {
|
|
1412
|
-
buffer = buffer.slice(0, -1);
|
|
1413
|
-
stderr.write("\b \b");
|
|
1414
|
-
}
|
|
1415
|
-
continue;
|
|
1416
|
-
}
|
|
1417
|
-
buffer += char;
|
|
1418
|
-
stderr.write(STAR);
|
|
1419
|
-
}
|
|
1420
|
-
};
|
|
1421
|
-
stdin.on("data", onData);
|
|
1422
|
-
});
|
|
1423
|
-
}
|
|
1424
|
-
};
|
|
1425
|
-
//#endregion
|
|
1426
|
-
//#region lib/engine/fs/memory-file-system.ts
|
|
1427
|
-
const SECRET_MODE = 384;
|
|
1428
|
-
var MemoryFunnelFileSystem = class extends FunnelFileSystem {
|
|
1429
|
-
dirs;
|
|
1430
|
-
files;
|
|
1431
|
-
mtimes;
|
|
1432
|
-
modes;
|
|
1433
|
-
now;
|
|
1434
|
-
constructor(props = {}) {
|
|
1435
|
-
super();
|
|
1436
|
-
this.dirs = new Set(props.dirs ?? []);
|
|
1437
|
-
this.files = new Map(Object.entries(props.files ?? {}));
|
|
1438
|
-
this.mtimes = new Map(Object.entries(props.mtimes ?? {}));
|
|
1439
|
-
this.modes = new Map(Object.entries(props.modes ?? {}));
|
|
1440
|
-
this.now = props.now ?? (() => Date.now());
|
|
1441
|
-
}
|
|
1442
|
-
existsSync(path) {
|
|
1443
|
-
return this.dirs.has(path) || this.files.has(path);
|
|
1444
|
-
}
|
|
1445
|
-
readFileSync(path) {
|
|
1446
|
-
return this.files.get(path) ?? "";
|
|
1447
|
-
}
|
|
1448
|
-
writeFileSync(path, data) {
|
|
1449
|
-
this.files.set(path, data);
|
|
1450
|
-
this.touch(path);
|
|
1451
|
-
}
|
|
1452
|
-
writeSecretFileSync(path, data) {
|
|
1453
|
-
this.files.set(path, data);
|
|
1454
|
-
this.modes.set(path, SECRET_MODE);
|
|
1455
|
-
this.touch(path);
|
|
1456
|
-
}
|
|
1457
|
-
appendFileSync(path, data) {
|
|
1458
|
-
const prev = this.files.get(path) ?? "";
|
|
1459
|
-
this.files.set(path, prev + data);
|
|
1460
|
-
this.touch(path);
|
|
1461
|
-
}
|
|
1462
|
-
unlink(path) {
|
|
1463
|
-
this.files.delete(path);
|
|
1464
|
-
this.mtimes.delete(path);
|
|
1465
|
-
this.modes.delete(path);
|
|
1466
|
-
}
|
|
1467
|
-
mkdirSync(path, options) {
|
|
1468
|
-
this.dirs.add(path);
|
|
1469
|
-
}
|
|
1470
|
-
readdirSync(path) {
|
|
1471
|
-
const prefix = path.endsWith("/") ? path : `${path}/`;
|
|
1472
|
-
const names = [];
|
|
1473
|
-
for (const file of this.files.keys()) {
|
|
1474
|
-
if (!file.startsWith(prefix)) continue;
|
|
1475
|
-
const rest = file.slice(prefix.length);
|
|
1476
|
-
if (!rest.includes("/")) names.push(rest);
|
|
1477
|
-
}
|
|
1478
|
-
return names;
|
|
1479
|
-
}
|
|
1480
|
-
statSync(path) {
|
|
1481
|
-
const mtimeMs = this.mtimes.get(path);
|
|
1482
|
-
if (mtimeMs === void 0) throw new Error(`not found: ${path}`);
|
|
1483
|
-
return {
|
|
1484
|
-
mtimeMs,
|
|
1485
|
-
mode: this.modes.get(path) ?? null
|
|
1486
|
-
};
|
|
1487
|
-
}
|
|
1488
|
-
setMtime(path, mtimeMs) {
|
|
1489
|
-
this.mtimes.set(path, mtimeMs);
|
|
1490
|
-
}
|
|
1491
|
-
setMode(path, mode) {
|
|
1492
|
-
this.modes.set(path, mode);
|
|
1493
|
-
}
|
|
1494
|
-
touch(path) {
|
|
1495
|
-
if (!this.mtimes.has(path)) this.mtimes.set(path, this.now());
|
|
1496
|
-
else this.mtimes.set(path, this.now());
|
|
1497
|
-
}
|
|
1498
|
-
};
|
|
1499
|
-
//#endregion
|
|
1500
|
-
//#region lib/engine/id/memory-id-generator.ts
|
|
1501
|
-
var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
|
|
1502
|
-
counter = 0;
|
|
1503
|
-
prefix;
|
|
1504
|
-
constructor(props = {}) {
|
|
1505
|
-
super();
|
|
1506
|
-
this.prefix = props.prefix ?? "id";
|
|
1507
|
-
}
|
|
1508
|
-
generate() {
|
|
1509
|
-
this.counter++;
|
|
1510
|
-
return `${this.prefix}-${this.counter}`;
|
|
1511
|
-
}
|
|
1512
|
-
};
|
|
1513
|
-
//#endregion
|
|
1514
|
-
//#region lib/engine/logger/memory-logger.ts
|
|
1515
|
-
var MemoryFunnelLogger = class extends FunnelLogger {
|
|
1516
|
-
file = null;
|
|
1517
|
-
entries = [];
|
|
1518
|
-
info(message, meta) {
|
|
1519
|
-
this.entries.push({
|
|
1520
|
-
level: "info",
|
|
1521
|
-
message,
|
|
1522
|
-
meta
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1525
|
-
warn(message, meta) {
|
|
1526
|
-
this.entries.push({
|
|
1527
|
-
level: "warn",
|
|
1528
|
-
message,
|
|
1529
|
-
meta
|
|
560
|
+
warn(message, meta) {
|
|
561
|
+
this.entries.push({
|
|
562
|
+
level: "warn",
|
|
563
|
+
message,
|
|
564
|
+
meta
|
|
1530
565
|
});
|
|
1531
566
|
}
|
|
1532
567
|
error(message, meta) {
|
|
@@ -1662,18 +697,6 @@ var MockFunnelSettingsReader = class extends FunnelSettingsReader {
|
|
|
1662
697
|
}
|
|
1663
698
|
};
|
|
1664
699
|
//#endregion
|
|
1665
|
-
//#region lib/engine/settings/tmp-dir.ts
|
|
1666
|
-
/**
|
|
1667
|
-
* Resolves the funnel temp/log root for the current OS. Defaults to
|
|
1668
|
-
* `<os.tmpdir()>/funnel` so Windows lands under `%TEMP%\funnel` and POSIX
|
|
1669
|
-
* lands under `/tmp/funnel`. Callers may override via `FUNNEL_TMP_DIR`.
|
|
1670
|
-
*/
|
|
1671
|
-
function funnelTmpDir() {
|
|
1672
|
-
const override = process.env.FUNNEL_TMP_DIR;
|
|
1673
|
-
if (override && override.length > 0) return override;
|
|
1674
|
-
return join(tmpdir(), "funnel");
|
|
1675
|
-
}
|
|
1676
|
-
//#endregion
|
|
1677
700
|
//#region lib/engine/time/memory-clock.ts
|
|
1678
701
|
var MemoryFunnelClock = class extends FunnelClock {
|
|
1679
702
|
current;
|
|
@@ -1692,121 +715,43 @@ var MemoryFunnelClock = class extends FunnelClock {
|
|
|
1692
715
|
}
|
|
1693
716
|
};
|
|
1694
717
|
//#endregion
|
|
1695
|
-
//#region lib/gateway/
|
|
718
|
+
//#region lib/gateway/resolve-daemon-script.ts
|
|
1696
719
|
/**
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
720
|
+
* Locate the daemon entry script. Works in both dev (running from source)
|
|
721
|
+
* and built mode (bundled into dist/bin.js with daemon at dist/gateway/daemon.js).
|
|
722
|
+
*
|
|
723
|
+
* The candidates cover:
|
|
724
|
+
* 1. dev: this helper lives at lib/gateway/, so daemon.ts is its sibling
|
|
725
|
+
* 2. built sibling: dist/gateway/daemon.js if the helper itself ends up at dist/gateway/
|
|
726
|
+
* 3. bundled: when this helper is inlined into dist/bin.js, the helper's dir is dist/,
|
|
727
|
+
* and daemon.js lives at dist/gateway/daemon.js
|
|
728
|
+
*
|
|
729
|
+
* Uses `fileURLToPath(import.meta.url)` rather than `import.meta.dir` so the
|
|
730
|
+
* same helper resolves correctly whether run from source, the built sibling,
|
|
731
|
+
* or inlined into the bundle.
|
|
1701
732
|
*/
|
|
1702
|
-
const
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
});
|
|
1713
|
-
const publishResponseSchema = z.object({
|
|
1714
|
-
ok: z.literal(true),
|
|
1715
|
-
offset: z.number().int().nonnegative()
|
|
1716
|
-
});
|
|
733
|
+
const resolveDaemonScript = () => {
|
|
734
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
735
|
+
const candidates = [
|
|
736
|
+
resolve(here, "./daemon.ts"),
|
|
737
|
+
resolve(here, "./daemon.js"),
|
|
738
|
+
resolve(here, "./gateway/daemon.js")
|
|
739
|
+
];
|
|
740
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
741
|
+
throw new Error(`daemon script not found (looked in ${candidates.join(", ")})`);
|
|
742
|
+
};
|
|
1717
743
|
//#endregion
|
|
1718
|
-
//#region lib/gateway/
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
constructor(deps) {
|
|
1730
|
-
this.port = deps.port;
|
|
1731
|
-
this.isDaemonRunning = deps.isDaemonRunning;
|
|
1732
|
-
this.getToken = deps.getToken ?? (() => null);
|
|
1733
|
-
Object.freeze(this);
|
|
1734
|
-
}
|
|
1735
|
-
async publish(channelName, request) {
|
|
1736
|
-
if (!this.isDaemonRunning()) return OFFLINE$1;
|
|
1737
|
-
try {
|
|
1738
|
-
const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
|
|
1739
|
-
const res = await fetch(url, {
|
|
1740
|
-
method: "POST",
|
|
1741
|
-
headers: {
|
|
1742
|
-
...this.authHeaders(),
|
|
1743
|
-
"content-type": "application/json"
|
|
1744
|
-
},
|
|
1745
|
-
body: JSON.stringify(request)
|
|
1746
|
-
});
|
|
1747
|
-
if (!res.ok) return {
|
|
1748
|
-
state: "error",
|
|
1749
|
-
reason: await res.text() || `HTTP ${res.status}`
|
|
1750
|
-
};
|
|
1751
|
-
const parsed = publishResponseSchema.safeParse(await res.json());
|
|
1752
|
-
if (!parsed.success) return {
|
|
1753
|
-
state: "error",
|
|
1754
|
-
reason: "malformed daemon response"
|
|
1755
|
-
};
|
|
1756
|
-
return {
|
|
1757
|
-
state: "ok",
|
|
1758
|
-
offset: parsed.data.offset
|
|
1759
|
-
};
|
|
1760
|
-
} catch (error) {
|
|
1761
|
-
return {
|
|
1762
|
-
state: "error",
|
|
1763
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
1764
|
-
};
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
authHeaders() {
|
|
1768
|
-
const token = this.getToken();
|
|
1769
|
-
return token ? { authorization: `Bearer ${token}` } : {};
|
|
1770
|
-
}
|
|
1771
|
-
};
|
|
1772
|
-
//#endregion
|
|
1773
|
-
//#region lib/gateway/resolve-daemon-script.ts
|
|
1774
|
-
/**
|
|
1775
|
-
* Locate the daemon entry script. Works in both dev (running from source)
|
|
1776
|
-
* and built mode (bundled into dist/bin.js with daemon at dist/gateway/daemon.js).
|
|
1777
|
-
*
|
|
1778
|
-
* The candidates cover:
|
|
1779
|
-
* 1. dev: this helper lives at lib/gateway/, so daemon.ts is its sibling
|
|
1780
|
-
* 2. built sibling: dist/gateway/daemon.js if the helper itself ends up at dist/gateway/
|
|
1781
|
-
* 3. bundled: when this helper is inlined into dist/bin.js, the helper's dir is dist/,
|
|
1782
|
-
* and daemon.js lives at dist/gateway/daemon.js
|
|
1783
|
-
*
|
|
1784
|
-
* Uses `fileURLToPath(import.meta.url)` rather than `import.meta.dir` so the
|
|
1785
|
-
* same helper resolves correctly whether run from source, the built sibling,
|
|
1786
|
-
* or inlined into the bundle.
|
|
1787
|
-
*/
|
|
1788
|
-
const resolveDaemonScript = () => {
|
|
1789
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
1790
|
-
const candidates = [
|
|
1791
|
-
resolve(here, "./daemon.ts"),
|
|
1792
|
-
resolve(here, "./daemon.js"),
|
|
1793
|
-
resolve(here, "./gateway/daemon.js")
|
|
1794
|
-
];
|
|
1795
|
-
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
1796
|
-
throw new Error(`daemon script not found (looked in ${candidates.join(", ")})`);
|
|
1797
|
-
};
|
|
1798
|
-
//#endregion
|
|
1799
|
-
//#region lib/gateway/gateway.ts
|
|
1800
|
-
const STARTUP_TIMEOUT_MS = 5e3;
|
|
1801
|
-
const SIGTERM_TIMEOUT_MS = 2e3;
|
|
1802
|
-
const POLL_INTERVAL_MS = 100;
|
|
1803
|
-
const SIGKILL_GRACE_MS = 200;
|
|
1804
|
-
const defaultProcess$1 = new NodeFunnelProcessRunner();
|
|
1805
|
-
const defaultFs$1 = new NodeFunnelFileSystem();
|
|
1806
|
-
const defaultClock = new NodeFunnelClock();
|
|
1807
|
-
const defaultSleep$1 = (ms) => new Promise((r) => {
|
|
1808
|
-
setTimeout(r, ms);
|
|
1809
|
-
});
|
|
744
|
+
//#region lib/gateway/gateway.ts
|
|
745
|
+
const STARTUP_TIMEOUT_MS = 5e3;
|
|
746
|
+
const SIGTERM_TIMEOUT_MS = 2e3;
|
|
747
|
+
const POLL_INTERVAL_MS = 100;
|
|
748
|
+
const SIGKILL_GRACE_MS = 200;
|
|
749
|
+
const defaultProcess = new NodeFunnelProcessRunner();
|
|
750
|
+
const defaultFs = new NodeFunnelFileSystem();
|
|
751
|
+
const defaultClock = new NodeFunnelClock();
|
|
752
|
+
const defaultSleep = (ms) => new Promise((r) => {
|
|
753
|
+
setTimeout(r, ms);
|
|
754
|
+
});
|
|
1810
755
|
/**
|
|
1811
756
|
* Manages the gateway daemon as a separate process via PID file.
|
|
1812
757
|
* Use `start()` to spawn `bun daemon.ts` in the background and `stop()` to
|
|
@@ -1823,1812 +768,137 @@ var FunnelGateway = class {
|
|
|
1823
768
|
port;
|
|
1824
769
|
sleep;
|
|
1825
770
|
constructor(deps = {}) {
|
|
1826
|
-
this.process = deps.process ?? defaultProcess
|
|
1827
|
-
this.fs = deps.fs ?? defaultFs
|
|
771
|
+
this.process = deps.process ?? defaultProcess;
|
|
772
|
+
this.fs = deps.fs ?? defaultFs;
|
|
1828
773
|
this.clock = deps.clock ?? defaultClock;
|
|
1829
774
|
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
1830
775
|
this.tmpDir = deps.tmpDir ?? funnelTmpDir();
|
|
1831
776
|
this.pidFile = join(this.dir, "gateway.pid");
|
|
1832
777
|
this.gatewayLog = join(this.tmpDir, "gateway.log");
|
|
1833
778
|
this.port = deps.port ?? resolveFunnelPort();
|
|
1834
|
-
this.sleep = deps.sleep ?? defaultSleep
|
|
779
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
1835
780
|
Object.freeze(this);
|
|
1836
781
|
}
|
|
1837
|
-
isRunning() {
|
|
1838
|
-
const pid = this.readPid();
|
|
1839
|
-
if (!pid) return false;
|
|
1840
|
-
return this.isProcessAlive(pid);
|
|
1841
|
-
}
|
|
1842
|
-
getStatus() {
|
|
1843
|
-
const pid = this.readPid();
|
|
1844
|
-
const running = pid !== null && this.isProcessAlive(pid);
|
|
1845
|
-
return {
|
|
1846
|
-
running,
|
|
1847
|
-
pid: running ? pid : null,
|
|
1848
|
-
port: this.port
|
|
1849
|
-
};
|
|
1850
|
-
}
|
|
1851
|
-
async start(options = {}) {
|
|
1852
|
-
if (this.isRunning()) return true;
|
|
1853
|
-
this.fs.mkdirSync(this.tmpDir, { recursive: true });
|
|
1854
|
-
const gatewayScript = resolveDaemonScript();
|
|
1855
|
-
const command = this.buildStartCommand(gatewayScript, options);
|
|
1856
|
-
this.process.detach(command, {
|
|
1857
|
-
env: { FUNNEL_DIR: this.dir },
|
|
1858
|
-
stdoutFile: this.gatewayLog,
|
|
1859
|
-
stderrFile: this.gatewayLog
|
|
1860
|
-
});
|
|
1861
|
-
const deadline = this.clock.millis() + STARTUP_TIMEOUT_MS;
|
|
1862
|
-
while (this.clock.millis() < deadline) {
|
|
1863
|
-
if (this.isRunning()) return true;
|
|
1864
|
-
await this.sleep(POLL_INTERVAL_MS);
|
|
1865
|
-
}
|
|
1866
|
-
return this.isRunning();
|
|
1867
|
-
}
|
|
1868
|
-
buildStartCommand(gatewayScript, options = {}) {
|
|
1869
|
-
const tag = `funnel-gateway[${this.dir}]`;
|
|
1870
|
-
if (options.caffeinate !== false && globalThis.process.platform === "darwin") return [
|
|
1871
|
-
"caffeinate",
|
|
1872
|
-
"-is",
|
|
1873
|
-
"bun",
|
|
1874
|
-
gatewayScript,
|
|
1875
|
-
tag
|
|
1876
|
-
];
|
|
1877
|
-
return [
|
|
1878
|
-
"bun",
|
|
1879
|
-
gatewayScript,
|
|
1880
|
-
tag
|
|
1881
|
-
];
|
|
1882
|
-
}
|
|
1883
|
-
async stop() {
|
|
1884
|
-
const pid = this.readPid();
|
|
1885
|
-
if (!pid) return true;
|
|
1886
|
-
if (!this.isProcessAlive(pid)) {
|
|
1887
|
-
this.removePid();
|
|
1888
|
-
return true;
|
|
1889
|
-
}
|
|
1890
|
-
try {
|
|
1891
|
-
this.process.kill(pid, "SIGTERM");
|
|
1892
|
-
} catch {
|
|
1893
|
-
return false;
|
|
1894
|
-
}
|
|
1895
|
-
const deadline = this.clock.millis() + SIGTERM_TIMEOUT_MS;
|
|
1896
|
-
while (this.clock.millis() < deadline) {
|
|
1897
|
-
if (!this.isProcessAlive(pid)) {
|
|
1898
|
-
this.removePid();
|
|
1899
|
-
return true;
|
|
1900
|
-
}
|
|
1901
|
-
await this.sleep(POLL_INTERVAL_MS);
|
|
1902
|
-
}
|
|
1903
|
-
try {
|
|
1904
|
-
this.process.kill(pid, "SIGKILL");
|
|
1905
|
-
} catch {}
|
|
1906
|
-
await this.sleep(SIGKILL_GRACE_MS);
|
|
1907
|
-
this.removePid();
|
|
1908
|
-
return !this.isProcessAlive(pid);
|
|
1909
|
-
}
|
|
1910
|
-
async restart(options = {}) {
|
|
1911
|
-
const wasRunning = this.isRunning();
|
|
1912
|
-
if (options.onlyIfRunning && !wasRunning) return {
|
|
1913
|
-
ok: true,
|
|
1914
|
-
wasRunning: false,
|
|
1915
|
-
stopped: false,
|
|
1916
|
-
started: false
|
|
1917
|
-
};
|
|
1918
|
-
const stopped = wasRunning ? await this.stop() : true;
|
|
1919
|
-
if (!stopped) return {
|
|
1920
|
-
ok: false,
|
|
1921
|
-
wasRunning,
|
|
1922
|
-
stopped: false,
|
|
1923
|
-
started: false
|
|
1924
|
-
};
|
|
1925
|
-
const started = await this.start({ caffeinate: options.caffeinate });
|
|
1926
|
-
return {
|
|
1927
|
-
ok: started,
|
|
1928
|
-
wasRunning,
|
|
1929
|
-
stopped,
|
|
1930
|
-
started
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
1933
|
-
getGatewayLog() {
|
|
1934
|
-
return this.gatewayLog;
|
|
1935
|
-
}
|
|
1936
|
-
getPort() {
|
|
1937
|
-
return this.port;
|
|
1938
|
-
}
|
|
1939
|
-
readPid() {
|
|
1940
|
-
if (!this.fs.existsSync(this.pidFile)) return null;
|
|
1941
|
-
try {
|
|
1942
|
-
const content = this.fs.readFileSync(this.pidFile).trim();
|
|
1943
|
-
const pid = Number(content);
|
|
1944
|
-
if (!pid || pid <= 0) return null;
|
|
1945
|
-
return pid;
|
|
1946
|
-
} catch {
|
|
1947
|
-
return null;
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
removePid() {
|
|
1951
|
-
this.fs.unlink(this.pidFile);
|
|
1952
|
-
}
|
|
1953
|
-
isProcessAlive(pid) {
|
|
1954
|
-
return this.process.isAlive(pid);
|
|
1955
|
-
}
|
|
1956
|
-
};
|
|
1957
|
-
//#endregion
|
|
1958
|
-
//#region lib/gateway/auth-middleware.ts
|
|
1959
|
-
/**
|
|
1960
|
-
* Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
|
|
1961
|
-
* Mounted on the routes that mutate listener state or expose detailed status.
|
|
1962
|
-
* `/health` is intentionally left unauthenticated so the daemon manager can
|
|
1963
|
-
* probe liveness without needing the token.
|
|
1964
|
-
*/
|
|
1965
|
-
const requireBearerToken = (deps) => {
|
|
1966
|
-
return async (c, next) => {
|
|
1967
|
-
if (!constantTimeEqual((c.req.header("authorization") ?? "").match(/^Bearer\s+(.+)$/i)?.[1] ?? "", deps.expected)) return c.text("unauthorized", 401);
|
|
1968
|
-
return await next();
|
|
1969
|
-
};
|
|
1970
|
-
};
|
|
1971
|
-
const constantTimeEqual = (a, b) => {
|
|
1972
|
-
const bufA = Buffer.from(a, "utf-8");
|
|
1973
|
-
const bufB = Buffer.from(b, "utf-8");
|
|
1974
|
-
const maxLen = Math.max(bufA.length, bufB.length, 1);
|
|
1975
|
-
const padA = Buffer.alloc(maxLen);
|
|
1976
|
-
const padB = Buffer.alloc(maxLen);
|
|
1977
|
-
bufA.copy(padA);
|
|
1978
|
-
bufB.copy(padB);
|
|
1979
|
-
return timingSafeEqual(padA, padB) && bufA.length === bufB.length;
|
|
1980
|
-
};
|
|
1981
|
-
//#endregion
|
|
1982
|
-
//#region lib/gateway/factory.ts
|
|
1983
|
-
const factory$1 = createFactory();
|
|
1984
|
-
//#endregion
|
|
1985
|
-
//#region lib/gateway/broadcaster.ts
|
|
1986
|
-
const byteLengthOf = (event) => {
|
|
1987
|
-
let bytes = Buffer.byteLength(event.content, "utf-8");
|
|
1988
|
-
if (event.meta) for (const [k, v] of Object.entries(event.meta)) bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8");
|
|
1989
|
-
return bytes;
|
|
1990
|
-
};
|
|
1991
|
-
const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
1992
|
-
const DEFAULT_REPLAY_BUFFER_SIZE = 200;
|
|
1993
|
-
const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
|
|
1994
|
-
const defaultOnError$2 = () => {};
|
|
1995
|
-
/**
|
|
1996
|
-
* In-process pub/sub for connector events.
|
|
1997
|
-
*
|
|
1998
|
-
* Two outbound paths:
|
|
1999
|
-
* - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
|
|
2000
|
-
* - In-process subscribers registered via `subscribe()` (programmable API)
|
|
2001
|
-
*
|
|
2002
|
-
* Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
|
|
2003
|
-
* (default 1 MiB), the client is closed with code 1009 and dropped from the
|
|
2004
|
-
* registry to keep one slow consumer from blocking the daemon.
|
|
2005
|
-
*
|
|
2006
|
-
* Replay: every emitted event gets a strictly increasing `offset`. The latest
|
|
2007
|
-
* `replayBufferSize` events are kept in memory; reconnecting WS clients can
|
|
2008
|
-
* pass `?since=<offset>` and the broadcaster resends matching events before
|
|
2009
|
-
* resuming the live stream. The in-memory ring covers short reconnects;
|
|
2010
|
-
* older history is served from the event log wired in as `persistentReplay`.
|
|
2011
|
-
*/
|
|
2012
|
-
var FunnelBroadcaster = class {
|
|
2013
|
-
clients = /* @__PURE__ */ new Map();
|
|
2014
|
-
subscribers = /* @__PURE__ */ new Set();
|
|
2015
|
-
logger;
|
|
2016
|
-
onError;
|
|
2017
|
-
maxBufferedBytes;
|
|
2018
|
-
now;
|
|
2019
|
-
replayBufferSize;
|
|
2020
|
-
replayBufferMaxBytes;
|
|
2021
|
-
replayBuffer = [];
|
|
2022
|
-
persistentReplay;
|
|
2023
|
-
exclusiveCursor = /* @__PURE__ */ new Map();
|
|
2024
|
-
replayBufferBytes = 0;
|
|
2025
|
-
eventsBroadcast = 0;
|
|
2026
|
-
droppedSlowClients = 0;
|
|
2027
|
-
lastBroadcastAt = null;
|
|
2028
|
-
latestOffset = 0;
|
|
2029
|
-
constructor(deps = {}) {
|
|
2030
|
-
this.logger = deps.logger;
|
|
2031
|
-
this.onError = deps.onError ?? defaultOnError$2;
|
|
2032
|
-
this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
2033
|
-
this.now = deps.now ?? (() => Date.now());
|
|
2034
|
-
this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE);
|
|
2035
|
-
this.replayBufferMaxBytes = Math.max(0, deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES);
|
|
2036
|
-
this.persistentReplay = deps.persistentReplay ?? null;
|
|
2037
|
-
}
|
|
2038
|
-
getMetrics() {
|
|
2039
|
-
return {
|
|
2040
|
-
clients: this.clients.size,
|
|
2041
|
-
subscribers: this.subscribers.size,
|
|
2042
|
-
eventsBroadcast: this.eventsBroadcast,
|
|
2043
|
-
droppedSlowClients: this.droppedSlowClients,
|
|
2044
|
-
lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
|
|
2045
|
-
latestOffset: this.latestOffset,
|
|
2046
|
-
oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null
|
|
2047
|
-
};
|
|
2048
|
-
}
|
|
2049
|
-
/**
|
|
2050
|
-
* Returns events with offset > since, filtered by the connector subscription
|
|
2051
|
-
* rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
|
|
2052
|
-
*
|
|
2053
|
-
* Two-tier lookup:
|
|
2054
|
-
* 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
|
|
2055
|
-
* 2. If `since` predates the oldest in-memory entry and a persistent replay source
|
|
2056
|
-
* is wired in (SQLite by default), the gap is filled from it. This covers reconnects
|
|
2057
|
-
* across daemon restarts where the in-memory buffer was lost.
|
|
2058
|
-
*
|
|
2059
|
-
* Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
|
|
2060
|
-
*/
|
|
2061
|
-
replaySince(since, data) {
|
|
2062
|
-
const oldestInMemory = this.replayBuffer[0]?.offset;
|
|
2063
|
-
const needFallback = this.persistentReplay && (oldestInMemory === void 0 || since < oldestInMemory - 1);
|
|
2064
|
-
const fromMemory = this.replayBuffer.filter((event) => event.offset > since && this.matchesClient(event, data));
|
|
2065
|
-
if (!needFallback) return fromMemory;
|
|
2066
|
-
const persisted = this.persistentReplay ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data)) : [];
|
|
2067
|
-
const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY;
|
|
2068
|
-
return [...persisted.filter((event) => event.offset < cutoff), ...fromMemory];
|
|
2069
|
-
}
|
|
2070
|
-
matchesClient(event, data) {
|
|
2071
|
-
const target = event.meta?.target;
|
|
2072
|
-
if (target && target !== data.subscriberId) return false;
|
|
2073
|
-
const channelId = event.meta?.channelId;
|
|
2074
|
-
if (channelId && channelId !== data.channel) return false;
|
|
2075
|
-
const connector = event.meta?.connector;
|
|
2076
|
-
if (!connector) return true;
|
|
2077
|
-
return data.connectors.includes(connector);
|
|
2078
|
-
}
|
|
2079
|
-
/**
|
|
2080
|
-
* Returns the list of WS clients that should receive `event`. For each per-channel group:
|
|
2081
|
-
* - fanout → every matching client receives
|
|
2082
|
-
* - exclusive → exactly one client receives, picked round-robin per channel
|
|
2083
|
-
*
|
|
2084
|
-
* `meta.target` narrows the recipient set via `matchesClient`: only the subscriber
|
|
2085
|
-
* whose `subscriberId` equals `target` receives a targeted event.
|
|
2086
|
-
*/
|
|
2087
|
-
pickRecipients(event) {
|
|
2088
|
-
const exclusiveByChannel = /* @__PURE__ */ new Map();
|
|
2089
|
-
const recipients = [];
|
|
2090
|
-
for (const [ws, data] of this.clients) {
|
|
2091
|
-
if (!this.matchesClient(event, data)) continue;
|
|
2092
|
-
if (data.delivery === "exclusive") {
|
|
2093
|
-
const list = exclusiveByChannel.get(data.channel) ?? [];
|
|
2094
|
-
list.push(ws);
|
|
2095
|
-
exclusiveByChannel.set(data.channel, list);
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
recipients.push(ws);
|
|
2099
|
-
}
|
|
2100
|
-
for (const [channel, candidates] of exclusiveByChannel) {
|
|
2101
|
-
if (candidates.length === 0) continue;
|
|
2102
|
-
const cursor = this.exclusiveCursor.get(channel) ?? 0;
|
|
2103
|
-
const picked = candidates[cursor % candidates.length];
|
|
2104
|
-
if (picked) recipients.push(picked);
|
|
2105
|
-
this.exclusiveCursor.set(channel, cursor + 1);
|
|
2106
|
-
}
|
|
2107
|
-
return recipients;
|
|
2108
|
-
}
|
|
2109
|
-
addClient(ws, data) {
|
|
2110
|
-
this.clients.set(ws, data);
|
|
2111
|
-
}
|
|
2112
|
-
removeClient(ws) {
|
|
2113
|
-
this.clients.delete(ws);
|
|
2114
|
-
}
|
|
2115
|
-
getClientCount() {
|
|
2116
|
-
return this.clients.size;
|
|
2117
|
-
}
|
|
2118
|
-
listChannels() {
|
|
2119
|
-
return [...this.clients.values()].map((d) => ({ ...d }));
|
|
2120
|
-
}
|
|
2121
|
-
subscribe(handler) {
|
|
2122
|
-
this.subscribers.add(handler);
|
|
2123
|
-
return () => {
|
|
2124
|
-
this.subscribers.delete(handler);
|
|
2125
|
-
};
|
|
2126
|
-
}
|
|
2127
|
-
broadcast(content, meta) {
|
|
2128
|
-
this.latestOffset += 1;
|
|
2129
|
-
const event = {
|
|
2130
|
-
content,
|
|
2131
|
-
meta,
|
|
2132
|
-
offset: this.latestOffset
|
|
2133
|
-
};
|
|
2134
|
-
const payload = JSON.stringify(event);
|
|
2135
|
-
meta?.connector;
|
|
2136
|
-
this.eventsBroadcast += 1;
|
|
2137
|
-
this.lastBroadcastAt = this.now();
|
|
2138
|
-
if (this.replayBufferSize > 0) {
|
|
2139
|
-
const eventBytes = byteLengthOf(event);
|
|
2140
|
-
this.replayBuffer.push(event);
|
|
2141
|
-
this.replayBufferBytes += eventBytes;
|
|
2142
|
-
while ((this.replayBuffer.length > this.replayBufferSize || this.replayBufferBytes > this.replayBufferMaxBytes) && this.replayBuffer.length > 0) {
|
|
2143
|
-
const dropped = this.replayBuffer.shift();
|
|
2144
|
-
if (dropped) this.replayBufferBytes -= byteLengthOf(dropped);
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
const recipients = this.pickRecipients(event);
|
|
2148
|
-
for (const ws of recipients) {
|
|
2149
|
-
const buffered = ws.getBufferedAmount();
|
|
2150
|
-
if (buffered > this.maxBufferedBytes) {
|
|
2151
|
-
const data = this.clients.get(ws);
|
|
2152
|
-
this.logger?.warn("dropping slow WS client (backpressure)", {
|
|
2153
|
-
channel: data?.channel,
|
|
2154
|
-
buffered,
|
|
2155
|
-
max: this.maxBufferedBytes
|
|
2156
|
-
});
|
|
2157
|
-
try {
|
|
2158
|
-
ws.close(1009, "backpressure");
|
|
2159
|
-
} catch {}
|
|
2160
|
-
this.clients.delete(ws);
|
|
2161
|
-
this.droppedSlowClients += 1;
|
|
2162
|
-
continue;
|
|
2163
|
-
}
|
|
2164
|
-
ws.send(payload);
|
|
2165
|
-
}
|
|
2166
|
-
for (const handler of this.subscribers) try {
|
|
2167
|
-
handler(event);
|
|
2168
|
-
} catch (error) {
|
|
2169
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2170
|
-
this.logger?.error("broadcast subscriber threw", { error: err.message });
|
|
2171
|
-
this.onError(err, {
|
|
2172
|
-
component: "broadcaster.subscriber",
|
|
2173
|
-
offset: event.offset,
|
|
2174
|
-
connector: event.meta?.connector ?? null,
|
|
2175
|
-
channel: event.meta?.channel ?? null
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
2178
|
-
return event;
|
|
2179
|
-
}
|
|
2180
|
-
/** Forward-seed the offset counter (used at startup from the persisted event store). */
|
|
2181
|
-
seedLatestOffset(offset) {
|
|
2182
|
-
if (offset > this.latestOffset) this.latestOffset = offset;
|
|
2183
|
-
}
|
|
2184
|
-
};
|
|
2185
|
-
//#endregion
|
|
2186
|
-
//#region lib/gateway/funnel-event-log.ts
|
|
2187
|
-
/**
|
|
2188
|
-
* Replayable event payload persisted by the gateway. Domain events the
|
|
2189
|
-
* broadcaster emits to WS clients land here so reconnects across daemon
|
|
2190
|
-
* restarts can be served from disk. System events (gateway start, channel
|
|
2191
|
-
* connected, etc.) are routed to `FunnelLogger` instead — they never go
|
|
2192
|
-
* through this log, which keeps the offset space clean for replay.
|
|
2193
|
-
*/
|
|
2194
|
-
const funnelEventSchema = z.object({
|
|
2195
|
-
type: z.string(),
|
|
2196
|
-
content: z.string(),
|
|
2197
|
-
channel_id: z.string().nullable(),
|
|
2198
|
-
connector_id: z.string().nullable(),
|
|
2199
|
-
meta: z.record(z.string(), z.string()).nullable()
|
|
2200
|
-
});
|
|
2201
|
-
/**
|
|
2202
|
-
* Durable, append-only log of broadcaster events keyed by the offset the
|
|
2203
|
-
* broadcaster assigns. The gateway persists every domain event here, and
|
|
2204
|
-
* across restarts it both seeds the broadcaster's offset counter
|
|
2205
|
-
* (`findMaxOffset`) and serves reconnect replay (`loadSince`) from it.
|
|
2206
|
-
*
|
|
2207
|
-
* `loadSince` is the only method the broadcaster itself needs, which makes
|
|
2208
|
-
* any implementation assignable to the broadcaster's narrow `ReplaySource`.
|
|
2209
|
-
*
|
|
2210
|
-
* Implementations:
|
|
2211
|
-
* - `SqliteFunnelEventLog` — the default; durable across daemon restarts.
|
|
2212
|
-
* - `MemoryFunnelEventLog` — an in-process double for tests and embedders
|
|
2213
|
-
* that do not need durability (replay is lost when the process exits).
|
|
2214
|
-
*/
|
|
2215
|
-
var FunnelEventLog = class {};
|
|
2216
|
-
//#endregion
|
|
2217
|
-
//#region lib/logger/leuco-logger-sqlite-sink.ts
|
|
2218
|
-
/** Conservative whitelist for column names interpolated into SQL. */
|
|
2219
|
-
const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
|
|
2220
|
-
/** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
|
|
2221
|
-
const BYTE_CHECK_INTERVAL = 500;
|
|
2222
|
-
const RESERVED_COLUMNS = new Set([
|
|
2223
|
-
"seq",
|
|
2224
|
-
"ts",
|
|
2225
|
-
"type",
|
|
2226
|
-
"event"
|
|
2227
|
-
]);
|
|
2228
|
-
/**
|
|
2229
|
-
* Schema versions. Each entry is the list of DDL statements that take the
|
|
2230
|
-
* database from version i to version i + 1. Migrations run in a transaction
|
|
2231
|
-
* so a partial failure rolls back. Adding a new version is append-only —
|
|
2232
|
-
* never edit a published one. Caller-defined index columns are added
|
|
2233
|
-
* dynamically on construct (independent of versioned migrations) because
|
|
2234
|
-
* they are configuration, not schema evolution.
|
|
2235
|
-
*/
|
|
2236
|
-
const MIGRATIONS = [[
|
|
2237
|
-
"CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
|
|
2238
|
-
"CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
|
|
2239
|
-
"CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
|
|
2240
|
-
]];
|
|
2241
|
-
/**
|
|
2242
|
-
* SQLite-backed sink built on `bun:sqlite`. Implements both primary and
|
|
2243
|
-
* relay roles so the same instance can own seq generation for one bus and
|
|
2244
|
-
* mirror records from another (e.g. cross-process replication, restore
|
|
2245
|
-
* from a backup stream).
|
|
2246
|
-
*
|
|
2247
|
-
* Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
|
|
2248
|
-
* atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
|
|
2249
|
-
* at the same database file therefore see one monotonically increasing
|
|
2250
|
-
* seq stream without any bus-level coordination — the database itself is
|
|
2251
|
-
* the synchronization point.
|
|
2252
|
-
*
|
|
2253
|
-
* Schema is version-managed via `PRAGMA user_version`. Migrations are
|
|
2254
|
-
* append-only and run in a transaction on every construct so a partial
|
|
2255
|
-
* upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
|
|
2256
|
-
* via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
|
|
2257
|
-
* a new index to an existing database is a no-downtime operation.
|
|
2258
|
-
*
|
|
2259
|
-
* Type safety: the second generic parameter `I` is the literal tuple of
|
|
2260
|
-
* index column names. `extractIndexes` and `getRecords({ where })` are
|
|
2261
|
-
* both type-checked against this tuple, so a typo at the call site is a
|
|
2262
|
-
* compile-time error rather than a silent miss at runtime.
|
|
2263
|
-
*
|
|
2264
|
-
* Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
|
|
2265
|
-
* insert as a single indexed DELETE that no-ops below the cap.
|
|
2266
|
-
*
|
|
2267
|
-
* Bulk inserts use `insertMany`, which wraps the batch in one transaction
|
|
2268
|
-
* for ~10–100x throughput at the cost of one fsync per batch instead of
|
|
2269
|
-
* one per row.
|
|
2270
|
-
*/
|
|
2271
|
-
var LeucoLoggerSqliteSink = class {
|
|
2272
|
-
db;
|
|
2273
|
-
maxRows;
|
|
2274
|
-
maxAgeMs;
|
|
2275
|
-
maxBytes;
|
|
2276
|
-
targetBytes;
|
|
2277
|
-
now;
|
|
2278
|
-
indexes;
|
|
2279
|
-
extractIndexes;
|
|
2280
|
-
insertStmt;
|
|
2281
|
-
insertWithSeqStmt;
|
|
2282
|
-
maxSeqStmt;
|
|
2283
|
-
countStmt;
|
|
2284
|
-
trimRowsStmt;
|
|
2285
|
-
trimAgeStmt;
|
|
2286
|
-
trimOldestStmt;
|
|
2287
|
-
insertsSinceByteCheck = 0;
|
|
2288
|
-
constructor(props) {
|
|
2289
|
-
this.db = new Database(props.path);
|
|
2290
|
-
this.db.run("PRAGMA journal_mode = WAL");
|
|
2291
|
-
this.migrate();
|
|
2292
|
-
this.maxRows = props.maxRows ?? null;
|
|
2293
|
-
this.maxAgeMs = props.maxAgeMs ?? null;
|
|
2294
|
-
this.maxBytes = props.maxBytes ?? null;
|
|
2295
|
-
this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
|
|
2296
|
-
this.now = props.now ?? (() => Date.now());
|
|
2297
|
-
this.indexes = props.indexes ?? [];
|
|
2298
|
-
if (this.indexes.length > 0) {
|
|
2299
|
-
validateIndexNames(this.indexes);
|
|
2300
|
-
this.extractIndexes = props.extractIndexes ?? null;
|
|
2301
|
-
this.syncIndexColumns();
|
|
2302
|
-
} else this.extractIndexes = null;
|
|
2303
|
-
const cols = [
|
|
2304
|
-
"ts",
|
|
2305
|
-
"type",
|
|
2306
|
-
"event",
|
|
2307
|
-
...this.indexes
|
|
2308
|
-
];
|
|
2309
|
-
const placeholders = cols.map(() => "?").join(", ");
|
|
2310
|
-
this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
|
|
2311
|
-
const colsWithSeq = ["seq", ...cols];
|
|
2312
|
-
const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
|
|
2313
|
-
this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
|
|
2314
|
-
this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
|
|
2315
|
-
this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
|
|
2316
|
-
this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
|
|
2317
|
-
this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
|
|
2318
|
-
this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
|
|
2319
|
-
}
|
|
2320
|
-
insert(input) {
|
|
2321
|
-
try {
|
|
2322
|
-
const params = this.buildInsertParams(input.ts, input.event);
|
|
2323
|
-
const result = this.insertStmt.run(...params);
|
|
2324
|
-
const seq = Number(result.lastInsertRowid);
|
|
2325
|
-
this.trim();
|
|
2326
|
-
return {
|
|
2327
|
-
seq,
|
|
2328
|
-
ts: input.ts,
|
|
2329
|
-
event: input.event
|
|
2330
|
-
};
|
|
2331
|
-
} catch (e) {
|
|
2332
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
2335
|
-
insertMany(inputs) {
|
|
2336
|
-
if (inputs.length === 0) return [];
|
|
2337
|
-
try {
|
|
2338
|
-
const records = [];
|
|
2339
|
-
this.db.transaction((batch) => {
|
|
2340
|
-
for (const input of batch) {
|
|
2341
|
-
const params = this.buildInsertParams(input.ts, input.event);
|
|
2342
|
-
const result = this.insertStmt.run(...params);
|
|
2343
|
-
records.push({
|
|
2344
|
-
seq: Number(result.lastInsertRowid),
|
|
2345
|
-
ts: input.ts,
|
|
2346
|
-
event: input.event
|
|
2347
|
-
});
|
|
2348
|
-
}
|
|
2349
|
-
})(inputs);
|
|
2350
|
-
this.trim();
|
|
2351
|
-
return records;
|
|
2352
|
-
} catch (e) {
|
|
2353
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
2354
|
-
}
|
|
2355
|
-
}
|
|
2356
|
-
write(record) {
|
|
2357
|
-
try {
|
|
2358
|
-
const params = [record.seq, ...this.buildInsertParams(record.ts, record.event)];
|
|
2359
|
-
this.insertWithSeqStmt.run(...params);
|
|
2360
|
-
this.trim();
|
|
2361
|
-
} catch (e) {
|
|
2362
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
getMaxSeq() {
|
|
2366
|
-
const row = this.maxSeqStmt.get();
|
|
2367
|
-
return row ? row.max : 0;
|
|
2368
|
-
}
|
|
2369
|
-
getRecords(props = {}) {
|
|
2370
|
-
const conditions = ["seq > ?"];
|
|
2371
|
-
const params = [props.sinceSeq ?? 0];
|
|
2372
|
-
if (typeof props.type === "string") {
|
|
2373
|
-
conditions.push("type = ?");
|
|
2374
|
-
params.push(props.type);
|
|
2375
|
-
}
|
|
2376
|
-
if (props.where) this.appendWhereConditions(props.where, conditions, params);
|
|
2377
|
-
const limit = props.limit ?? 1e3;
|
|
2378
|
-
params.push(limit);
|
|
2379
|
-
const dir = props.order === "desc" ? "DESC" : "ASC";
|
|
2380
|
-
const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
|
|
2381
|
-
const rows = this.db.prepare(sql).all(...params);
|
|
2382
|
-
if (dir === "DESC") rows.reverse();
|
|
2383
|
-
return rows.map(toRecord);
|
|
2384
|
-
}
|
|
2385
|
-
/**
|
|
2386
|
-
* Current schema version. Useful for diagnostics and for tests that want
|
|
2387
|
-
* to verify migrations ran. Reads `PRAGMA user_version` once per call.
|
|
2388
|
-
*/
|
|
2389
|
-
getSchemaVersion() {
|
|
2390
|
-
return this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
|
|
2391
|
-
}
|
|
2392
|
-
close() {
|
|
2393
|
-
this.db.close();
|
|
2394
|
-
}
|
|
2395
|
-
buildInsertParams(ts, event) {
|
|
2396
|
-
const type = extractType(event);
|
|
2397
|
-
const json = JSON.stringify(event);
|
|
2398
|
-
if (this.indexes.length === 0) return [
|
|
2399
|
-
ts,
|
|
2400
|
-
type,
|
|
2401
|
-
json
|
|
2402
|
-
];
|
|
2403
|
-
const values = this.extractIndexes ? this.extractIndexes(event) : null;
|
|
2404
|
-
return [
|
|
2405
|
-
ts,
|
|
2406
|
-
type,
|
|
2407
|
-
json,
|
|
2408
|
-
...this.indexes.map((col) => values?.[col] ?? null)
|
|
2409
|
-
];
|
|
2410
|
-
}
|
|
2411
|
-
appendWhereConditions(where, conditions, params) {
|
|
2412
|
-
const widened = where;
|
|
2413
|
-
for (const col of this.indexes) {
|
|
2414
|
-
const value = widened[col];
|
|
2415
|
-
if (value === void 0) continue;
|
|
2416
|
-
if (value === null) conditions.push(`${col} IS NULL`);
|
|
2417
|
-
else {
|
|
2418
|
-
conditions.push(`${col} = ?`);
|
|
2419
|
-
params.push(value);
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
}
|
|
2423
|
-
trim() {
|
|
2424
|
-
if (this.maxRows !== null) {
|
|
2425
|
-
const row = this.countStmt.get();
|
|
2426
|
-
if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
|
|
2427
|
-
}
|
|
2428
|
-
if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
|
|
2429
|
-
this.maybeTrimBytes();
|
|
2430
|
-
}
|
|
2431
|
-
/**
|
|
2432
|
-
* Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
|
|
2433
|
-
* we measure the file; on overflow we estimate how many of the oldest rows to
|
|
2434
|
-
* drop to land near targetBytes (by the byte/row ratio), delete them in one
|
|
2435
|
-
* statement, then VACUUM once to return the freed pages to the filesystem (a
|
|
2436
|
-
* plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
|
|
2437
|
-
* overflow keeps the expensive rewrite rare — the file must refill the whole
|
|
2438
|
-
* maxBytes→targetBytes delta before the next overflow can trigger.
|
|
2439
|
-
*/
|
|
2440
|
-
maybeTrimBytes() {
|
|
2441
|
-
if (this.maxBytes === null || this.targetBytes === null) return;
|
|
2442
|
-
this.insertsSinceByteCheck += 1;
|
|
2443
|
-
if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
|
|
2444
|
-
this.insertsSinceByteCheck = 0;
|
|
2445
|
-
const bytes = this.byteSize();
|
|
2446
|
-
if (bytes <= this.maxBytes) return;
|
|
2447
|
-
const rows = this.countStmt.get()?.n ?? 0;
|
|
2448
|
-
if (rows === 0) return;
|
|
2449
|
-
const bytesToFree = bytes - this.targetBytes;
|
|
2450
|
-
const bytesPerRow = bytes / rows;
|
|
2451
|
-
const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
|
|
2452
|
-
this.trimOldestStmt.run(rowsToDrop);
|
|
2453
|
-
this.db.run("VACUUM");
|
|
2454
|
-
}
|
|
2455
|
-
byteSize() {
|
|
2456
|
-
return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
|
|
2457
|
-
}
|
|
2458
|
-
/** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
|
|
2459
|
-
clear() {
|
|
2460
|
-
this.db.run("DELETE FROM leuco_log");
|
|
2461
|
-
this.db.run("VACUUM");
|
|
2462
|
-
this.insertsSinceByteCheck = 0;
|
|
2463
|
-
}
|
|
2464
|
-
syncIndexColumns() {
|
|
2465
|
-
const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
|
|
2466
|
-
for (const col of this.indexes) {
|
|
2467
|
-
if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
|
|
2468
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
migrate() {
|
|
2472
|
-
const current = this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
|
|
2473
|
-
if (current >= MIGRATIONS.length) return;
|
|
2474
|
-
const pending = MIGRATIONS.slice(current);
|
|
2475
|
-
let version = current;
|
|
2476
|
-
for (const stmts of pending) {
|
|
2477
|
-
version += 1;
|
|
2478
|
-
this.db.transaction(() => {
|
|
2479
|
-
for (const stmt of stmts) this.db.run(stmt);
|
|
2480
|
-
this.db.run(`PRAGMA user_version = ${version}`);
|
|
2481
|
-
})();
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
};
|
|
2485
|
-
function validateIndexNames(names) {
|
|
2486
|
-
for (const name of names) {
|
|
2487
|
-
if (!COLUMN_NAME_RE.test(name)) throw new Error(`invalid index column name: ${name}`);
|
|
2488
|
-
if (RESERVED_COLUMNS.has(name)) throw new Error(`reserved index column name: ${name}`);
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
function extractType(event) {
|
|
2492
|
-
if (typeof event !== "object" || event === null) return null;
|
|
2493
|
-
if (!("type" in event)) return null;
|
|
2494
|
-
const t = event.type;
|
|
2495
|
-
return typeof t === "string" ? t : null;
|
|
2496
|
-
}
|
|
2497
|
-
function toRecord(row) {
|
|
2498
|
-
return {
|
|
2499
|
-
seq: row.seq,
|
|
2500
|
-
ts: row.ts,
|
|
2501
|
-
event: JSON.parse(row.event)
|
|
2502
|
-
};
|
|
2503
|
-
}
|
|
2504
|
-
//#endregion
|
|
2505
|
-
//#region lib/gateway/sqlite-funnel-event-log.ts
|
|
2506
|
-
const MAX_CONTENT_CHARS = 2e3;
|
|
2507
|
-
/**
|
|
2508
|
-
* SQLite-backed `FunnelEventLog`. One indexed table holds every broadcaster
|
|
2509
|
-
* event with `channel_id` and `connector_id` as dedicated columns, so
|
|
2510
|
-
* per-channel and per-connector replay is an indexed range scan.
|
|
2511
|
-
*
|
|
2512
|
-
* Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
|
|
2513
|
-
* atomically. The broadcaster owns its own offset counter at runtime
|
|
2514
|
-
* (seeded from `findMaxOffset()` at startup); each broadcaster event
|
|
2515
|
-
* flows in here via `record()` with that pre-assigned offset, which the
|
|
2516
|
-
* sink stores via `write()` — PK uniqueness catches double-emit bugs.
|
|
2517
|
-
*
|
|
2518
|
-
* System events (gateway lifecycle, channel connect/disconnect, etc.) do
|
|
2519
|
-
* NOT go through this store. They are diagnostic only and live in
|
|
2520
|
-
* `FunnelLogger`'s file so the seq space here stays exclusive to
|
|
2521
|
-
* broadcaster traffic. This is what makes the broadcaster's seq seeding
|
|
2522
|
-
* (`getMaxSeq()` at startup) correct without per-event coordination.
|
|
2523
|
-
*/
|
|
2524
|
-
var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
2525
|
-
sink;
|
|
2526
|
-
now;
|
|
2527
|
-
constructor(props) {
|
|
2528
|
-
super();
|
|
2529
|
-
this.now = props.now ?? (() => Date.now());
|
|
2530
|
-
this.sink = new LeucoLoggerSqliteSink({
|
|
2531
|
-
path: props.path,
|
|
2532
|
-
indexes: ["channel_id", "connector_id"],
|
|
2533
|
-
extractIndexes: (event) => ({
|
|
2534
|
-
channel_id: event.channel_id,
|
|
2535
|
-
connector_id: event.connector_id
|
|
2536
|
-
}),
|
|
2537
|
-
now: this.now,
|
|
2538
|
-
...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {},
|
|
2539
|
-
...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {},
|
|
2540
|
-
...props.maxBytes !== void 0 ? { maxBytes: props.maxBytes } : {},
|
|
2541
|
-
...props.targetBytes !== void 0 ? { targetBytes: props.targetBytes } : {}
|
|
2542
|
-
});
|
|
2543
|
-
}
|
|
2544
|
-
/**
|
|
2545
|
-
* Persist a broadcaster-driven event with its assigned offset. Caller
|
|
2546
|
-
* (the gateway-server) supplies the offset from `broadcaster.broadcast()`
|
|
2547
|
-
* so this store and the broadcaster's in-memory ring stay aligned.
|
|
2548
|
-
*/
|
|
2549
|
-
record(record) {
|
|
2550
|
-
const event = {
|
|
2551
|
-
type: record.meta?.event_type ?? "unknown",
|
|
2552
|
-
content: truncate$1(record.content),
|
|
2553
|
-
channel_id: record.channelId,
|
|
2554
|
-
connector_id: record.connectorId,
|
|
2555
|
-
meta: record.meta
|
|
2556
|
-
};
|
|
2557
|
-
this.sink.write({
|
|
2558
|
-
seq: record.offset,
|
|
2559
|
-
ts: this.now(),
|
|
2560
|
-
event
|
|
2561
|
-
});
|
|
2562
|
-
}
|
|
2563
|
-
/**
|
|
2564
|
-
* Returns events with offset > since. Filtering by channel/connector is
|
|
2565
|
-
* the broadcaster's responsibility (it knows the client's subscription),
|
|
2566
|
-
* so this returns the full slice and lets the caller filter.
|
|
2567
|
-
*/
|
|
2568
|
-
loadSince(since) {
|
|
2569
|
-
const records = this.sink.getRecords({ sinceSeq: since });
|
|
2570
|
-
const out = [];
|
|
2571
|
-
for (const record of records) out.push({
|
|
2572
|
-
content: record.event.content,
|
|
2573
|
-
meta: record.event.meta ?? void 0,
|
|
2574
|
-
offset: record.seq
|
|
2575
|
-
});
|
|
2576
|
-
return out;
|
|
2577
|
-
}
|
|
2578
|
-
/**
|
|
2579
|
-
* Returns events for one channel (and optionally one connector). Used
|
|
2580
|
-
* by the gateway logs CLI for scoped queries. Channel/connector filters
|
|
2581
|
-
* are indexed columns, so this is an indexed range scan.
|
|
2582
|
-
*/
|
|
2583
|
-
loadForChannel(props) {
|
|
2584
|
-
const where = { channel_id: props.channelId };
|
|
2585
|
-
if (props.connectorId !== void 0) where.connector_id = props.connectorId;
|
|
2586
|
-
const records = this.sink.getRecords({
|
|
2587
|
-
where,
|
|
2588
|
-
...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
|
|
2589
|
-
...props.limit !== void 0 ? { limit: props.limit } : {}
|
|
2590
|
-
});
|
|
2591
|
-
const out = [];
|
|
2592
|
-
for (const record of records) out.push({
|
|
2593
|
-
content: record.event.content,
|
|
2594
|
-
meta: record.event.meta ?? void 0,
|
|
2595
|
-
offset: record.seq
|
|
2596
|
-
});
|
|
2597
|
-
return out;
|
|
2598
|
-
}
|
|
2599
|
-
findMaxOffset() {
|
|
2600
|
-
return this.sink.getMaxSeq();
|
|
2601
|
-
}
|
|
2602
|
-
clear() {
|
|
2603
|
-
this.sink.clear();
|
|
2604
|
-
}
|
|
2605
|
-
close() {
|
|
2606
|
-
this.sink.close();
|
|
2607
|
-
}
|
|
2608
|
-
};
|
|
2609
|
-
function truncate$1(content) {
|
|
2610
|
-
if (content.length <= MAX_CONTENT_CHARS) return content;
|
|
2611
|
-
return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
|
|
2612
|
-
}
|
|
2613
|
-
//#endregion
|
|
2614
|
-
//#region lib/gateway/listener-supervisor.ts
|
|
2615
|
-
const defaultOnError$1 = () => {};
|
|
2616
|
-
const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
|
|
2617
|
-
const DEFAULT_MAX_BACKOFF_MS = 6e4;
|
|
2618
|
-
const defaultSleep = (ms) => new Promise((r) => {
|
|
2619
|
-
setTimeout(r, ms);
|
|
2620
|
-
});
|
|
2621
|
-
/**
|
|
2622
|
-
* Owns the running listener instances and their lifecycle.
|
|
2623
|
-
*
|
|
2624
|
-
* Lives in the gateway process and is the only place that calls
|
|
2625
|
-
* `listener.start()` / `listener.stop()`. Each entry is keyed by
|
|
2626
|
-
* `${channelName}/${connectorName}` so the same connector name can exist in
|
|
2627
|
-
* multiple channels without colliding.
|
|
2628
|
-
*
|
|
2629
|
-
* Periodically polls each running listener's `isAlive()` and auto-restarts
|
|
2630
|
-
* dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
|
|
2631
|
-
* the backoff counter on successful restart.
|
|
2632
|
-
*/
|
|
2633
|
-
var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
2634
|
-
channels;
|
|
2635
|
-
notify;
|
|
2636
|
-
logger;
|
|
2637
|
-
onError;
|
|
2638
|
-
running = /* @__PURE__ */ new Map();
|
|
2639
|
-
failureCounts = /* @__PURE__ */ new Map();
|
|
2640
|
-
stats = /* @__PURE__ */ new Map();
|
|
2641
|
-
healthCheckIntervalMs;
|
|
2642
|
-
maxBackoffMs;
|
|
2643
|
-
sleep;
|
|
2644
|
-
now;
|
|
2645
|
-
healthCheckTimer = null;
|
|
2646
|
-
healthCheckInFlight = false;
|
|
2647
|
-
constructor(deps) {
|
|
2648
|
-
this.channels = deps.channels;
|
|
2649
|
-
this.notify = deps.notify;
|
|
2650
|
-
this.logger = deps.logger;
|
|
2651
|
-
this.onError = deps.onError ?? defaultOnError$1;
|
|
2652
|
-
this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
|
|
2653
|
-
this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
2654
|
-
this.sleep = deps.sleep ?? defaultSleep;
|
|
2655
|
-
this.now = deps.now ?? (() => Date.now());
|
|
2656
|
-
}
|
|
2657
|
-
static keyOf(channelName, connectorName) {
|
|
2658
|
-
return `${channelName}/${connectorName}`;
|
|
2659
|
-
}
|
|
2660
|
-
isRunning(channelName, connectorName) {
|
|
2661
|
-
return this.running.has(FunnelListenerSupervisor.keyOf(channelName, connectorName));
|
|
2662
|
-
}
|
|
2663
|
-
list() {
|
|
2664
|
-
return [...this.running.entries()].map(([key, entry]) => {
|
|
2665
|
-
const stats = this.stats.get(key);
|
|
2666
|
-
return {
|
|
2667
|
-
channelName: entry.channelName,
|
|
2668
|
-
channelId: entry.channelId,
|
|
2669
|
-
name: entry.config.name,
|
|
2670
|
-
type: entry.config.type,
|
|
2671
|
-
alive: entry.listener.isAlive(),
|
|
2672
|
-
events: stats?.events ?? 0,
|
|
2673
|
-
errors: stats?.errors ?? 0,
|
|
2674
|
-
failureCount: this.failureCounts.get(key) ?? 0,
|
|
2675
|
-
lastEventAt: stats?.lastEventAt ?? null
|
|
2676
|
-
};
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
async start(channelName, connectorName) {
|
|
2680
|
-
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
|
|
2681
|
-
if (this.running.has(key)) return {
|
|
2682
|
-
ok: true,
|
|
2683
|
-
reason: "already running"
|
|
2684
|
-
};
|
|
2685
|
-
const created = this.channels.createListener(channelName, connectorName);
|
|
2686
|
-
if (!created) return {
|
|
2687
|
-
ok: false,
|
|
2688
|
-
reason: `connector "${connectorName}" not found in channel "${channelName}"`
|
|
2689
|
-
};
|
|
2690
|
-
const bind = async (content, meta) => {
|
|
2691
|
-
try {
|
|
2692
|
-
await this.notify(channelName, connectorName, content, meta);
|
|
2693
|
-
this.recordEvent(key);
|
|
2694
|
-
} catch (error) {
|
|
2695
|
-
this.recordError(key);
|
|
2696
|
-
throw error;
|
|
2697
|
-
}
|
|
2698
|
-
};
|
|
2699
|
-
try {
|
|
2700
|
-
await created.listener.start(bind);
|
|
2701
|
-
this.running.set(key, {
|
|
2702
|
-
config: created.config,
|
|
2703
|
-
channelName,
|
|
2704
|
-
channelId: created.channelId,
|
|
2705
|
-
listener: created.listener
|
|
2706
|
-
});
|
|
2707
|
-
this.ensureStats(key);
|
|
2708
|
-
this.logger?.info(`${created.config.type} listener started`, {
|
|
2709
|
-
channel: channelName,
|
|
2710
|
-
connector: connectorName
|
|
2711
|
-
});
|
|
2712
|
-
return { ok: true };
|
|
2713
|
-
} catch (error) {
|
|
2714
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2715
|
-
this.logger?.error(`${created.config.type} listener failed to start`, {
|
|
2716
|
-
channel: channelName,
|
|
2717
|
-
connector: connectorName,
|
|
2718
|
-
error: err.message
|
|
2719
|
-
});
|
|
2720
|
-
this.onError(err, {
|
|
2721
|
-
component: "listener-supervisor.start",
|
|
2722
|
-
channel: channelName,
|
|
2723
|
-
connector: connectorName,
|
|
2724
|
-
type: created.config.type
|
|
2725
|
-
});
|
|
2726
|
-
return {
|
|
2727
|
-
ok: false,
|
|
2728
|
-
reason: err.message
|
|
2729
|
-
};
|
|
2730
|
-
}
|
|
2731
|
-
}
|
|
2732
|
-
async stop(channelName, connectorName) {
|
|
2733
|
-
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
|
|
2734
|
-
const entry = this.running.get(key);
|
|
2735
|
-
if (!entry) return {
|
|
2736
|
-
ok: true,
|
|
2737
|
-
reason: "not running"
|
|
2738
|
-
};
|
|
2739
|
-
try {
|
|
2740
|
-
await entry.listener.stop();
|
|
2741
|
-
this.running.delete(key);
|
|
2742
|
-
this.failureCounts.delete(key);
|
|
2743
|
-
this.logger?.info(`${entry.config.type} listener stopped`, {
|
|
2744
|
-
channel: channelName,
|
|
2745
|
-
connector: connectorName
|
|
2746
|
-
});
|
|
2747
|
-
return { ok: true };
|
|
2748
|
-
} catch (error) {
|
|
2749
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2750
|
-
this.logger?.error(`${entry.config.type} listener failed to stop`, {
|
|
2751
|
-
channel: channelName,
|
|
2752
|
-
connector: connectorName,
|
|
2753
|
-
error: err.message
|
|
2754
|
-
});
|
|
2755
|
-
this.onError(err, {
|
|
2756
|
-
component: "listener-supervisor.stop",
|
|
2757
|
-
channel: channelName,
|
|
2758
|
-
connector: connectorName,
|
|
2759
|
-
type: entry.config.type
|
|
2760
|
-
});
|
|
2761
|
-
return {
|
|
2762
|
-
ok: false,
|
|
2763
|
-
reason: err.message
|
|
2764
|
-
};
|
|
2765
|
-
}
|
|
2766
|
-
}
|
|
2767
|
-
async restart(channelName, connectorName) {
|
|
2768
|
-
const stopped = await this.stop(channelName, connectorName);
|
|
2769
|
-
if (!stopped.ok) return stopped;
|
|
2770
|
-
return await this.start(channelName, connectorName);
|
|
2771
|
-
}
|
|
2772
|
-
async startAll() {
|
|
2773
|
-
const all = this.channels.listAllConnectors();
|
|
2774
|
-
for (const view of all) await this.start(view.channelName, view.name);
|
|
2775
|
-
this.startHealthCheck();
|
|
2776
|
-
}
|
|
2777
|
-
async stopAll() {
|
|
2778
|
-
this.stopHealthCheck();
|
|
2779
|
-
for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
|
|
2780
|
-
}
|
|
2781
|
-
ensureStats(key) {
|
|
2782
|
-
const existing = this.stats.get(key);
|
|
2783
|
-
if (existing) return existing;
|
|
2784
|
-
const fresh = {
|
|
2785
|
-
events: 0,
|
|
2786
|
-
errors: 0,
|
|
2787
|
-
failureCount: 0,
|
|
2788
|
-
lastEventAt: null
|
|
2789
|
-
};
|
|
2790
|
-
this.stats.set(key, fresh);
|
|
2791
|
-
return fresh;
|
|
2792
|
-
}
|
|
2793
|
-
recordEvent(key) {
|
|
2794
|
-
const stats = this.ensureStats(key);
|
|
2795
|
-
stats.events += 1;
|
|
2796
|
-
stats.lastEventAt = new Date(this.now()).toISOString();
|
|
2797
|
-
}
|
|
2798
|
-
recordError(key) {
|
|
2799
|
-
this.ensureStats(key).errors += 1;
|
|
2800
|
-
}
|
|
2801
|
-
startHealthCheck() {
|
|
2802
|
-
if (this.healthCheckTimer) return;
|
|
2803
|
-
this.healthCheckTimer = setInterval(() => {
|
|
2804
|
-
this.runHealthCheck();
|
|
2805
|
-
}, this.healthCheckIntervalMs);
|
|
2806
|
-
this.healthCheckTimer.unref();
|
|
2807
|
-
}
|
|
2808
|
-
stopHealthCheck() {
|
|
2809
|
-
if (!this.healthCheckTimer) return;
|
|
2810
|
-
clearInterval(this.healthCheckTimer);
|
|
2811
|
-
this.healthCheckTimer = null;
|
|
2812
|
-
}
|
|
2813
|
-
async runHealthCheck() {
|
|
2814
|
-
if (this.healthCheckInFlight) return;
|
|
2815
|
-
this.healthCheckInFlight = true;
|
|
2816
|
-
try {
|
|
2817
|
-
for (const [key, entry] of [...this.running.entries()]) {
|
|
2818
|
-
if (entry.listener.isAlive()) {
|
|
2819
|
-
this.failureCounts.delete(key);
|
|
2820
|
-
continue;
|
|
2821
|
-
}
|
|
2822
|
-
await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
|
|
2823
|
-
}
|
|
2824
|
-
} finally {
|
|
2825
|
-
this.healthCheckInFlight = false;
|
|
2826
|
-
}
|
|
2827
|
-
}
|
|
2828
|
-
async recoverDead(channelName, connectorName, type) {
|
|
2829
|
-
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
|
|
2830
|
-
const failureCount = this.failureCounts.get(key) ?? 0;
|
|
2831
|
-
const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
|
|
2832
|
-
this.logger?.warn(`${type} listener unhealthy, restarting`, {
|
|
2833
|
-
channel: channelName,
|
|
2834
|
-
connector: connectorName,
|
|
2835
|
-
attempt: failureCount + 1,
|
|
2836
|
-
backoffMs
|
|
2837
|
-
});
|
|
2838
|
-
await this.stop(channelName, connectorName);
|
|
2839
|
-
await this.sleep(backoffMs);
|
|
2840
|
-
if ((await this.start(channelName, connectorName)).ok) {
|
|
2841
|
-
this.failureCounts.delete(key);
|
|
2842
|
-
this.logger?.info(`${type} listener recovered`, {
|
|
2843
|
-
channel: channelName,
|
|
2844
|
-
connector: connectorName
|
|
2845
|
-
});
|
|
2846
|
-
} else this.failureCounts.set(key, failureCount + 1);
|
|
2847
|
-
}
|
|
2848
|
-
};
|
|
2849
|
-
//#endregion
|
|
2850
|
-
//#region lib/gateway/kill-competing-slack-gateways.ts
|
|
2851
|
-
const defaultProcess = new NodeFunnelProcessRunner();
|
|
2852
|
-
const titleFor = (dir) => `funnel-gateway[${dir}]`;
|
|
2853
|
-
/**
|
|
2854
|
-
* Kills other funnel daemon processes that share the SAME funnel home dir,
|
|
2855
|
-
* which is the only situation that causes a real conflict (duplicate Slack
|
|
2856
|
-
* Socket Mode connections with the same tokens). Daemons rooted at a
|
|
2857
|
-
* different `~/.funnel/` are left alone — they hold different tokens and
|
|
2858
|
-
* speak to different Slack apps. The daemon advertises its dir via the
|
|
2859
|
-
* `funnel-gateway[<dir>]` marker appended to argv (also assigned to
|
|
2860
|
-
* `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
|
|
2861
|
-
* absorbs the POSIX/Windows enumeration difference behind the marker match.
|
|
2862
|
-
*/
|
|
2863
|
-
const killCompetingSlackGateways = async (props) => {
|
|
2864
|
-
const runner = props.process ?? defaultProcess;
|
|
2865
|
-
const logger = props.logger;
|
|
2866
|
-
const expectedTitle = titleFor(props.dir);
|
|
2867
|
-
const snapshots = runner.listProcessesContaining(expectedTitle);
|
|
2868
|
-
const killed = [];
|
|
2869
|
-
for (const snapshot of snapshots) {
|
|
2870
|
-
if (snapshot.pid === props.selfPid) continue;
|
|
2871
|
-
runner.kill(snapshot.pid, "SIGTERM");
|
|
2872
|
-
killed.push(snapshot.pid);
|
|
2873
|
-
logger?.info("killed competing Slack gateway process", {
|
|
2874
|
-
pid: snapshot.pid,
|
|
2875
|
-
args: snapshot.command.slice(0, 160)
|
|
2876
|
-
});
|
|
2877
|
-
}
|
|
2878
|
-
return killed;
|
|
2879
|
-
};
|
|
2880
|
-
//#endregion
|
|
2881
|
-
//#region lib/gateway/routes/validator.ts
|
|
2882
|
-
/**
|
|
2883
|
-
* Path-param validator for gateway routes. On failure it answers with the same
|
|
2884
|
-
* `{ ok: false, reason }` shape the listener routes already use, so
|
|
2885
|
-
* `FunnelListenersClient` can surface the message without special-casing.
|
|
2886
|
-
*/
|
|
2887
|
-
const zParam = (schema) => zValidator("param", schema, (result, c) => {
|
|
2888
|
-
if (result.success) return;
|
|
2889
|
-
const issue = result.error.issues[0];
|
|
2890
|
-
const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid request";
|
|
2891
|
-
return c.json({
|
|
2892
|
-
ok: false,
|
|
2893
|
-
reason
|
|
2894
|
-
}, 400);
|
|
2895
|
-
});
|
|
2896
|
-
//#endregion
|
|
2897
|
-
//#region lib/gateway/routes/channels.connectors.call.ts
|
|
2898
|
-
const bodySchema = z.object({
|
|
2899
|
-
method: z.string().min(1),
|
|
2900
|
-
path: z.string().min(1),
|
|
2901
|
-
body: z.unknown().optional()
|
|
2902
|
-
});
|
|
2903
|
-
/**
|
|
2904
|
-
* POST /channels/:channel/connectors/:connector/call
|
|
2905
|
-
*
|
|
2906
|
-
* Generic adapter call. Used by the funnel MCP server (running in the Claude
|
|
2907
|
-
* Code process) to send replies/reactions/etc. without spawning a CLI
|
|
2908
|
-
* subprocess. Mirrors the CLI's `funnel channels <c> connectors <conn> request
|
|
2909
|
-
* --method=...` but with a structured JSON body and no shell.
|
|
2910
|
-
*/
|
|
2911
|
-
const channelsConnectorsCallHandler = factory$1.createHandlers(zParam(z.object({
|
|
2912
|
-
channel: z.string().min(1),
|
|
2913
|
-
connector: z.string().min(1)
|
|
2914
|
-
})), async (c) => {
|
|
2915
|
-
const param = c.req.valid("param");
|
|
2916
|
-
const raw = await c.req.json().catch(() => null);
|
|
2917
|
-
const parsed = bodySchema.safeParse(raw);
|
|
2918
|
-
if (!parsed.success) throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
2919
|
-
const result = await c.var.deps.channels.call(param.channel, param.connector, {
|
|
2920
|
-
method: parsed.data.method,
|
|
2921
|
-
path: parsed.data.path,
|
|
2922
|
-
body: parsed.data.body ?? {}
|
|
2923
|
-
});
|
|
2924
|
-
return c.json({
|
|
2925
|
-
ok: true,
|
|
2926
|
-
result
|
|
2927
|
-
});
|
|
2928
|
-
});
|
|
2929
|
-
//#endregion
|
|
2930
|
-
//#region lib/gateway/routes/channels.publish.ts
|
|
2931
|
-
/**
|
|
2932
|
-
* POST /channels/:channel/publish
|
|
2933
|
-
*
|
|
2934
|
-
* Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
|
|
2935
|
-
* path: events go through `broadcaster.broadcast` + `eventLog.record`, so
|
|
2936
|
-
* subscribers see them exactly as if a listener had produced them.
|
|
2937
|
-
*
|
|
2938
|
-
* Body validation is Zod-shared with the client (`publishRequestSchema`); the
|
|
2939
|
-
* response (`publishResponseSchema`) carries the assigned offset so callers can
|
|
2940
|
-
* correlate with the persistent event store.
|
|
2941
|
-
*/
|
|
2942
|
-
const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ channel: z.string().min(1) })), zValidator("json", publishRequestSchema, (result, c) => {
|
|
2943
|
-
if (result.success) return;
|
|
2944
|
-
const issue = result.error.issues[0];
|
|
2945
|
-
const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body";
|
|
2946
|
-
return c.json({
|
|
2947
|
-
ok: false,
|
|
2948
|
-
reason
|
|
2949
|
-
}, 400);
|
|
2950
|
-
}), (c) => {
|
|
2951
|
-
const param = c.req.valid("param");
|
|
2952
|
-
const body = c.req.valid("json");
|
|
2953
|
-
const meta = body.target ? {
|
|
2954
|
-
...body.meta,
|
|
2955
|
-
target: body.target
|
|
2956
|
-
} : body.meta;
|
|
2957
|
-
const response = {
|
|
2958
|
-
ok: true,
|
|
2959
|
-
offset: c.var.deps.emit({
|
|
2960
|
-
channel: param.channel,
|
|
2961
|
-
connector: body.connector,
|
|
2962
|
-
content: body.content,
|
|
2963
|
-
meta
|
|
2964
|
-
}).offset
|
|
2965
|
-
};
|
|
2966
|
-
return c.json(response);
|
|
2967
|
-
});
|
|
2968
|
-
//#endregion
|
|
2969
|
-
//#region lib/gateway/connector-diagnostic-sql-reader.ts
|
|
2970
|
-
/**
|
|
2971
|
-
* Read-only SQL surface over the three diagnostic tables, for Claude to query
|
|
2972
|
-
* the log with arbitrary `SELECT`s. It opens all files read-only and exposes
|
|
2973
|
-
* three views — `raw`, `processed`, `connection` — that hide the storage
|
|
2974
|
-
* details (the physical table is `leuco_log` and each row's columns live
|
|
2975
|
-
* inside a JSON `event` blob): the views surface the columns as plain fields,
|
|
2976
|
-
* with `payload` already pulled out of the nested JSON.
|
|
2977
|
-
*
|
|
2978
|
-
* The tables are separate files. `raw` and `processed` share an `event_id`,
|
|
2979
|
-
* so a `JOIN` answers "the event arrived, but what verdict did it get?";
|
|
2980
|
-
* `connection` answers the other half — "did the listener ever connect at
|
|
2981
|
-
* all?". Writes are impossible: the connection is read-only and `query`
|
|
2982
|
-
* rejects anything but a single `SELECT`.
|
|
2983
|
-
*/
|
|
2984
|
-
var ConnectorDiagnosticSqlReader = class {
|
|
2985
|
-
db;
|
|
2986
|
-
constructor(props) {
|
|
2987
|
-
const db = new Database(props.rawPath, { readonly: true });
|
|
2988
|
-
try {
|
|
2989
|
-
db.run("PRAGMA busy_timeout = 500");
|
|
2990
|
-
db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
|
|
2991
|
-
db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
|
|
2992
|
-
db.run(rawViewSql);
|
|
2993
|
-
db.run(processedViewSql);
|
|
2994
|
-
db.run(connectionViewSql);
|
|
2995
|
-
} catch (error) {
|
|
2996
|
-
db.close();
|
|
2997
|
-
throw error;
|
|
2998
|
-
}
|
|
2999
|
-
this.db = db;
|
|
3000
|
-
Object.freeze(this);
|
|
3001
|
-
}
|
|
3002
|
-
/**
|
|
3003
|
-
* Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
|
|
3004
|
-
* than throwing) for a non-SELECT statement or a SQL error, so the caller
|
|
3005
|
-
* can surface the message without a stack trace.
|
|
3006
|
-
*/
|
|
3007
|
-
query(sql, params = []) {
|
|
3008
|
-
const trimmed = sql.trim().replace(/;$/, "").trim();
|
|
3009
|
-
if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
|
|
3010
|
-
if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
|
|
3011
|
-
try {
|
|
3012
|
-
return this.db.prepare(trimmed).all(...params);
|
|
3013
|
-
} catch (error) {
|
|
3014
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
3015
|
-
}
|
|
3016
|
-
}
|
|
3017
|
-
close() {
|
|
3018
|
-
this.db.close();
|
|
3019
|
-
}
|
|
3020
|
-
};
|
|
3021
|
-
const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
|
|
3022
|
-
seq,
|
|
3023
|
-
ts,
|
|
3024
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
3025
|
-
json_extract(event, '$.type') AS type,
|
|
3026
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
3027
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
3028
|
-
json_extract(event, '$.payload') AS payload
|
|
3029
|
-
FROM main.leuco_log`;
|
|
3030
|
-
const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
|
|
3031
|
-
seq,
|
|
3032
|
-
ts,
|
|
3033
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
3034
|
-
json_extract(event, '$.type') AS type,
|
|
3035
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
3036
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
3037
|
-
json_extract(event, '$.outcome') AS outcome,
|
|
3038
|
-
json_extract(event, '$.payload') AS payload
|
|
3039
|
-
FROM processeddb.leuco_log`;
|
|
3040
|
-
const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
|
|
3041
|
-
seq,
|
|
3042
|
-
ts,
|
|
3043
|
-
json_extract(event, '$.type') AS type,
|
|
3044
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
3045
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
3046
|
-
json_extract(event, '$.status') AS status,
|
|
3047
|
-
json_extract(event, '$.detail') AS detail
|
|
3048
|
-
FROM connectiondb.leuco_log`;
|
|
3049
|
-
//#endregion
|
|
3050
|
-
//#region lib/gateway/routes/debug.ts
|
|
3051
|
-
const extractPreview = (payload) => {
|
|
3052
|
-
if (typeof payload !== "string" || payload.length === 0) return null;
|
|
3053
|
-
try {
|
|
3054
|
-
const parsed = JSON.parse(payload);
|
|
3055
|
-
if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
|
|
3056
|
-
const text = String(parsed.text);
|
|
3057
|
-
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
3058
|
-
}
|
|
3059
|
-
} catch {
|
|
3060
|
-
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3061
|
-
}
|
|
3062
|
-
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3063
|
-
};
|
|
3064
|
-
const buildChannelDiagnosis = (channel) => {
|
|
3065
|
-
const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
|
|
3066
|
-
if (channel.connectors.length === 0) return {
|
|
3067
|
-
status: "warn",
|
|
3068
|
-
message: "no connectors configured on this channel",
|
|
3069
|
-
nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
|
|
3070
|
-
rootCause: null
|
|
3071
|
-
};
|
|
3072
|
-
if (!channel.listener) return {
|
|
3073
|
-
status: "error",
|
|
3074
|
-
message: "no listener running for this channel",
|
|
3075
|
-
nextActions: ["fnl gateway restart"],
|
|
3076
|
-
rootCause
|
|
3077
|
-
};
|
|
3078
|
-
if (!channel.listener.alive) return {
|
|
3079
|
-
status: "error",
|
|
3080
|
-
message: "listener is dead",
|
|
3081
|
-
nextActions: ["fnl gateway logs", "fnl gateway restart"],
|
|
3082
|
-
rootCause
|
|
3083
|
-
};
|
|
3084
|
-
if (channel.claudeClients === 0) return {
|
|
3085
|
-
status: "warn",
|
|
3086
|
-
message: "no Claude connected to this channel",
|
|
3087
|
-
nextActions: [`fnl claude --channel ${channel.name}`],
|
|
3088
|
-
rootCause: null
|
|
3089
|
-
};
|
|
3090
|
-
if (channel.listener.errors > 0) return {
|
|
3091
|
-
status: "warn",
|
|
3092
|
-
message: "listener has errors",
|
|
3093
|
-
nextActions: ["fnl gateway logs"],
|
|
3094
|
-
rootCause
|
|
3095
|
-
};
|
|
3096
|
-
return {
|
|
3097
|
-
status: "ok",
|
|
3098
|
-
message: "healthy",
|
|
3099
|
-
nextActions: [],
|
|
3100
|
-
rootCause: null
|
|
3101
|
-
};
|
|
3102
|
-
};
|
|
3103
|
-
/** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
|
|
3104
|
-
const debugHandler$1 = factory$1.createHandlers(async (c) => {
|
|
3105
|
-
const deps = c.var.deps;
|
|
3106
|
-
const channelFilter = c.req.query("channel") ?? null;
|
|
3107
|
-
const allChannels = deps.channels.list();
|
|
3108
|
-
const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
|
|
3109
|
-
const gatewayListeners = deps.supervisor.list();
|
|
3110
|
-
const gatewayClients = deps.broadcaster.listChannels();
|
|
3111
|
-
const metrics = deps.broadcaster.getMetrics();
|
|
3112
|
-
const tmpDir = funnelTmpDir();
|
|
3113
|
-
const rawPath = join(tmpDir, "connector-raw.db");
|
|
3114
|
-
const processedPath = join(tmpDir, "connector-processed.db");
|
|
3115
|
-
const connectionPath = join(tmpDir, "connector-connection.db");
|
|
3116
|
-
const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
|
|
3117
|
-
const channels = targetChannels.map((ch) => {
|
|
3118
|
-
const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
|
|
3119
|
-
const listener = listenerEntry ? {
|
|
3120
|
-
alive: listenerEntry.alive,
|
|
3121
|
-
events: listenerEntry.events,
|
|
3122
|
-
errors: listenerEntry.errors,
|
|
3123
|
-
lastEventAt: listenerEntry.lastEventAt
|
|
3124
|
-
} : null;
|
|
3125
|
-
const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
|
|
3126
|
-
const recentEvents = [];
|
|
3127
|
-
const connectionErrors = [];
|
|
3128
|
-
if (hasStore) {
|
|
3129
|
-
const reader = new ConnectorDiagnosticSqlReader({
|
|
3130
|
-
rawPath,
|
|
3131
|
-
processedPath,
|
|
3132
|
-
connectionPath
|
|
3133
|
-
});
|
|
3134
|
-
const rows = (() => {
|
|
3135
|
-
try {
|
|
3136
|
-
return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
|
|
3137
|
-
} finally {
|
|
3138
|
-
reader.close();
|
|
3139
|
-
}
|
|
3140
|
-
})();
|
|
3141
|
-
if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
|
|
3142
|
-
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
3143
|
-
let payloadParsed = null;
|
|
3144
|
-
if (rawPayload) try {
|
|
3145
|
-
const parsed = JSON.parse(rawPayload);
|
|
3146
|
-
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
3147
|
-
} catch {
|
|
3148
|
-
payloadParsed = null;
|
|
3149
|
-
}
|
|
3150
|
-
recentEvents.push({
|
|
3151
|
-
seq: typeof row.seq === "number" ? row.seq : null,
|
|
3152
|
-
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3153
|
-
type: typeof row.type === "string" ? row.type : "?",
|
|
3154
|
-
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
3155
|
-
payload: rawPayload,
|
|
3156
|
-
payloadParsed,
|
|
3157
|
-
preview: extractPreview(row.payload)
|
|
3158
|
-
});
|
|
3159
|
-
}
|
|
3160
|
-
if (listener && (!listener.alive || listener.errors > 0) || !listener) {
|
|
3161
|
-
const errReader = new ConnectorDiagnosticSqlReader({
|
|
3162
|
-
rawPath,
|
|
3163
|
-
processedPath,
|
|
3164
|
-
connectionPath
|
|
3165
|
-
});
|
|
3166
|
-
const errRows = (() => {
|
|
3167
|
-
try {
|
|
3168
|
-
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]);
|
|
3169
|
-
} finally {
|
|
3170
|
-
errReader.close();
|
|
3171
|
-
}
|
|
3172
|
-
})();
|
|
3173
|
-
if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
|
|
3174
|
-
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3175
|
-
type: typeof row.type === "string" ? row.type : "?",
|
|
3176
|
-
status: typeof row.status === "string" ? row.status : "?",
|
|
3177
|
-
detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
|
|
3178
|
-
});
|
|
3179
|
-
}
|
|
3180
|
-
}
|
|
3181
|
-
const base = {
|
|
3182
|
-
id: ch.id,
|
|
3183
|
-
name: ch.name,
|
|
3184
|
-
connectors: ch.connectors.map((conn) => conn.name),
|
|
3185
|
-
listener,
|
|
3186
|
-
claudeClients,
|
|
3187
|
-
recentEvents,
|
|
3188
|
-
connectionErrors
|
|
3189
|
-
};
|
|
3190
|
-
return {
|
|
3191
|
-
...base,
|
|
3192
|
-
diagnosis: buildChannelDiagnosis(base)
|
|
3193
|
-
};
|
|
3194
|
-
});
|
|
3195
|
-
return c.json({
|
|
3196
|
-
pid: deps.selfPid,
|
|
3197
|
-
uptimeMs: deps.uptimeMs(),
|
|
3198
|
-
eventsBroadcast: metrics.eventsBroadcast,
|
|
3199
|
-
channels
|
|
3200
|
-
});
|
|
3201
|
-
});
|
|
3202
|
-
//#endregion
|
|
3203
|
-
//#region lib/gateway/routes/health.ts
|
|
3204
|
-
/** GET /health — liveness + listener registry snapshot. */
|
|
3205
|
-
const healthHandler = factory$1.createHandlers((c) => {
|
|
3206
|
-
const deps = c.var.deps;
|
|
3207
|
-
return c.json({
|
|
3208
|
-
ok: true,
|
|
3209
|
-
pid: deps.selfPid,
|
|
3210
|
-
clients: deps.broadcaster.getClientCount(),
|
|
3211
|
-
listeners: deps.supervisor.list()
|
|
3212
|
-
});
|
|
3213
|
-
});
|
|
3214
|
-
//#endregion
|
|
3215
|
-
//#region lib/gateway/routes/listeners.list.ts
|
|
3216
|
-
/** GET /listeners — running connector listeners with alive/dead status. */
|
|
3217
|
-
const listenersListHandler = factory$1.createHandlers((c) => {
|
|
3218
|
-
return c.json({ listeners: c.var.deps.supervisor.list() });
|
|
3219
|
-
});
|
|
3220
|
-
//#endregion
|
|
3221
|
-
//#region lib/gateway/routes/listeners.restart.ts
|
|
3222
|
-
/** POST /listeners/:channel/:connector/restart — stop + start a connector listener. */
|
|
3223
|
-
const listenersRestartHandler = factory$1.createHandlers(zParam(z.object({
|
|
3224
|
-
channel: z.string().min(1),
|
|
3225
|
-
connector: z.string().min(1)
|
|
3226
|
-
})), async (c) => {
|
|
3227
|
-
const param = c.req.valid("param");
|
|
3228
|
-
const result = await c.var.deps.supervisor.restart(param.channel, param.connector);
|
|
3229
|
-
return c.json(result, result.ok ? 200 : 400);
|
|
3230
|
-
});
|
|
3231
|
-
//#endregion
|
|
3232
|
-
//#region lib/gateway/routes/listeners.start.ts
|
|
3233
|
-
/** POST /listeners/:channel/:connector/start — start a connector listener. */
|
|
3234
|
-
const listenersStartHandler = factory$1.createHandlers(zParam(z.object({
|
|
3235
|
-
channel: z.string().min(1),
|
|
3236
|
-
connector: z.string().min(1)
|
|
3237
|
-
})), async (c) => {
|
|
3238
|
-
const param = c.req.valid("param");
|
|
3239
|
-
const result = await c.var.deps.supervisor.start(param.channel, param.connector);
|
|
3240
|
-
return c.json(result, result.ok ? 200 : 400);
|
|
3241
|
-
});
|
|
3242
|
-
//#endregion
|
|
3243
|
-
//#region lib/gateway/routes/listeners.stop.ts
|
|
3244
|
-
/** DELETE /listeners/:channel/:connector — stop a connector listener. */
|
|
3245
|
-
const listenersStopHandler = factory$1.createHandlers(zParam(z.object({
|
|
3246
|
-
channel: z.string().min(1),
|
|
3247
|
-
connector: z.string().min(1)
|
|
3248
|
-
})), async (c) => {
|
|
3249
|
-
const param = c.req.valid("param");
|
|
3250
|
-
const result = await c.var.deps.supervisor.stop(param.channel, param.connector);
|
|
3251
|
-
return c.json(result, result.ok ? 200 : 400);
|
|
3252
|
-
});
|
|
3253
|
-
//#endregion
|
|
3254
|
-
//#region lib/gateway/routes/status.ts
|
|
3255
|
-
/** GET /status — listener registry, connected channels, and broadcaster metrics. */
|
|
3256
|
-
const statusHandler$1 = factory$1.createHandlers((c) => {
|
|
3257
|
-
const deps = c.var.deps;
|
|
3258
|
-
return c.json({
|
|
3259
|
-
ok: true,
|
|
3260
|
-
pid: deps.selfPid,
|
|
3261
|
-
uptimeMs: deps.uptimeMs(),
|
|
3262
|
-
clients: deps.broadcaster.listChannels(),
|
|
3263
|
-
listeners: deps.supervisor.list(),
|
|
3264
|
-
broadcaster: deps.broadcaster.getMetrics()
|
|
3265
|
-
});
|
|
3266
|
-
});
|
|
3267
|
-
//#endregion
|
|
3268
|
-
//#region lib/gateway/routes/index.ts
|
|
3269
|
-
function buildGatewayRoutes() {
|
|
3270
|
-
return factory$1.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler$1).get("/debug", ...debugHandler$1).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$1);
|
|
3271
|
-
}
|
|
3272
|
-
const gatewayRoutes = buildGatewayRoutes();
|
|
3273
|
-
//#endregion
|
|
3274
|
-
//#region lib/gateway/gateway-server.ts
|
|
3275
|
-
const DEFAULT_HOST = "127.0.0.1";
|
|
3276
|
-
const LOOPBACK_HOSTS = new Set([
|
|
3277
|
-
"127.0.0.1",
|
|
3278
|
-
"localhost",
|
|
3279
|
-
"::1",
|
|
3280
|
-
"::ffff:127.0.0.1"
|
|
3281
|
-
]);
|
|
3282
|
-
const defaultDbPath = () => join(funnelTmpDir(), "events.db");
|
|
3283
|
-
const defaultOnError = () => {};
|
|
3284
|
-
/**
|
|
3285
|
-
* In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
|
|
3286
|
-
* listeners through `FunnelListenerSupervisor`, fans events out via
|
|
3287
|
-
* `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
|
|
3288
|
-
* System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
|
|
3289
|
-
* instead — keeping the SQLite seq space exclusive to broadcaster traffic so
|
|
3290
|
-
* the broadcaster's offset counter and `getMaxSeq()` stay aligned without
|
|
3291
|
-
* per-event coordination. Exposes `/listeners` HTTP for runtime
|
|
3292
|
-
* start/stop/restart of individual connectors.
|
|
3293
|
-
*/
|
|
3294
|
-
var FunnelGatewayServer = class {
|
|
3295
|
-
channels;
|
|
3296
|
-
port;
|
|
3297
|
-
hostname;
|
|
3298
|
-
dbPath;
|
|
3299
|
-
process;
|
|
3300
|
-
logger;
|
|
3301
|
-
onError;
|
|
3302
|
-
selfPid;
|
|
3303
|
-
dir;
|
|
3304
|
-
killCompetingSlack;
|
|
3305
|
-
token;
|
|
3306
|
-
broadcaster;
|
|
3307
|
-
eventLog;
|
|
3308
|
-
supervisor;
|
|
3309
|
-
nowMs;
|
|
3310
|
-
extraRoutes;
|
|
3311
|
-
startedAt = null;
|
|
3312
|
-
server = null;
|
|
3313
|
-
constructor(deps) {
|
|
3314
|
-
this.channels = deps.channels;
|
|
3315
|
-
this.port = deps.port ?? resolveFunnelPort();
|
|
3316
|
-
this.hostname = deps.hostname ?? DEFAULT_HOST;
|
|
3317
|
-
this.dbPath = deps.dbPath ?? defaultDbPath();
|
|
3318
|
-
this.process = deps.process;
|
|
3319
|
-
this.logger = deps.logger;
|
|
3320
|
-
this.onError = deps.onError ?? defaultOnError;
|
|
3321
|
-
this.selfPid = deps.selfPid ?? globalThis.process.pid;
|
|
3322
|
-
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
3323
|
-
this.killCompetingSlack = deps.killCompetingSlack ?? true;
|
|
3324
|
-
this.token = deps.token ?? "";
|
|
3325
|
-
this.extraRoutes = deps.extraRoutes ?? null;
|
|
3326
|
-
const clock = deps.clock;
|
|
3327
|
-
this.nowMs = clock ? () => clock.millis() : () => Date.now();
|
|
3328
|
-
if (deps.eventLog) this.eventLog = deps.eventLog;
|
|
3329
|
-
else {
|
|
3330
|
-
const dbDir = dirname(this.dbPath);
|
|
3331
|
-
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
3332
|
-
this.eventLog = new SqliteFunnelEventLog({
|
|
3333
|
-
path: this.dbPath,
|
|
3334
|
-
now: this.nowMs
|
|
3335
|
-
});
|
|
3336
|
-
}
|
|
3337
|
-
this.broadcaster = new FunnelBroadcaster({
|
|
3338
|
-
logger: this.logger,
|
|
3339
|
-
onError: this.onError,
|
|
3340
|
-
now: this.nowMs,
|
|
3341
|
-
persistentReplay: this.eventLog
|
|
3342
|
-
});
|
|
3343
|
-
this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
|
|
3344
|
-
this.supervisor = new FunnelListenerSupervisor({
|
|
3345
|
-
channels: this.channels,
|
|
3346
|
-
logger: this.logger,
|
|
3347
|
-
onError: this.onError,
|
|
3348
|
-
notify: async (channelName, connectorName, content, meta) => {
|
|
3349
|
-
this.emit({
|
|
3350
|
-
channel: channelName,
|
|
3351
|
-
connector: connectorName,
|
|
3352
|
-
content,
|
|
3353
|
-
meta
|
|
3354
|
-
});
|
|
3355
|
-
},
|
|
3356
|
-
now: this.nowMs
|
|
3357
|
-
});
|
|
3358
|
-
}
|
|
3359
|
-
async start() {
|
|
3360
|
-
if (this.server) return this.server;
|
|
3361
|
-
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 });
|
|
3362
|
-
const app = this.buildApp();
|
|
3363
|
-
this.startedAt = this.nowMs();
|
|
3364
|
-
this.server = Bun.serve({
|
|
3365
|
-
port: this.port,
|
|
3366
|
-
hostname: this.hostname,
|
|
3367
|
-
development: false,
|
|
3368
|
-
fetch: (request, server) => this.handleFetch(request, server, app),
|
|
3369
|
-
websocket: {
|
|
3370
|
-
open: (ws) => this.handleWsOpen(ws),
|
|
3371
|
-
close: (ws) => this.handleWsClose(ws),
|
|
3372
|
-
message() {}
|
|
3373
|
-
}
|
|
3374
|
-
});
|
|
3375
|
-
this.logServerStarted();
|
|
3376
|
-
await this.bootListeners();
|
|
3377
|
-
return this.server;
|
|
3378
|
-
}
|
|
3379
|
-
async stop() {
|
|
3380
|
-
await this.supervisor.stopAll();
|
|
3381
|
-
if (this.server) {
|
|
3382
|
-
this.server.stop();
|
|
3383
|
-
this.server = null;
|
|
3384
|
-
}
|
|
782
|
+
isRunning() {
|
|
783
|
+
const pid = this.readPid();
|
|
784
|
+
if (!pid) return false;
|
|
785
|
+
return this.isProcessAlive(pid);
|
|
3385
786
|
}
|
|
3386
787
|
getStatus() {
|
|
788
|
+
const pid = this.readPid();
|
|
789
|
+
const running = pid !== null && this.isProcessAlive(pid);
|
|
3387
790
|
return {
|
|
3388
|
-
|
|
3389
|
-
|
|
791
|
+
running,
|
|
792
|
+
pid: running ? pid : null,
|
|
793
|
+
port: this.port
|
|
3390
794
|
};
|
|
3391
795
|
}
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
796
|
+
async start(options = {}) {
|
|
797
|
+
if (this.isRunning()) return true;
|
|
798
|
+
this.fs.mkdirSync(this.tmpDir, { recursive: true });
|
|
799
|
+
const gatewayScript = resolveDaemonScript();
|
|
800
|
+
const command = this.buildStartCommand(gatewayScript, options);
|
|
801
|
+
this.process.detach(command, {
|
|
802
|
+
env: { FUNNEL_DIR: this.dir },
|
|
803
|
+
stdoutFile: this.gatewayLog,
|
|
804
|
+
stderrFile: this.gatewayLog
|
|
805
|
+
});
|
|
806
|
+
const deadline = this.clock.millis() + STARTUP_TIMEOUT_MS;
|
|
807
|
+
while (this.clock.millis() < deadline) {
|
|
808
|
+
if (this.isRunning()) return true;
|
|
809
|
+
await this.sleep(POLL_INTERVAL_MS);
|
|
810
|
+
}
|
|
811
|
+
return this.isRunning();
|
|
3397
812
|
}
|
|
3398
|
-
|
|
3399
|
-
|
|
813
|
+
buildStartCommand(gatewayScript, options = {}) {
|
|
814
|
+
const tag = `funnel-gateway[${this.dir}]`;
|
|
815
|
+
if (options.caffeinate !== false && globalThis.process.platform === "darwin") return [
|
|
816
|
+
"caffeinate",
|
|
817
|
+
"-is",
|
|
818
|
+
"bun",
|
|
819
|
+
gatewayScript,
|
|
820
|
+
tag
|
|
821
|
+
];
|
|
822
|
+
return [
|
|
823
|
+
"bun",
|
|
824
|
+
gatewayScript,
|
|
825
|
+
tag
|
|
826
|
+
];
|
|
3400
827
|
}
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
*/
|
|
3408
|
-
onEvent(handler) {
|
|
3409
|
-
return this.broadcaster.subscribe(handler);
|
|
3410
|
-
}
|
|
3411
|
-
handleFetch(request, server, app) {
|
|
3412
|
-
const url = new URL(request.url);
|
|
3413
|
-
if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
|
|
3414
|
-
if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
|
|
3415
|
-
const requestedChannel = url.searchParams.get("channel") ?? "";
|
|
3416
|
-
const channel = requestedChannel ? this.resolveChannel(requestedChannel) : null;
|
|
3417
|
-
const channelId = channel?.id ?? requestedChannel;
|
|
3418
|
-
const channelName = channel?.name ?? null;
|
|
3419
|
-
const connectors = channel?.connectors ?? [];
|
|
3420
|
-
const delivery = channel?.delivery ?? "fanout";
|
|
3421
|
-
const sinceRaw = url.searchParams.get("since");
|
|
3422
|
-
const sinceParsed = sinceRaw === null ? NaN : Number.parseInt(sinceRaw, 10);
|
|
3423
|
-
const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : void 0;
|
|
3424
|
-
const subscriberId = url.searchParams.get("id") ?? void 0;
|
|
3425
|
-
if (server.upgrade(request, { data: {
|
|
3426
|
-
channel: channelId,
|
|
3427
|
-
channelName,
|
|
3428
|
-
connectors,
|
|
3429
|
-
delivery,
|
|
3430
|
-
subscriberId,
|
|
3431
|
-
since
|
|
3432
|
-
} })) return void 0;
|
|
3433
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
828
|
+
async stop() {
|
|
829
|
+
const pid = this.readPid();
|
|
830
|
+
if (!pid) return true;
|
|
831
|
+
if (!this.isProcessAlive(pid)) {
|
|
832
|
+
this.removePid();
|
|
833
|
+
return true;
|
|
3434
834
|
}
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
const replay = this.broadcaster.replaySince(ws.data.since, ws.data);
|
|
3440
|
-
for (const event of replay) ws.send(JSON.stringify(event));
|
|
835
|
+
try {
|
|
836
|
+
this.process.kill(pid, "SIGTERM");
|
|
837
|
+
} catch {
|
|
838
|
+
return false;
|
|
3441
839
|
}
|
|
3442
|
-
this.
|
|
3443
|
-
this.
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
total: String(this.broadcaster.getClientCount())
|
|
3450
|
-
});
|
|
3451
|
-
}
|
|
3452
|
-
handleWsClose(ws) {
|
|
3453
|
-
this.broadcaster.removeClient(ws);
|
|
3454
|
-
this.logger?.info("channel disconnected", {
|
|
3455
|
-
event_type: "system",
|
|
3456
|
-
action: "channel_disconnect",
|
|
3457
|
-
channel: ws.data.channelName ?? "",
|
|
3458
|
-
channelId: ws.data.channel,
|
|
3459
|
-
total: String(this.broadcaster.getClientCount())
|
|
3460
|
-
});
|
|
3461
|
-
}
|
|
3462
|
-
logServerStarted() {
|
|
3463
|
-
this.logger?.info("gateway started", {
|
|
3464
|
-
event_type: "system",
|
|
3465
|
-
action: "gateway_start",
|
|
3466
|
-
port: String(this.port),
|
|
3467
|
-
pid: String(this.selfPid)
|
|
3468
|
-
});
|
|
3469
|
-
this.logger?.info("funnel gateway listening", {
|
|
3470
|
-
url: `http://localhost:${this.port}`,
|
|
3471
|
-
websocket: `ws://localhost:${this.port}/ws`,
|
|
3472
|
-
health: `http://localhost:${this.port}/health`
|
|
3473
|
-
});
|
|
3474
|
-
}
|
|
3475
|
-
buildApp() {
|
|
3476
|
-
const base = factory$1.createApp();
|
|
3477
|
-
base.use((c, next) => {
|
|
3478
|
-
c.set("deps", {
|
|
3479
|
-
selfPid: this.selfPid,
|
|
3480
|
-
broadcaster: this.broadcaster,
|
|
3481
|
-
supervisor: this.supervisor,
|
|
3482
|
-
channels: this.channels,
|
|
3483
|
-
uptimeMs: () => this.startedAt ? this.nowMs() - this.startedAt : 0,
|
|
3484
|
-
emit: (input) => this.emit(input)
|
|
3485
|
-
});
|
|
3486
|
-
return next();
|
|
3487
|
-
});
|
|
3488
|
-
if (this.token) {
|
|
3489
|
-
base.use("/listeners/*", requireBearerToken({ expected: this.token }));
|
|
3490
|
-
base.use("/status", requireBearerToken({ expected: this.token }));
|
|
3491
|
-
base.use("/debug", requireBearerToken({ expected: this.token }));
|
|
3492
|
-
base.use("/channels/*", requireBearerToken({ expected: this.token }));
|
|
840
|
+
const deadline = this.clock.millis() + SIGTERM_TIMEOUT_MS;
|
|
841
|
+
while (this.clock.millis() < deadline) {
|
|
842
|
+
if (!this.isProcessAlive(pid)) {
|
|
843
|
+
this.removePid();
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
await this.sleep(POLL_INTERVAL_MS);
|
|
3493
847
|
}
|
|
3494
|
-
|
|
848
|
+
try {
|
|
849
|
+
this.process.kill(pid, "SIGKILL");
|
|
850
|
+
} catch {}
|
|
851
|
+
await this.sleep(SIGKILL_GRACE_MS);
|
|
852
|
+
this.removePid();
|
|
853
|
+
return !this.isProcessAlive(pid);
|
|
3495
854
|
}
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
3504
|
-
for (const proto of protocols) if (proto.startsWith("funnel.token.") && constantTimeEqual(proto.slice(13), this.token)) return true;
|
|
3505
|
-
const match = (request.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i);
|
|
3506
|
-
if (match && constantTimeEqual(match[1] ?? "", this.token)) return true;
|
|
3507
|
-
return false;
|
|
3508
|
-
}
|
|
3509
|
-
resolveChannel(requested) {
|
|
3510
|
-
const channel = this.channels.get(requested) ?? this.channels.getById(requested);
|
|
3511
|
-
if (!channel) return null;
|
|
3512
|
-
return {
|
|
3513
|
-
id: channel.id,
|
|
3514
|
-
name: channel.name,
|
|
3515
|
-
connectors: channel.connectors.map((c) => c.name),
|
|
3516
|
-
delivery: channel.delivery
|
|
855
|
+
async restart(options = {}) {
|
|
856
|
+
const wasRunning = this.isRunning();
|
|
857
|
+
if (options.onlyIfRunning && !wasRunning) return {
|
|
858
|
+
ok: true,
|
|
859
|
+
wasRunning: false,
|
|
860
|
+
stopped: false,
|
|
861
|
+
started: false
|
|
3517
862
|
};
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
pids: killed.join(",")
|
|
3532
|
-
});
|
|
3533
|
-
}
|
|
3534
|
-
await this.supervisor.startAll();
|
|
3535
|
-
for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
|
|
3536
|
-
event_type: "system",
|
|
3537
|
-
action: `${entry.type}_connect`,
|
|
3538
|
-
channel: entry.channelName,
|
|
3539
|
-
connector: entry.name
|
|
3540
|
-
});
|
|
3541
|
-
this.logger?.info(`event store: ${this.dbPath}`);
|
|
3542
|
-
this.logger?.info("funnel gateway running");
|
|
3543
|
-
}
|
|
3544
|
-
/**
|
|
3545
|
-
* Broadcast `content` to subscribers of `channel`, persisting the event in
|
|
3546
|
-
* the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
|
|
3547
|
-
* when they resolve. Used by both the connector-listener path (via the
|
|
3548
|
-
* supervisor's `notify` callback) and the public `/channels/:channel/publish`
|
|
3549
|
-
* route. Returns the assigned event offset.
|
|
3550
|
-
*/
|
|
3551
|
-
emit(input) {
|
|
3552
|
-
const channelId = this.lookupChannelId(input.channel);
|
|
3553
|
-
const connectorId = channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null;
|
|
3554
|
-
const enriched = {
|
|
3555
|
-
...input.meta,
|
|
3556
|
-
channel: input.channel
|
|
863
|
+
const stopped = wasRunning ? await this.stop() : true;
|
|
864
|
+
if (!stopped) return {
|
|
865
|
+
ok: false,
|
|
866
|
+
wasRunning,
|
|
867
|
+
stopped: false,
|
|
868
|
+
started: false
|
|
869
|
+
};
|
|
870
|
+
const started = await this.start({ caffeinate: options.caffeinate });
|
|
871
|
+
return {
|
|
872
|
+
ok: started,
|
|
873
|
+
wasRunning,
|
|
874
|
+
stopped,
|
|
875
|
+
started
|
|
3557
876
|
};
|
|
3558
|
-
if (input.connector) enriched.connector = input.connector;
|
|
3559
|
-
if (channelId) enriched.channelId = channelId;
|
|
3560
|
-
if (connectorId) enriched.connectorId = connectorId;
|
|
3561
|
-
const event = this.broadcaster.broadcast(input.content, enriched);
|
|
3562
|
-
this.eventLog.record({
|
|
3563
|
-
content: input.content,
|
|
3564
|
-
channelId: channelId ?? null,
|
|
3565
|
-
connectorId: connectorId ?? null,
|
|
3566
|
-
meta: enriched,
|
|
3567
|
-
offset: event.offset
|
|
3568
|
-
});
|
|
3569
|
-
return { offset: event.offset };
|
|
3570
|
-
}
|
|
3571
|
-
lookupChannelId(channelName) {
|
|
3572
|
-
return this.channels.get(channelName)?.id ?? null;
|
|
3573
877
|
}
|
|
3574
|
-
|
|
3575
|
-
return this.
|
|
878
|
+
getGatewayLog() {
|
|
879
|
+
return this.gatewayLog;
|
|
3576
880
|
}
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
//#region lib/gateway/gateway-token.ts
|
|
3580
|
-
const TOKEN_FILE_NAME = "gateway.token";
|
|
3581
|
-
const TOKEN_BYTES = 32;
|
|
3582
|
-
const defaultFs = new NodeFunnelFileSystem();
|
|
3583
|
-
const defaultGenerate = () => {
|
|
3584
|
-
const buf = new Uint8Array(TOKEN_BYTES);
|
|
3585
|
-
crypto.getRandomValues(buf);
|
|
3586
|
-
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3587
|
-
};
|
|
3588
|
-
/**
|
|
3589
|
-
* Reads / generates the gateway daemon token used to authenticate
|
|
3590
|
-
* `/listeners*`, `/status`, and `/ws` connections.
|
|
3591
|
-
*
|
|
3592
|
-
* Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
|
|
3593
|
-
* written with mode 0600. Clients on the same machine as the daemon read
|
|
3594
|
-
* the file directly; the token never leaves the user's home directory.
|
|
3595
|
-
*/
|
|
3596
|
-
var FunnelGatewayToken = class {
|
|
3597
|
-
fs;
|
|
3598
|
-
path;
|
|
3599
|
-
generate;
|
|
3600
|
-
constructor(deps = {}) {
|
|
3601
|
-
this.fs = deps.fs ?? defaultFs;
|
|
3602
|
-
this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME);
|
|
3603
|
-
this.generate = deps.generate ?? defaultGenerate;
|
|
3604
|
-
Object.freeze(this);
|
|
881
|
+
getPort() {
|
|
882
|
+
return this.port;
|
|
3605
883
|
}
|
|
3606
|
-
|
|
3607
|
-
if (!this.fs.existsSync(this.
|
|
3608
|
-
|
|
3609
|
-
|
|
884
|
+
readPid() {
|
|
885
|
+
if (!this.fs.existsSync(this.pidFile)) return null;
|
|
886
|
+
try {
|
|
887
|
+
const content = this.fs.readFileSync(this.pidFile).trim();
|
|
888
|
+
const pid = Number(content);
|
|
889
|
+
if (!pid || pid <= 0) return null;
|
|
890
|
+
return pid;
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
3610
894
|
}
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
*
|
|
3614
|
-
* NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
|
|
3615
|
-
* itself before the PID lock is acquired) could each generate independent tokens. The
|
|
3616
|
-
* gateway PID file makes this practically a non-issue; if you need stronger guarantees,
|
|
3617
|
-
* take a file lock around this call externally.
|
|
3618
|
-
*/
|
|
3619
|
-
ensure() {
|
|
3620
|
-
const existing = this.read();
|
|
3621
|
-
if (existing) return existing;
|
|
3622
|
-
const token = this.generate();
|
|
3623
|
-
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
3624
|
-
this.fs.writeSecretFileSync(this.path, `${token}\n`);
|
|
3625
|
-
return token;
|
|
895
|
+
removePid() {
|
|
896
|
+
this.fs.unlink(this.pidFile);
|
|
3626
897
|
}
|
|
3627
|
-
|
|
3628
|
-
return this.
|
|
898
|
+
isProcessAlive(pid) {
|
|
899
|
+
return this.process.isAlive(pid);
|
|
3629
900
|
}
|
|
3630
901
|
};
|
|
3631
|
-
const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME);
|
|
3632
902
|
//#endregion
|
|
3633
903
|
//#region lib/gateway/listeners-client.ts
|
|
3634
904
|
const listenerEntrySchema = z.object({
|
|
@@ -4032,473 +1302,6 @@ var NoopFunnelLogger = class extends FunnelLogger {
|
|
|
4032
1302
|
error() {}
|
|
4033
1303
|
};
|
|
4034
1304
|
//#endregion
|
|
4035
|
-
//#region lib/gateway/memory-funnel-event-log.ts
|
|
4036
|
-
/**
|
|
4037
|
-
* In-process `FunnelEventLog` backed by a plain array. Used by tests and by
|
|
4038
|
-
* embedders that do not need durability — replay works within the process
|
|
4039
|
-
* lifetime but is lost when the process exits. Unlike the SQLite log it does
|
|
4040
|
-
* not truncate content or prune, so it is not meant for unbounded production
|
|
4041
|
-
* traffic.
|
|
4042
|
-
*/
|
|
4043
|
-
var MemoryFunnelEventLog = class extends FunnelEventLog {
|
|
4044
|
-
events = [];
|
|
4045
|
-
constructor() {
|
|
4046
|
-
super();
|
|
4047
|
-
Object.freeze(this);
|
|
4048
|
-
}
|
|
4049
|
-
record(record) {
|
|
4050
|
-
this.events.push({
|
|
4051
|
-
offset: record.offset,
|
|
4052
|
-
content: record.content,
|
|
4053
|
-
meta: record.meta ?? void 0,
|
|
4054
|
-
channelId: record.channelId,
|
|
4055
|
-
connectorId: record.connectorId
|
|
4056
|
-
});
|
|
4057
|
-
}
|
|
4058
|
-
loadSince(since) {
|
|
4059
|
-
const out = [];
|
|
4060
|
-
for (const event of this.events) if (event.offset > since) out.push({
|
|
4061
|
-
content: event.content,
|
|
4062
|
-
meta: event.meta,
|
|
4063
|
-
offset: event.offset
|
|
4064
|
-
});
|
|
4065
|
-
return out;
|
|
4066
|
-
}
|
|
4067
|
-
findMaxOffset() {
|
|
4068
|
-
let max = 0;
|
|
4069
|
-
for (const event of this.events) if (event.offset > max) max = event.offset;
|
|
4070
|
-
return max;
|
|
4071
|
-
}
|
|
4072
|
-
clear() {
|
|
4073
|
-
this.events.length = 0;
|
|
4074
|
-
}
|
|
4075
|
-
close() {}
|
|
4076
|
-
};
|
|
4077
|
-
//#endregion
|
|
4078
|
-
//#region lib/gateway/connector-diagnostic-log.ts
|
|
4079
|
-
/**
|
|
4080
|
-
* Points in the listener's connection lifecycle. The single source of truth
|
|
4081
|
-
* for the value set: the `status` column schema, the `ConnectorConnectionStatus`
|
|
4082
|
-
* union, and the runtime Set used to narrow on read-back all derive from this
|
|
4083
|
-
* array, so adding a status is a one-line change that cannot drift out of sync.
|
|
4084
|
-
*
|
|
4085
|
-
* started start() was called
|
|
4086
|
-
* connected the socket opened and events can flow
|
|
4087
|
-
* disconnected the socket was closed by a stop() call (a clean teardown)
|
|
4088
|
-
* auth-failed the token was rejected before the socket opened
|
|
4089
|
-
* stopped the listener was fully torn down (always follows a stop(),
|
|
4090
|
-
* paired with the disconnected/error that preceded it)
|
|
4091
|
-
* error start/stop threw, or Bolt surfaced an error frame — this is
|
|
4092
|
-
* also where an unsolicited socket drop shows up when Bolt
|
|
4093
|
-
* reports it (an `error` with no following `stopped` means the
|
|
4094
|
-
* supervisor recycled the listener, not a clean stop)
|
|
4095
|
-
*
|
|
4096
|
-
* A connection row is independent of any single inbound event, so it carries
|
|
4097
|
-
* no `eventId`. This is how "no notification arrived because the listener
|
|
4098
|
-
* never connected (or dropped, or failed auth)" becomes visible: the
|
|
4099
|
-
* raw/processed tables only hold events that *did* arrive.
|
|
4100
|
-
*/
|
|
4101
|
-
const CONNECTOR_CONNECTION_STATUSES = [
|
|
4102
|
-
"started",
|
|
4103
|
-
"connected",
|
|
4104
|
-
"disconnected",
|
|
4105
|
-
"auth-failed",
|
|
4106
|
-
"stopped",
|
|
4107
|
-
"error"
|
|
4108
|
-
];
|
|
4109
|
-
/**
|
|
4110
|
-
* Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
|
|
4111
|
-
* carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
|
|
4112
|
-
* connectors land in the same tables without a schema change. `event_id` is
|
|
4113
|
-
* the correlation key the listener mints once per inbound event and stamps
|
|
4114
|
-
* onto both the raw and processed rows, so the two are joinable even though
|
|
4115
|
-
* they live in separate tables with independent `seq` counters.
|
|
4116
|
-
*
|
|
4117
|
-
* These schemas mirror the stored shape (snake_case columns) the way
|
|
4118
|
-
* `FunnelEvent` does for the replay log; they exist for `z.infer` and to
|
|
4119
|
-
* document the column set, not as a parse boundary.
|
|
4120
|
-
*/
|
|
4121
|
-
const connectorRawEventSchema = z.object({
|
|
4122
|
-
event_id: z.string(),
|
|
4123
|
-
type: z.string(),
|
|
4124
|
-
connector_id: z.string().nullable(),
|
|
4125
|
-
channel_id: z.string().nullable(),
|
|
4126
|
-
payload: z.string()
|
|
4127
|
-
});
|
|
4128
|
-
const connectorProcessedEventSchema = z.object({
|
|
4129
|
-
event_id: z.string(),
|
|
4130
|
-
type: z.string(),
|
|
4131
|
-
connector_id: z.string().nullable(),
|
|
4132
|
-
channel_id: z.string().nullable(),
|
|
4133
|
-
outcome: z.string(),
|
|
4134
|
-
payload: z.string()
|
|
4135
|
-
});
|
|
4136
|
-
const connectorConnectionEventSchema = z.object({
|
|
4137
|
-
type: z.string(),
|
|
4138
|
-
connector_id: z.string().nullable(),
|
|
4139
|
-
channel_id: z.string().nullable(),
|
|
4140
|
-
status: z.enum(CONNECTOR_CONNECTION_STATUSES),
|
|
4141
|
-
detail: z.string()
|
|
4142
|
-
});
|
|
4143
|
-
/**
|
|
4144
|
-
* Three-table diagnostic log of everything a connector listener does, so
|
|
4145
|
-
* "why was there no notification?" is answerable whichever way it failed:
|
|
4146
|
-
* - `raw` — every inbound event, before any filtering, with the listener's
|
|
4147
|
-
* untouched payload (the Slack Bolt event, the GH webhook, …)
|
|
4148
|
-
* - `processed` — the verdict for that event: `outcome` (emitted, or the
|
|
4149
|
-
* reason it was dropped) and, when emitted, the body that was delivered.
|
|
4150
|
-
* Shares an `eventId` with its raw row, so the two join into one story.
|
|
4151
|
-
* - `connection` — the listener's lifecycle (started, connected, dropped,
|
|
4152
|
-
* auth-failed, stopped, errored). This is the half the event tables can't
|
|
4153
|
-
* show: an event that never arrived leaves no raw row, but a listener that
|
|
4154
|
-
* never connected leaves a `connection` trail that says so.
|
|
4155
|
-
*
|
|
4156
|
-
* The three are physically separate (independent retention and payload-size
|
|
4157
|
-
* policy) so a query never crosses them by accident and a huge raw payload
|
|
4158
|
-
* never bloats the verdict or lifecycle trails. None flow to WS clients or the
|
|
4159
|
-
* MCP channel — this is a separate store from `FunnelEventLog` (replay) and
|
|
4160
|
-
* exists solely for debugging.
|
|
4161
|
-
*
|
|
4162
|
-
* Implementations:
|
|
4163
|
-
* - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
|
|
4164
|
-
* bounded by per-table row/age caps.
|
|
4165
|
-
* - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
|
|
4166
|
-
*/
|
|
4167
|
-
var ConnectorDiagnosticLog = class {};
|
|
4168
|
-
//#endregion
|
|
4169
|
-
//#region lib/gateway/sqlite-connector-diagnostic-log.ts
|
|
4170
|
-
/**
|
|
4171
|
-
* Cap on a raw payload kept verbatim. The point of the raw table is to see
|
|
4172
|
-
* what Slack/Discord actually sent, and a typical event is a few KB — so 256
|
|
4173
|
-
* KiB keeps essentially everything intact while bounding the rare giant
|
|
4174
|
-
* payload (a huge Block Kit message, a file dump) that would otherwise let a
|
|
4175
|
-
* single row bloat the debug database without limit.
|
|
4176
|
-
*/
|
|
4177
|
-
const RAW_PAYLOAD_CAP = 256 * 1024;
|
|
4178
|
-
/**
|
|
4179
|
-
* Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
|
|
4180
|
-
* per table (raw / processed / connection), in separate files. Each sink
|
|
4181
|
-
* indexes the columns its queries filter on — `event_id` / `connector_id` /
|
|
4182
|
-
* `channel_id` for raw, plus `outcome` for processed and `status` for
|
|
4183
|
-
* connection — so those lookups are indexed scans (`type` is a fixed column
|
|
4184
|
-
* the sink extracts separately, not an index, so filtering by it is a scan).
|
|
4185
|
-
*
|
|
4186
|
-
* The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
|
|
4187
|
-
* truncating mid-string (which yields unparseable JSON), it replaces the
|
|
4188
|
-
* body with a small JSON object that keeps the diagnostic essentials and
|
|
4189
|
-
* records the dropped size under `_funnel_oversized`. Every stored payload
|
|
4190
|
-
* therefore stays valid JSON.
|
|
4191
|
-
*/
|
|
4192
|
-
var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
|
|
4193
|
-
raw;
|
|
4194
|
-
processed;
|
|
4195
|
-
connection;
|
|
4196
|
-
now;
|
|
4197
|
-
logger;
|
|
4198
|
-
constructor(props) {
|
|
4199
|
-
super();
|
|
4200
|
-
this.now = props.now ?? (() => Date.now());
|
|
4201
|
-
this.logger = props.logger;
|
|
4202
|
-
const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
|
|
4203
|
-
const verdictCap = {
|
|
4204
|
-
now: this.now,
|
|
4205
|
-
...ageCap,
|
|
4206
|
-
...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
|
|
4207
|
-
};
|
|
4208
|
-
const rawMax = props.rawMaxRows ?? props.maxRows;
|
|
4209
|
-
const rawCap = {
|
|
4210
|
-
now: this.now,
|
|
4211
|
-
...ageCap,
|
|
4212
|
-
...rawMax !== void 0 ? { maxRows: rawMax } : {}
|
|
4213
|
-
};
|
|
4214
|
-
this.raw = new LeucoLoggerSqliteSink({
|
|
4215
|
-
path: props.rawPath,
|
|
4216
|
-
indexes: [
|
|
4217
|
-
"event_id",
|
|
4218
|
-
"connector_id",
|
|
4219
|
-
"channel_id"
|
|
4220
|
-
],
|
|
4221
|
-
extractIndexes: (event) => ({
|
|
4222
|
-
event_id: event.event_id,
|
|
4223
|
-
connector_id: event.connector_id,
|
|
4224
|
-
channel_id: event.channel_id
|
|
4225
|
-
}),
|
|
4226
|
-
...rawCap
|
|
4227
|
-
});
|
|
4228
|
-
this.processed = new LeucoLoggerSqliteSink({
|
|
4229
|
-
path: props.processedPath,
|
|
4230
|
-
indexes: [
|
|
4231
|
-
"event_id",
|
|
4232
|
-
"connector_id",
|
|
4233
|
-
"channel_id",
|
|
4234
|
-
"outcome"
|
|
4235
|
-
],
|
|
4236
|
-
extractIndexes: (event) => ({
|
|
4237
|
-
event_id: event.event_id,
|
|
4238
|
-
connector_id: event.connector_id,
|
|
4239
|
-
channel_id: event.channel_id,
|
|
4240
|
-
outcome: event.outcome
|
|
4241
|
-
}),
|
|
4242
|
-
...verdictCap
|
|
4243
|
-
});
|
|
4244
|
-
this.connection = new LeucoLoggerSqliteSink({
|
|
4245
|
-
path: props.connectionPath,
|
|
4246
|
-
indexes: [
|
|
4247
|
-
"connector_id",
|
|
4248
|
-
"channel_id",
|
|
4249
|
-
"status"
|
|
4250
|
-
],
|
|
4251
|
-
extractIndexes: (event) => ({
|
|
4252
|
-
connector_id: event.connector_id,
|
|
4253
|
-
channel_id: event.channel_id,
|
|
4254
|
-
status: event.status
|
|
4255
|
-
}),
|
|
4256
|
-
...verdictCap
|
|
4257
|
-
});
|
|
4258
|
-
restrictPermissions(props.rawPath);
|
|
4259
|
-
restrictPermissions(props.processedPath);
|
|
4260
|
-
restrictPermissions(props.connectionPath);
|
|
4261
|
-
Object.freeze(this);
|
|
4262
|
-
}
|
|
4263
|
-
recordRaw(record) {
|
|
4264
|
-
const event = {
|
|
4265
|
-
event_id: record.eventId,
|
|
4266
|
-
type: record.type,
|
|
4267
|
-
connector_id: record.connectorId,
|
|
4268
|
-
channel_id: record.channelId,
|
|
4269
|
-
payload: capPayload(record.payload, record.type)
|
|
4270
|
-
};
|
|
4271
|
-
this.report("raw", this.raw.insert({
|
|
4272
|
-
ts: this.now(),
|
|
4273
|
-
event
|
|
4274
|
-
}));
|
|
4275
|
-
}
|
|
4276
|
-
recordProcessed(record) {
|
|
4277
|
-
const event = {
|
|
4278
|
-
event_id: record.eventId,
|
|
4279
|
-
type: record.type,
|
|
4280
|
-
connector_id: record.connectorId,
|
|
4281
|
-
channel_id: record.channelId,
|
|
4282
|
-
outcome: record.outcome,
|
|
4283
|
-
payload: record.payload
|
|
4284
|
-
};
|
|
4285
|
-
this.report("processed", this.processed.insert({
|
|
4286
|
-
ts: this.now(),
|
|
4287
|
-
event
|
|
4288
|
-
}));
|
|
4289
|
-
}
|
|
4290
|
-
recordConnection(record) {
|
|
4291
|
-
const event = {
|
|
4292
|
-
type: record.type,
|
|
4293
|
-
connector_id: record.connectorId,
|
|
4294
|
-
channel_id: record.channelId,
|
|
4295
|
-
status: record.status,
|
|
4296
|
-
detail: record.detail
|
|
4297
|
-
};
|
|
4298
|
-
this.report("connection", this.connection.insert({
|
|
4299
|
-
ts: this.now(),
|
|
4300
|
-
event
|
|
4301
|
-
}));
|
|
4302
|
-
}
|
|
4303
|
-
report(table, result) {
|
|
4304
|
-
if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
|
|
4305
|
-
table,
|
|
4306
|
-
error: result.message
|
|
4307
|
-
});
|
|
4308
|
-
}
|
|
4309
|
-
queryRaw(query) {
|
|
4310
|
-
return this.raw.getRecords({
|
|
4311
|
-
...query.type !== void 0 ? { type: query.type } : {},
|
|
4312
|
-
...query.limit !== void 0 ? { limit: query.limit } : {},
|
|
4313
|
-
where: buildWhere(query),
|
|
4314
|
-
order: "desc"
|
|
4315
|
-
}).map((record) => ({
|
|
4316
|
-
seq: record.seq,
|
|
4317
|
-
ts: record.ts,
|
|
4318
|
-
eventId: record.event.event_id,
|
|
4319
|
-
type: record.event.type,
|
|
4320
|
-
connectorId: record.event.connector_id,
|
|
4321
|
-
channelId: record.event.channel_id,
|
|
4322
|
-
payload: record.event.payload
|
|
4323
|
-
}));
|
|
4324
|
-
}
|
|
4325
|
-
queryProcessed(query) {
|
|
4326
|
-
const where = buildWhere(query);
|
|
4327
|
-
if (query.outcome !== void 0) where.outcome = query.outcome;
|
|
4328
|
-
return this.processed.getRecords({
|
|
4329
|
-
...query.type !== void 0 ? { type: query.type } : {},
|
|
4330
|
-
...query.limit !== void 0 ? { limit: query.limit } : {},
|
|
4331
|
-
where,
|
|
4332
|
-
order: "desc"
|
|
4333
|
-
}).map((record) => ({
|
|
4334
|
-
seq: record.seq,
|
|
4335
|
-
ts: record.ts,
|
|
4336
|
-
eventId: record.event.event_id,
|
|
4337
|
-
type: record.event.type,
|
|
4338
|
-
connectorId: record.event.connector_id,
|
|
4339
|
-
channelId: record.event.channel_id,
|
|
4340
|
-
outcome: record.event.outcome,
|
|
4341
|
-
payload: record.event.payload
|
|
4342
|
-
}));
|
|
4343
|
-
}
|
|
4344
|
-
queryConnection(query) {
|
|
4345
|
-
const where = buildWhere(query);
|
|
4346
|
-
if (query.status !== void 0) where.status = query.status;
|
|
4347
|
-
return this.connection.getRecords({
|
|
4348
|
-
...query.type !== void 0 ? { type: query.type } : {},
|
|
4349
|
-
...query.limit !== void 0 ? { limit: query.limit } : {},
|
|
4350
|
-
where,
|
|
4351
|
-
order: "desc"
|
|
4352
|
-
}).map((record) => ({
|
|
4353
|
-
seq: record.seq,
|
|
4354
|
-
ts: record.ts,
|
|
4355
|
-
type: record.event.type,
|
|
4356
|
-
connectorId: record.event.connector_id,
|
|
4357
|
-
channelId: record.event.channel_id,
|
|
4358
|
-
status: statusOf(record.event.status),
|
|
4359
|
-
detail: record.event.detail
|
|
4360
|
-
}));
|
|
4361
|
-
}
|
|
4362
|
-
clear() {
|
|
4363
|
-
this.raw.clear();
|
|
4364
|
-
this.processed.clear();
|
|
4365
|
-
this.connection.clear();
|
|
4366
|
-
}
|
|
4367
|
-
close() {
|
|
4368
|
-
this.raw.close();
|
|
4369
|
-
this.processed.close();
|
|
4370
|
-
this.connection.close();
|
|
4371
|
-
}
|
|
4372
|
-
};
|
|
4373
|
-
const restrictPermissions = (path) => {
|
|
4374
|
-
if (path === ":memory:") return;
|
|
4375
|
-
for (const suffix of [
|
|
4376
|
-
"",
|
|
4377
|
-
"-wal",
|
|
4378
|
-
"-shm"
|
|
4379
|
-
]) try {
|
|
4380
|
-
chmodSync(`${path}${suffix}`, 384);
|
|
4381
|
-
} catch {}
|
|
4382
|
-
};
|
|
4383
|
-
const buildWhere = (query) => {
|
|
4384
|
-
const where = {};
|
|
4385
|
-
if (query.connectorId !== void 0) where.connector_id = query.connectorId;
|
|
4386
|
-
if (query.channelId !== void 0) where.channel_id = query.channelId;
|
|
4387
|
-
return where;
|
|
4388
|
-
};
|
|
4389
|
-
const statusField = connectorConnectionEventSchema.shape.status;
|
|
4390
|
-
const statusOf = (value) => {
|
|
4391
|
-
const parsed = statusField.safeParse(value);
|
|
4392
|
-
return parsed.success ? parsed.data : "error";
|
|
4393
|
-
};
|
|
4394
|
-
const capPayload = (payload, type) => {
|
|
4395
|
-
const size = Buffer.byteLength(payload, "utf8");
|
|
4396
|
-
if (size <= RAW_PAYLOAD_CAP) return payload;
|
|
4397
|
-
return JSON.stringify({
|
|
4398
|
-
...headFields(payload),
|
|
4399
|
-
_funnel_oversized: size,
|
|
4400
|
-
_funnel_type: type
|
|
4401
|
-
});
|
|
4402
|
-
};
|
|
4403
|
-
const HEAD_KEYS = [
|
|
4404
|
-
"type",
|
|
4405
|
-
"subtype",
|
|
4406
|
-
"ts",
|
|
4407
|
-
"channel",
|
|
4408
|
-
"channel_type",
|
|
4409
|
-
"user",
|
|
4410
|
-
"bot_id"
|
|
4411
|
-
];
|
|
4412
|
-
const headFields = (payload) => {
|
|
4413
|
-
try {
|
|
4414
|
-
const parsed = JSON.parse(payload);
|
|
4415
|
-
if (typeof parsed !== "object" || parsed === null) return {};
|
|
4416
|
-
const source = parsed;
|
|
4417
|
-
const head = {};
|
|
4418
|
-
for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
|
|
4419
|
-
return head;
|
|
4420
|
-
} catch {
|
|
4421
|
-
return {};
|
|
4422
|
-
}
|
|
4423
|
-
};
|
|
4424
|
-
//#endregion
|
|
4425
|
-
//#region lib/gateway/memory-connector-diagnostic-log.ts
|
|
4426
|
-
/**
|
|
4427
|
-
* In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
|
|
4428
|
-
* and embedders that do not need durability. Like the SQLite log it keeps
|
|
4429
|
-
* `seq` per-table (each array's 1-based position) and returns the most recent
|
|
4430
|
-
* `limit` rows oldest-first; unlike it, it never prunes and never offloads
|
|
4431
|
-
* oversized payloads — it keeps whatever the caller hands it, which is fine
|
|
4432
|
-
* for the bounded volumes a test produces. Payload-validity is therefore a
|
|
4433
|
-
* SQLite-only guarantee; do not write a test that leans on this double
|
|
4434
|
-
* rejecting a malformed payload.
|
|
4435
|
-
*/
|
|
4436
|
-
var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
|
|
4437
|
-
raws = [];
|
|
4438
|
-
processeds = [];
|
|
4439
|
-
connections = [];
|
|
4440
|
-
constructor(now = () => Date.now()) {
|
|
4441
|
-
super();
|
|
4442
|
-
this.now = now;
|
|
4443
|
-
Object.freeze(this);
|
|
4444
|
-
}
|
|
4445
|
-
recordRaw(record) {
|
|
4446
|
-
this.raws.push({
|
|
4447
|
-
...record,
|
|
4448
|
-
seq: this.raws.length + 1,
|
|
4449
|
-
ts: this.now()
|
|
4450
|
-
});
|
|
4451
|
-
}
|
|
4452
|
-
recordProcessed(record) {
|
|
4453
|
-
this.processeds.push({
|
|
4454
|
-
...record,
|
|
4455
|
-
seq: this.processeds.length + 1,
|
|
4456
|
-
ts: this.now()
|
|
4457
|
-
});
|
|
4458
|
-
}
|
|
4459
|
-
recordConnection(record) {
|
|
4460
|
-
this.connections.push({
|
|
4461
|
-
...record,
|
|
4462
|
-
seq: this.connections.length + 1,
|
|
4463
|
-
ts: this.now()
|
|
4464
|
-
});
|
|
4465
|
-
}
|
|
4466
|
-
queryRaw(query) {
|
|
4467
|
-
return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
|
|
4468
|
-
}
|
|
4469
|
-
queryProcessed(query) {
|
|
4470
|
-
return takeRecent(this.processeds.filter((event) => {
|
|
4471
|
-
if (!matches(event, query)) return false;
|
|
4472
|
-
if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
|
|
4473
|
-
return true;
|
|
4474
|
-
}), query.limit);
|
|
4475
|
-
}
|
|
4476
|
-
queryConnection(query) {
|
|
4477
|
-
return takeRecent(this.connections.filter((event) => {
|
|
4478
|
-
if (!matches(event, query)) return false;
|
|
4479
|
-
if (query.status !== void 0 && event.status !== query.status) return false;
|
|
4480
|
-
return true;
|
|
4481
|
-
}), query.limit);
|
|
4482
|
-
}
|
|
4483
|
-
clear() {
|
|
4484
|
-
this.raws.length = 0;
|
|
4485
|
-
this.processeds.length = 0;
|
|
4486
|
-
this.connections.length = 0;
|
|
4487
|
-
}
|
|
4488
|
-
close() {}
|
|
4489
|
-
};
|
|
4490
|
-
const matches = (event, query) => {
|
|
4491
|
-
if (query.type !== void 0 && event.type !== query.type) return false;
|
|
4492
|
-
if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
|
|
4493
|
-
if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
|
|
4494
|
-
return true;
|
|
4495
|
-
};
|
|
4496
|
-
const takeRecent = (events, limit) => {
|
|
4497
|
-
if (limit === void 0) return events;
|
|
4498
|
-
if (limit <= 0) return [];
|
|
4499
|
-
return events.slice(-limit);
|
|
4500
|
-
};
|
|
4501
|
-
//#endregion
|
|
4502
1305
|
//#region lib/cli/factory.ts
|
|
4503
1306
|
const factory = createFactory();
|
|
4504
1307
|
//#endregion
|
|
@@ -6541,21 +3344,6 @@ examples:
|
|
|
6541
3344
|
});
|
|
6542
3345
|
return c.text(lines.join("\n"));
|
|
6543
3346
|
});
|
|
6544
|
-
//#endregion
|
|
6545
|
-
//#region lib/engine/local-config/local-config-json-schema.ts
|
|
6546
|
-
/**
|
|
6547
|
-
* Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
|
|
6548
|
-
* `$schema` references in committed `funnel.json` files so editors can give
|
|
6549
|
-
* autocomplete and validation for channels[] (transport) and profiles[]
|
|
6550
|
-
* (launch recipe) without anyone hand-maintaining a separate schema.
|
|
6551
|
-
*/
|
|
6552
|
-
const funnelJsonSchema = () => {
|
|
6553
|
-
return {
|
|
6554
|
-
...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
|
|
6555
|
-
title: "Funnel per-repo launch config",
|
|
6556
|
-
description: "Used by `fnl claude` to declare channels (transport: connectors to materialize into ~/.funnel/settings.json on launch) and profiles (launch recipe: options / env / resume) bound to those channels."
|
|
6557
|
-
};
|
|
6558
|
-
};
|
|
6559
3347
|
const schemaHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel schema — print the JSON Schema for funnel.json
|
|
6560
3348
|
|
|
6561
3349
|
usage: funnel schema
|