@interactive-inc/claude-funnel 0.41.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -9
- package/dist/bin.js +255 -256
- 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/daemon.js +151 -152
- package/dist/gateway.d.ts +3 -0
- package/dist/gateway.js +2 -0
- package/dist/gh-connector-schema-eoTtHbY6.d.ts +14 -0
- package/dist/{gh-connector-schema-eYE4g77K.js → gh-connector-schema-o3Q1-ojL.js} +1 -176
- package/dist/gh-listener-DH-fClQm.js +178 -0
- package/dist/index-ChomoTZ5.d.ts +3404 -0
- package/dist/index.d.ts +11 -4214
- package/dist/index.js +195 -3869
- 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 +16 -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,212 +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
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
19
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
-
//#region lib/engine/id/id-generator.ts
|
|
22
|
-
/**
|
|
23
|
-
* ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
|
|
24
|
-
* MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
|
|
25
|
-
*/
|
|
26
|
-
var FunnelIdGenerator = class {};
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region lib/engine/id/node-id-generator.ts
|
|
29
|
-
var NodeFunnelIdGenerator = class extends FunnelIdGenerator {
|
|
30
|
-
generate() {
|
|
31
|
-
return crypto.randomUUID();
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
//#endregion
|
|
35
|
-
//#region lib/engine/settings/settings-reader.ts
|
|
36
|
-
var FunnelSettingsReader = class {};
|
|
37
|
-
//#endregion
|
|
38
|
-
//#region lib/connectors/connector-config-schema.ts
|
|
39
|
-
const connectorConfigSchema = z.discriminatedUnion("type", [
|
|
40
|
-
slackConnectorSchema,
|
|
41
|
-
ghConnectorSchema,
|
|
42
|
-
discordConnectorSchema,
|
|
43
|
-
scheduleConnectorSchema
|
|
44
|
-
]);
|
|
45
|
-
//#endregion
|
|
46
|
-
//#region lib/engine/settings/settings-schema.ts
|
|
47
|
-
/**
|
|
48
|
-
* Routing mode when multiple WS clients are subscribed to the same channel.
|
|
49
|
-
*
|
|
50
|
-
* - `fanout` (default): every connected client receives every event. Right when each
|
|
51
|
-
* subscriber has its own job (e.g., TUI mirrors, distinct Claude profiles each running
|
|
52
|
-
* their own pipeline against the same source).
|
|
53
|
-
* - `exclusive`: each event is delivered to exactly one connected client, picked
|
|
54
|
-
* round-robin per channel. Right when subscribers are interchangeable workers and you
|
|
55
|
-
* want each event handled once. Tap=all clients (TUI dashboard) always receive,
|
|
56
|
-
* regardless of mode, so they can passively observe.
|
|
57
|
-
*/
|
|
58
|
-
const channelDeliveryModeSchema = z.enum(["fanout", "exclusive"]);
|
|
59
|
-
const channelConfigSchema = z.object({
|
|
60
|
-
id: z.string(),
|
|
61
|
-
name: z.string(),
|
|
62
|
-
delivery: channelDeliveryModeSchema.default("fanout"),
|
|
63
|
-
connectors: z.array(connectorConfigSchema).default([])
|
|
64
|
-
});
|
|
65
|
-
const profileConfigSchema = z.object({
|
|
66
|
-
/** Stable identity (uuid). The primary key everything internal resolves to;
|
|
67
|
-
* survives renames. CLI surfaces still address profiles by `name`. */
|
|
68
|
-
id: z.string(),
|
|
69
|
-
/** Human-facing label used only at the CLI/TUI surface (`--profile <name>`).
|
|
70
|
-
* Renameable; never used as a storage key. */
|
|
71
|
-
name: z.string(),
|
|
72
|
-
path: z.string(),
|
|
73
|
-
channelId: z.string(),
|
|
74
|
-
/** Args prepended to the claude argv on every launch through this profile. */
|
|
75
|
-
options: z.array(z.string()).default([]),
|
|
76
|
-
/** Env vars layered under the launched claude process. process.env wins on collision. */
|
|
77
|
-
env: z.record(z.string(), z.string()).default({}),
|
|
78
|
-
/**
|
|
79
|
-
* When true (the default), funnel resumes this profile's previous claude
|
|
80
|
-
* session via `--session-id`/`--resume`. The id lives in `sessionId` below,
|
|
81
|
-
* scoped to this profile so an unrelated session in the same repo can't bleed
|
|
82
|
-
* in. Set to false for profiles that should always start fresh.
|
|
83
|
-
*/
|
|
84
|
-
resume: z.boolean().default(true),
|
|
85
|
-
/**
|
|
86
|
-
* Execution state, not config: the claude session id this profile last
|
|
87
|
-
* launched. Written by the launcher, read on the next resume. Absent until
|
|
88
|
-
* the first launch; kept inside the profile (rather than a separate file) so
|
|
89
|
-
* the session belongs to the profile by identity and the transport layer
|
|
90
|
-
* (channels) never has to know profiles exist.
|
|
91
|
-
*/
|
|
92
|
-
sessionId: z.string().optional()
|
|
93
|
-
});
|
|
94
|
-
const SETTINGS_VERSION = 1;
|
|
95
|
-
const settingsSchema = z.object({
|
|
96
|
-
/** Schema version. New files always write the current version; older files without one are read as v1. */
|
|
97
|
-
version: z.literal(1).default(1),
|
|
98
|
-
channels: z.array(channelConfigSchema).default([]),
|
|
99
|
-
profiles: z.array(profileConfigSchema).default([])
|
|
100
|
-
});
|
|
101
|
-
//#endregion
|
|
102
|
-
//#region lib/engine/settings/settings-store.ts
|
|
103
|
-
/**
|
|
104
|
-
* Resolves the funnel home dir. Defaults to `~/.funnel`, overridable via
|
|
105
|
-
* `FUNNEL_DIR` so a funnel.json-scoped launch can point everything (settings,
|
|
106
|
-
* gateway pid/token, claude pids) at a repo-local `<repo>/.funnel` and never
|
|
107
|
-
* touch the global home. Read at call time, not module load, so a daemon
|
|
108
|
-
* spawned with the env set resolves the override.
|
|
109
|
-
*/
|
|
110
|
-
function resolveFunnelDir() {
|
|
111
|
-
const override = process.env.FUNNEL_DIR;
|
|
112
|
-
if (override && override.length > 0) return override;
|
|
113
|
-
return join(homedir(), ".funnel");
|
|
114
|
-
}
|
|
115
|
-
const DEFAULT_GATEWAY_PORT = 9742;
|
|
116
|
-
/**
|
|
117
|
-
* Resolves the gateway port. Defaults to 9742 — the port a programmatically
|
|
118
|
-
* hosted gateway (`new Funnel().gatewayServer()`) uses. The `funnel` CLI entry
|
|
119
|
-
* sets `FUNNEL_PORT` to a distinct default so a CLI launch never collides with
|
|
120
|
-
* an embedding app's gateway on 9742. Read at call time so a daemon spawned
|
|
121
|
-
* with the env set resolves the override.
|
|
122
|
-
*/
|
|
123
|
-
function resolveFunnelPort() {
|
|
124
|
-
return Number(process.env.FUNNEL_PORT) || 9742;
|
|
125
|
-
}
|
|
126
|
-
const FUNNEL_DIR = join(homedir(), ".funnel");
|
|
127
|
-
const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
|
|
128
|
-
const defaultFs$5 = new NodeFunnelFileSystem();
|
|
129
|
-
const defaultIdGenerator$2 = new NodeFunnelIdGenerator();
|
|
130
|
-
var FunnelSettingsStore = class extends FunnelSettingsReader {
|
|
131
|
-
path;
|
|
132
|
-
fs;
|
|
133
|
-
idGenerator;
|
|
134
|
-
constructor(deps = {}) {
|
|
135
|
-
super();
|
|
136
|
-
this.path = deps.path ?? SETTINGS_PATH;
|
|
137
|
-
this.fs = deps.fs ?? defaultFs$5;
|
|
138
|
-
this.idGenerator = deps.idGenerator ?? defaultIdGenerator$2;
|
|
139
|
-
Object.freeze(this);
|
|
140
|
-
}
|
|
141
|
-
read() {
|
|
142
|
-
if (!this.fs.existsSync(this.path)) return {
|
|
143
|
-
version: 1,
|
|
144
|
-
channels: [],
|
|
145
|
-
profiles: []
|
|
146
|
-
};
|
|
147
|
-
const content = this.fs.readFileSync(this.path);
|
|
148
|
-
const parsed = JSON.parse(content);
|
|
149
|
-
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`);
|
|
150
|
-
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)}`);
|
|
151
|
-
const minted = this.backfillProfileIds(parsed);
|
|
152
|
-
const result = settingsSchema.safeParse(parsed);
|
|
153
|
-
if (!result.success) throw new Error(`invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`);
|
|
154
|
-
if (minted) this.write(result.data);
|
|
155
|
-
return result.data;
|
|
156
|
-
}
|
|
157
|
-
looksLikeLegacy(parsed) {
|
|
158
|
-
if (!parsed || typeof parsed !== "object") return false;
|
|
159
|
-
const obj = parsed;
|
|
160
|
-
if (Array.isArray(obj.channels)) for (const channel of obj.channels) {
|
|
161
|
-
if (!channel || typeof channel !== "object") continue;
|
|
162
|
-
const ch = channel;
|
|
163
|
-
if (Array.isArray(ch.connectors) && ch.connectors.some((x) => typeof x === "string")) return true;
|
|
164
|
-
if (!("id" in ch) && "name" in ch) return true;
|
|
165
|
-
}
|
|
166
|
-
if (Array.isArray(obj.connectors)) return true;
|
|
167
|
-
if (Array.isArray(obj.repositories)) return true;
|
|
168
|
-
if (Array.isArray(obj.profiles)) for (const profile of obj.profiles) {
|
|
169
|
-
if (!profile || typeof profile !== "object") continue;
|
|
170
|
-
const p = profile;
|
|
171
|
-
if ("repository" in p || "envFiles" in p || "channel" in p && !("channelId" in p)) return true;
|
|
172
|
-
}
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Non-destructive migration for profiles written before `id` existed. Mints a
|
|
177
|
-
* uuid for each profile lacking one and returns whether anything was minted, so
|
|
178
|
-
* `read` can persist it immediately — a profile id must be STABLE across reads,
|
|
179
|
-
* otherwise `setSessionId` (a second read) sees a different id and can't match
|
|
180
|
-
* the one the launch used. Mutates `parsed` in place (freshly JSON-parsed).
|
|
181
|
-
*/
|
|
182
|
-
backfillProfileIds(parsed) {
|
|
183
|
-
if (!parsed || typeof parsed !== "object") return false;
|
|
184
|
-
const obj = parsed;
|
|
185
|
-
if (!Array.isArray(obj.profiles)) return false;
|
|
186
|
-
let minted = false;
|
|
187
|
-
for (const profile of obj.profiles) {
|
|
188
|
-
if (!profile || typeof profile !== "object") continue;
|
|
189
|
-
const p = profile;
|
|
190
|
-
if (typeof p.id !== "string") {
|
|
191
|
-
p.id = this.idGenerator.generate();
|
|
192
|
-
minted = true;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return minted;
|
|
196
|
-
}
|
|
197
|
-
write(settings) {
|
|
198
|
-
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
199
|
-
const versioned = {
|
|
200
|
-
...settings,
|
|
201
|
-
version: 1
|
|
202
|
-
};
|
|
203
|
-
this.fs.writeSecretFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`);
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
//#endregion
|
|
207
27
|
//#region lib/connectors/connector-factory.ts
|
|
208
|
-
const defaultFs$
|
|
209
|
-
const defaultProcess$
|
|
28
|
+
const defaultFs$1 = new NodeFunnelFileSystem();
|
|
29
|
+
const defaultProcess$1 = new NodeFunnelProcessRunner();
|
|
210
30
|
/**
|
|
211
31
|
* Pure factory for per-type listeners and adapters. The factory has no CRUD
|
|
212
32
|
* responsibility — connector configs live inside settings.json under their
|
|
@@ -228,8 +48,8 @@ var FunnelConnectorFactory = class {
|
|
|
228
48
|
slackListenerOptions;
|
|
229
49
|
scheduleListenerOptions;
|
|
230
50
|
constructor(deps = {}) {
|
|
231
|
-
this.fs = deps.fs ?? defaultFs$
|
|
232
|
-
this.process = deps.process ?? defaultProcess$
|
|
51
|
+
this.fs = deps.fs ?? defaultFs$1;
|
|
52
|
+
this.process = deps.process ?? defaultProcess$1;
|
|
233
53
|
this.logger = deps.logger;
|
|
234
54
|
this.diagnosticLog = deps.diagnosticLog;
|
|
235
55
|
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
@@ -364,7 +184,7 @@ const slotFields = (literalKey, envKey, fields, current) => {
|
|
|
364
184
|
return result;
|
|
365
185
|
};
|
|
366
186
|
const defaultClock$1 = new NodeFunnelClock();
|
|
367
|
-
const defaultIdGenerator
|
|
187
|
+
const defaultIdGenerator = new NodeFunnelIdGenerator();
|
|
368
188
|
/**
|
|
369
189
|
* Channels own their connectors. Each channel has a stable id (UUID); the
|
|
370
190
|
* `name` is the human-facing label used by the CLI. Connectors live nested
|
|
@@ -382,9 +202,9 @@ var FunnelChannels = class {
|
|
|
382
202
|
constructor(deps) {
|
|
383
203
|
this.store = deps.store;
|
|
384
204
|
this.factory = deps.factory;
|
|
385
|
-
this.profileChecker = deps.profileChecker;
|
|
205
|
+
this.profileChecker = deps.profileChecker ?? null;
|
|
386
206
|
this.clock = deps.clock ?? defaultClock$1;
|
|
387
|
-
this.idGenerator = deps.idGenerator ?? defaultIdGenerator
|
|
207
|
+
this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
|
|
388
208
|
Object.freeze(this);
|
|
389
209
|
}
|
|
390
210
|
list() {
|
|
@@ -420,7 +240,7 @@ var FunnelChannels = class {
|
|
|
420
240
|
const index = settings.channels.findIndex((c) => c.name === name);
|
|
421
241
|
if (index < 0) throw new Error(`channel "${name}" not found`);
|
|
422
242
|
const channel = settings.channels[index];
|
|
423
|
-
if (channel && this.profileChecker
|
|
243
|
+
if (channel && this.profileChecker?.hasChannelRef(channel.id)) throw new Error(`channel "${name}" is referenced by a profile`);
|
|
424
244
|
settings.channels.splice(index, 1);
|
|
425
245
|
this.store.write(settings);
|
|
426
246
|
}
|
|
@@ -638,175 +458,6 @@ var FunnelChannels = class {
|
|
|
638
458
|
}
|
|
639
459
|
};
|
|
640
460
|
//#endregion
|
|
641
|
-
//#region lib/engine/claude/claude.ts
|
|
642
|
-
const defaultProcess$2 = new NodeFunnelProcessRunner();
|
|
643
|
-
const defaultFs$3 = new NodeFunnelFileSystem();
|
|
644
|
-
const defaultIdGenerator = new NodeFunnelIdGenerator();
|
|
645
|
-
/**
|
|
646
|
-
* Launches Claude Code with funnel pre-wired: ensures the gateway is running,
|
|
647
|
-
* installs the funnel MCP into the target repo's `.mcp.json` if missing,
|
|
648
|
-
* injects `FUNNEL_CHANNEL_ID` into the child env, and writes a per-profile
|
|
649
|
-
* PID file to enforce singleton launches.
|
|
650
|
-
*/
|
|
651
|
-
var FunnelClaude = class {
|
|
652
|
-
channels;
|
|
653
|
-
mcp;
|
|
654
|
-
gateway;
|
|
655
|
-
profiles;
|
|
656
|
-
process;
|
|
657
|
-
fs;
|
|
658
|
-
idGenerator;
|
|
659
|
-
logger;
|
|
660
|
-
pidDir;
|
|
661
|
-
constructor(deps) {
|
|
662
|
-
this.channels = deps.channels;
|
|
663
|
-
this.mcp = deps.mcp;
|
|
664
|
-
this.gateway = deps.gateway;
|
|
665
|
-
this.profiles = deps.profiles;
|
|
666
|
-
this.process = deps.process ?? defaultProcess$2;
|
|
667
|
-
this.fs = deps.fs ?? defaultFs$3;
|
|
668
|
-
this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
|
|
669
|
-
this.logger = deps.logger;
|
|
670
|
-
this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude");
|
|
671
|
-
Object.freeze(this);
|
|
672
|
-
}
|
|
673
|
-
async launch(options) {
|
|
674
|
-
const channel = this.channels.get(options.channel) ?? this.channels.getById(options.channel);
|
|
675
|
-
if (!channel) throw new Error(`channel "${options.channel}" not found`);
|
|
676
|
-
if (options.profileId && this.isRunning(options.profileId)) throw new Error(`profile "${options.profileId}" is already running`);
|
|
677
|
-
const cwd = options.cwd ?? globalThis.process.cwd();
|
|
678
|
-
if ((options.installMcp ?? true) && !this.mcp.findInstalledName(cwd)) {
|
|
679
|
-
this.mcp.install(cwd);
|
|
680
|
-
this.logger?.info(`added funnel MCP to .mcp.json`, { cwd });
|
|
681
|
-
}
|
|
682
|
-
if (!this.gateway.isRunning()) {
|
|
683
|
-
this.logger?.info(`starting gateway automatically`);
|
|
684
|
-
await this.gateway.start();
|
|
685
|
-
}
|
|
686
|
-
if (options.profileId) {
|
|
687
|
-
this.writePidFile(options.profileId);
|
|
688
|
-
this.installCleanup(options.profileId);
|
|
689
|
-
}
|
|
690
|
-
const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
|
|
691
|
-
const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
|
|
692
|
-
const env = this.buildEnv(channel.id, options.env ?? {});
|
|
693
|
-
this.logger?.info(`claude launch`, {
|
|
694
|
-
channel: options.channel,
|
|
695
|
-
channelId: channel.id,
|
|
696
|
-
cwd
|
|
697
|
-
});
|
|
698
|
-
try {
|
|
699
|
-
return await this.process.attach(["claude", ...claudeArgs], {
|
|
700
|
-
cwd,
|
|
701
|
-
env,
|
|
702
|
-
onSpawned: options.onSpawned
|
|
703
|
-
});
|
|
704
|
-
} finally {
|
|
705
|
-
if (options.profileId) this.removePidFile(options.profileId);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
isRunning(profileId) {
|
|
709
|
-
const pid = this.readPid(profileId);
|
|
710
|
-
if (!pid) return false;
|
|
711
|
-
return this.isProcessAlive(pid);
|
|
712
|
-
}
|
|
713
|
-
pidPath(profileId) {
|
|
714
|
-
return join(this.pidDir, `${profileId}.pid`);
|
|
715
|
-
}
|
|
716
|
-
readPid(profileId) {
|
|
717
|
-
const path = this.pidPath(profileId);
|
|
718
|
-
if (!this.fs.existsSync(path)) return null;
|
|
719
|
-
try {
|
|
720
|
-
const content = this.fs.readFileSync(path).trim();
|
|
721
|
-
const pid = Number(content);
|
|
722
|
-
if (!pid || pid <= 0) return null;
|
|
723
|
-
return pid;
|
|
724
|
-
} catch {
|
|
725
|
-
return null;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
writePidFile(profileId) {
|
|
729
|
-
this.fs.mkdirSync(this.pidDir, { recursive: true });
|
|
730
|
-
this.fs.writeFileSync(this.pidPath(profileId), String(globalThis.process.pid));
|
|
731
|
-
}
|
|
732
|
-
removePidFile(profileId) {
|
|
733
|
-
const path = this.pidPath(profileId);
|
|
734
|
-
if (this.fs.existsSync(path)) this.fs.unlink(path);
|
|
735
|
-
}
|
|
736
|
-
installCleanup(profileId) {
|
|
737
|
-
globalThis.process.once("exit", () => this.removePidFile(profileId));
|
|
738
|
-
}
|
|
739
|
-
isProcessAlive(pid) {
|
|
740
|
-
return this.process.isAlive(pid);
|
|
741
|
-
}
|
|
742
|
-
buildArgs(recipeOptions, userArgs, cwd, session) {
|
|
743
|
-
const result = [...recipeOptions, ...userArgs];
|
|
744
|
-
if (session !== null) if (session.mode === "resume") result.push("--resume", session.id);
|
|
745
|
-
else result.push("--session-id", session.id);
|
|
746
|
-
const mcpName = this.mcp.findInstalledName(cwd);
|
|
747
|
-
if (mcpName && !result.includes("--dangerously-load-development-channels") && !result.includes("--channels")) result.push("--dangerously-load-development-channels", `server:${mcpName}`);
|
|
748
|
-
return result;
|
|
749
|
-
}
|
|
750
|
-
/**
|
|
751
|
-
* Decides whether funnel should resume an existing claude session or start
|
|
752
|
-
* a freshly minted one. Backs off when the user already passed a
|
|
753
|
-
* session-shaping flag, since combining them would either confuse claude
|
|
754
|
-
* or override the explicit user intent.
|
|
755
|
-
*
|
|
756
|
-
* The session is owned by the profile (by id), not by cwd: two profiles
|
|
757
|
-
* pointing at the same repo each keep their own conversation, and a launch
|
|
758
|
-
* with no profile never resumes — so an unrelated session in the same repo
|
|
759
|
-
* can't bleed in. The channel never enters into it; sessions belong to the
|
|
760
|
-
* launch layer (profiles), keeping the transport layer ignorant of them.
|
|
761
|
-
*
|
|
762
|
-
* A persisted id is only resumed when its session jsonl still exists on
|
|
763
|
-
* disk. claude errors out on `--resume <id>` for a missing conversation, and
|
|
764
|
-
* a persisted id can outlive its jsonl (claude pruned it, or the very first
|
|
765
|
-
* launch was aborted after the id was written but before the jsonl
|
|
766
|
-
* appeared). When the file is gone we mint a fresh session instead, which
|
|
767
|
-
* overwrites the dangling entry — so the store self-heals.
|
|
768
|
-
*/
|
|
769
|
-
resolveSession(profileId, cwd, userArgs, recipeEnv) {
|
|
770
|
-
for (const arg of userArgs) {
|
|
771
|
-
if (arg === "-c" || arg === "--continue") return null;
|
|
772
|
-
if (arg === "--resume" || arg.startsWith("--resume=")) return null;
|
|
773
|
-
if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
|
|
774
|
-
}
|
|
775
|
-
const existing = this.profiles.getSessionId(profileId);
|
|
776
|
-
if (existing !== null && this.sessionFileExists(cwd, existing, recipeEnv)) return {
|
|
777
|
-
id: existing,
|
|
778
|
-
mode: "resume"
|
|
779
|
-
};
|
|
780
|
-
const fresh = this.idGenerator.generate();
|
|
781
|
-
this.profiles.setSessionId(profileId, fresh);
|
|
782
|
-
return {
|
|
783
|
-
id: fresh,
|
|
784
|
-
mode: "new"
|
|
785
|
-
};
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Mirrors claude's session storage path
|
|
789
|
-
* (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
|
|
790
|
-
* whether a recorded session still exists AND is non-empty. Reads the same
|
|
791
|
-
* `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
|
|
792
|
-
* wrong guess can only ever produce a false negative (start fresh), never a
|
|
793
|
-
* bad resume.
|
|
794
|
-
*/
|
|
795
|
-
sessionFileExists(cwd, sessionId, recipeEnv) {
|
|
796
|
-
const path = join(recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
|
|
797
|
-
if (!this.fs.existsSync(path)) return false;
|
|
798
|
-
return this.fs.readFileSync(path).trim().length > 0;
|
|
799
|
-
}
|
|
800
|
-
buildEnv(channelId, recipeEnv) {
|
|
801
|
-
const env = {};
|
|
802
|
-
for (const [key, value] of Object.entries(recipeEnv)) env[key] = value;
|
|
803
|
-
for (const [key, value] of Object.entries(globalThis.process.env)) if (typeof value === "string") env[key] = value;
|
|
804
|
-
env.FUNNEL_CHANNEL_ID = channelId;
|
|
805
|
-
env.FUNNEL_PORT = String(resolveFunnelPort());
|
|
806
|
-
return env;
|
|
807
|
-
}
|
|
808
|
-
};
|
|
809
|
-
//#endregion
|
|
810
461
|
//#region lib/engine/fs/memory-file-system.ts
|
|
811
462
|
const SECRET_MODE = 384;
|
|
812
463
|
var MemoryFunnelFileSystem = class extends FunnelFileSystem {
|
|
@@ -895,397 +546,6 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
|
|
|
895
546
|
}
|
|
896
547
|
};
|
|
897
548
|
//#endregion
|
|
898
|
-
//#region lib/engine/local-config/local-config-schema.ts
|
|
899
|
-
/**
|
|
900
|
-
* Per-repo launch config (`funnel.json`).
|
|
901
|
-
*
|
|
902
|
-
* `fnl claude` reads this when no global --profile preset is used. It picks one
|
|
903
|
-
* of the declared channels (`--channel <name>` selects by name; otherwise the
|
|
904
|
-
* first entry wins) and materializes its transport (connectors / delivery) into
|
|
905
|
-
* the repo's scoped settings (`~/.funnel/projects/<id>/settings.json`) on launch.
|
|
906
|
-
* Connectors carry no tokens here — a token absent from settings is prompted for
|
|
907
|
-
* at launch (TTY) and saved there, never in the repo.
|
|
908
|
-
*
|
|
909
|
-
* The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
|
|
910
|
-
* the channel: a channel only describes where events come from. `fnl claude`
|
|
911
|
-
* applies the first profile bound to the chosen channel; the recipe is passed
|
|
912
|
-
* straight to the launcher and is not persisted into the global profile list.
|
|
913
|
-
* These profiles are selected by their `channel` binding, not by name.
|
|
914
|
-
*/
|
|
915
|
-
const slackConnectorSpecSchema = z.object({
|
|
916
|
-
type: z.literal("slack"),
|
|
917
|
-
name: z.string(),
|
|
918
|
-
/** Shrink raw Slack events before fanout. Defaults to true. */
|
|
919
|
-
minify: z.boolean().optional()
|
|
920
|
-
});
|
|
921
|
-
const discordConnectorSpecSchema = z.object({
|
|
922
|
-
type: z.literal("discord"),
|
|
923
|
-
name: z.string()
|
|
924
|
-
});
|
|
925
|
-
const ghConnectorSpecSchema = z.object({
|
|
926
|
-
type: z.literal("gh"),
|
|
927
|
-
name: z.string(),
|
|
928
|
-
pollInterval: z.number().int().positive().optional()
|
|
929
|
-
});
|
|
930
|
-
const scheduleConnectorSpecSchema = z.object({
|
|
931
|
-
type: z.literal("schedule"),
|
|
932
|
-
name: z.string()
|
|
933
|
-
});
|
|
934
|
-
const connectorSpecSchema = z.discriminatedUnion("type", [
|
|
935
|
-
slackConnectorSpecSchema,
|
|
936
|
-
discordConnectorSpecSchema,
|
|
937
|
-
ghConnectorSpecSchema,
|
|
938
|
-
scheduleConnectorSpecSchema
|
|
939
|
-
]);
|
|
940
|
-
const channelSpecSchema = z.object({
|
|
941
|
-
name: z.string(),
|
|
942
|
-
connectors: z.array(connectorSpecSchema).optional()
|
|
943
|
-
});
|
|
944
|
-
const profileSpecSchema = z.object({
|
|
945
|
-
/** Handle for `fnl claude --profile <name>`. A profile is only launchable by this name. */
|
|
946
|
-
name: z.string(),
|
|
947
|
-
/** Name of the channel (declared in `channels[]`) this profile binds. The profile depends on the channel, never the reverse. */
|
|
948
|
-
channel: z.string(),
|
|
949
|
-
/** Args prepended to the claude argv on every launch through this profile. */
|
|
950
|
-
options: z.array(z.string()).optional(),
|
|
951
|
-
/** Env vars layered under the launched claude process. process.env wins on collision. */
|
|
952
|
-
env: z.record(z.string(), z.string()).optional(),
|
|
953
|
-
/**
|
|
954
|
-
* When true (the default), funnel injects `--session-id <uuid>` so that
|
|
955
|
-
* relaunching from the same cwd resumes the previous claude session
|
|
956
|
-
* without bleeding into other channels or workspaces. Set to false for
|
|
957
|
-
* profiles that should always start a fresh session.
|
|
958
|
-
*/
|
|
959
|
-
resume: z.boolean().optional()
|
|
960
|
-
});
|
|
961
|
-
const localConfigSchema = z.object({
|
|
962
|
-
$schema: z.string().optional(),
|
|
963
|
-
/**
|
|
964
|
-
* Stable per-repo identifier. funnel writes this on first launch when absent;
|
|
965
|
-
* all funnel state for this repo lives under `~/.funnel/projects/<id>/`, so the
|
|
966
|
-
* repo itself never holds settings or tokens. Committed alongside funnel.json.
|
|
967
|
-
*/
|
|
968
|
-
id: z.string().optional(),
|
|
969
|
-
/** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
|
|
970
|
-
channels: z.array(channelSpecSchema).min(1),
|
|
971
|
-
/** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
|
|
972
|
-
profiles: z.array(profileSpecSchema).optional()
|
|
973
|
-
});
|
|
974
|
-
const LOCAL_CONFIG_FILENAME = "funnel.json";
|
|
975
|
-
//#endregion
|
|
976
|
-
//#region lib/engine/local-config/local-config.ts
|
|
977
|
-
/**
|
|
978
|
-
* Reads `funnel.json` from a directory. Returns `null` when the file is
|
|
979
|
-
* absent so callers can fall through to other resolution paths (default
|
|
980
|
-
* profile, help). Throws on present-but-invalid files so misconfiguration
|
|
981
|
-
* surfaces loudly instead of silently launching the wrong channel.
|
|
982
|
-
*/
|
|
983
|
-
var FunnelLocalConfig = class {
|
|
984
|
-
fs;
|
|
985
|
-
constructor(deps) {
|
|
986
|
-
this.fs = deps.fs;
|
|
987
|
-
Object.freeze(this);
|
|
988
|
-
}
|
|
989
|
-
read(cwd) {
|
|
990
|
-
const path = join(cwd, LOCAL_CONFIG_FILENAME);
|
|
991
|
-
if (!this.fs.existsSync(path)) return null;
|
|
992
|
-
const raw = this.fs.readFileSync(path);
|
|
993
|
-
const parsed = (() => {
|
|
994
|
-
try {
|
|
995
|
-
return JSON.parse(raw);
|
|
996
|
-
} catch (error) {
|
|
997
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
998
|
-
throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
|
|
999
|
-
}
|
|
1000
|
-
})();
|
|
1001
|
-
const result = localConfigSchema.safeParse(parsed);
|
|
1002
|
-
if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
|
|
1003
|
-
this.assertProfilesValid(result.data);
|
|
1004
|
-
return result.data;
|
|
1005
|
-
}
|
|
1006
|
-
assertProfilesValid(config) {
|
|
1007
|
-
const profiles = config.profiles ?? [];
|
|
1008
|
-
if (profiles.length === 0) return;
|
|
1009
|
-
const channelNames = new Set(config.channels.map((channel) => channel.name));
|
|
1010
|
-
const seenNames = /* @__PURE__ */ new Set();
|
|
1011
|
-
for (const profile of profiles) {
|
|
1012
|
-
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[]`);
|
|
1013
|
-
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`);
|
|
1014
|
-
seenNames.add(profile.name);
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
//#endregion
|
|
1019
|
-
//#region lib/engine/token-prompter/token-prompter.ts
|
|
1020
|
-
/**
|
|
1021
|
-
* Asks the user for a secret value on stdin. Used as a last resort when a
|
|
1022
|
-
* funnel.json token field is absent and not present in `~/.funnel`. The Node
|
|
1023
|
-
* implementation refuses to prompt when stdin is not a TTY so non-interactive
|
|
1024
|
-
* launches (CI, agent spawning agent, daemons) fail fast instead of hanging.
|
|
1025
|
-
*/
|
|
1026
|
-
var FunnelTokenPrompter = class {};
|
|
1027
|
-
//#endregion
|
|
1028
|
-
//#region lib/engine/local-config/local-config-sync.ts
|
|
1029
|
-
/**
|
|
1030
|
-
* Reconciles a single funnel.json channel spec with `~/.funnel/settings.json`.
|
|
1031
|
-
* The spec is the source of truth for the channel it declares:
|
|
1032
|
-
*
|
|
1033
|
-
* - missing channel → created
|
|
1034
|
-
* - declared connector matched by name → tokens reconciled
|
|
1035
|
-
* - declared connector matched by token in the same channel under a
|
|
1036
|
-
* different name → renamed in place (then tokens reconciled)
|
|
1037
|
-
* - declared connector with no match → added
|
|
1038
|
-
* - any connector left in the channel that the spec did not touch → removed
|
|
1039
|
-
*
|
|
1040
|
-
* Removal only fires when the channel spec has a `connectors` field. An
|
|
1041
|
-
* absent field means "do not manage connectors from here" and leaves
|
|
1042
|
-
* everything in `~/.funnel` alone. Other channels in funnel.json (not
|
|
1043
|
-
* passed to this call) are untouched.
|
|
1044
|
-
*
|
|
1045
|
-
* Returns the per-connector change set so callers (e.g. the claude launcher)
|
|
1046
|
-
* can drive listener hot-reload on the gateway after settings are written.
|
|
1047
|
-
*/
|
|
1048
|
-
var FunnelLocalConfigSync = class {
|
|
1049
|
-
channels;
|
|
1050
|
-
prompter;
|
|
1051
|
-
constructor(deps) {
|
|
1052
|
-
this.channels = deps.channels;
|
|
1053
|
-
this.prompter = deps.prompter;
|
|
1054
|
-
Object.freeze(this);
|
|
1055
|
-
}
|
|
1056
|
-
async ensure(channel) {
|
|
1057
|
-
if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
|
|
1058
|
-
if (channel.connectors === void 0) return {
|
|
1059
|
-
touched: [],
|
|
1060
|
-
removed: []
|
|
1061
|
-
};
|
|
1062
|
-
const touched = [];
|
|
1063
|
-
const touchedIds = /* @__PURE__ */ new Set();
|
|
1064
|
-
for (const spec of channel.connectors) {
|
|
1065
|
-
const outcome = await this.ensureConnector(channel.name, spec);
|
|
1066
|
-
touched.push({
|
|
1067
|
-
name: outcome.name,
|
|
1068
|
-
changed: outcome.changed
|
|
1069
|
-
});
|
|
1070
|
-
touchedIds.add(outcome.id);
|
|
1071
|
-
}
|
|
1072
|
-
return {
|
|
1073
|
-
touched,
|
|
1074
|
-
removed: this.removeExtras(channel.name, touchedIds)
|
|
1075
|
-
};
|
|
1076
|
-
}
|
|
1077
|
-
async ensureConnector(channelName, spec) {
|
|
1078
|
-
if (spec.type === "slack") return await this.ensureSlack(channelName, spec);
|
|
1079
|
-
if (spec.type === "discord") return await this.ensureDiscord(channelName, spec);
|
|
1080
|
-
if (spec.type === "gh") return this.ensureGh(channelName, spec);
|
|
1081
|
-
return this.ensureSchedule(channelName, spec);
|
|
1082
|
-
}
|
|
1083
|
-
async ensureSlack(channelName, spec) {
|
|
1084
|
-
const byName = this.findExistingSlack(channelName, spec.name);
|
|
1085
|
-
const bot = await this.resolveSlot({
|
|
1086
|
-
label: `${spec.name}.botToken`,
|
|
1087
|
-
existingLiteral: byName?.botToken,
|
|
1088
|
-
existingEnv: byName?.botTokenEnv
|
|
1089
|
-
});
|
|
1090
|
-
const app = await this.resolveSlot({
|
|
1091
|
-
label: `${spec.name}.appToken`,
|
|
1092
|
-
existingLiteral: byName?.appToken,
|
|
1093
|
-
existingEnv: byName?.appTokenEnv
|
|
1094
|
-
});
|
|
1095
|
-
const update = {
|
|
1096
|
-
botToken: bot.token,
|
|
1097
|
-
botTokenEnv: bot.tokenEnv,
|
|
1098
|
-
appToken: app.token,
|
|
1099
|
-
appTokenEnv: app.tokenEnv
|
|
1100
|
-
};
|
|
1101
|
-
if (byName) {
|
|
1102
|
-
if (!(byName.botToken === bot.token && byName.botTokenEnv === bot.tokenEnv && byName.appToken === app.token && byName.appTokenEnv === app.tokenEnv)) {
|
|
1103
|
-
this.channels.updateSlackConnector(channelName, spec.name, update);
|
|
1104
|
-
return {
|
|
1105
|
-
id: byName.id,
|
|
1106
|
-
name: spec.name,
|
|
1107
|
-
changed: true
|
|
1108
|
-
};
|
|
1109
|
-
}
|
|
1110
|
-
return {
|
|
1111
|
-
id: byName.id,
|
|
1112
|
-
name: spec.name,
|
|
1113
|
-
changed: false
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
return {
|
|
1117
|
-
id: this.channels.addConnector(channelName, {
|
|
1118
|
-
type: "slack",
|
|
1119
|
-
name: spec.name,
|
|
1120
|
-
...update,
|
|
1121
|
-
...spec.minify !== void 0 ? { minify: spec.minify } : {}
|
|
1122
|
-
}).id,
|
|
1123
|
-
name: spec.name,
|
|
1124
|
-
changed: true
|
|
1125
|
-
};
|
|
1126
|
-
}
|
|
1127
|
-
async ensureDiscord(channelName, spec) {
|
|
1128
|
-
const byName = this.findExistingDiscord(channelName, spec.name);
|
|
1129
|
-
const bot = await this.resolveSlot({
|
|
1130
|
-
label: `${spec.name}.botToken`,
|
|
1131
|
-
existingLiteral: byName?.botToken,
|
|
1132
|
-
existingEnv: byName?.botTokenEnv
|
|
1133
|
-
});
|
|
1134
|
-
const update = {
|
|
1135
|
-
botToken: bot.token,
|
|
1136
|
-
botTokenEnv: bot.tokenEnv
|
|
1137
|
-
};
|
|
1138
|
-
if (byName) {
|
|
1139
|
-
if (byName.botToken !== bot.token || byName.botTokenEnv !== bot.tokenEnv) {
|
|
1140
|
-
this.channels.updateDiscordConnector(channelName, spec.name, update);
|
|
1141
|
-
return {
|
|
1142
|
-
id: byName.id,
|
|
1143
|
-
name: spec.name,
|
|
1144
|
-
changed: true
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
return {
|
|
1148
|
-
id: byName.id,
|
|
1149
|
-
name: spec.name,
|
|
1150
|
-
changed: false
|
|
1151
|
-
};
|
|
1152
|
-
}
|
|
1153
|
-
return {
|
|
1154
|
-
id: this.channels.addConnector(channelName, {
|
|
1155
|
-
type: "discord",
|
|
1156
|
-
name: spec.name,
|
|
1157
|
-
...update
|
|
1158
|
-
}).id,
|
|
1159
|
-
name: spec.name,
|
|
1160
|
-
changed: true
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
ensureGh(channelName, spec) {
|
|
1164
|
-
const existing = this.channels.getConnector(channelName, spec.name);
|
|
1165
|
-
if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
|
|
1166
|
-
if (existing && existing.type === "gh") {
|
|
1167
|
-
if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) {
|
|
1168
|
-
this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
|
|
1169
|
-
return {
|
|
1170
|
-
id: existing.id,
|
|
1171
|
-
name: spec.name,
|
|
1172
|
-
changed: true
|
|
1173
|
-
};
|
|
1174
|
-
}
|
|
1175
|
-
return {
|
|
1176
|
-
id: existing.id,
|
|
1177
|
-
name: spec.name,
|
|
1178
|
-
changed: false
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
return {
|
|
1182
|
-
id: this.channels.addConnector(channelName, {
|
|
1183
|
-
type: "gh",
|
|
1184
|
-
name: spec.name,
|
|
1185
|
-
...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
|
|
1186
|
-
}).id,
|
|
1187
|
-
name: spec.name,
|
|
1188
|
-
changed: true
|
|
1189
|
-
};
|
|
1190
|
-
}
|
|
1191
|
-
ensureSchedule(channelName, spec) {
|
|
1192
|
-
const existing = this.channels.getConnector(channelName, spec.name);
|
|
1193
|
-
if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
|
|
1194
|
-
if (existing && existing.type === "schedule") return {
|
|
1195
|
-
id: existing.id,
|
|
1196
|
-
name: spec.name,
|
|
1197
|
-
changed: false
|
|
1198
|
-
};
|
|
1199
|
-
return {
|
|
1200
|
-
id: this.channels.addConnector(channelName, {
|
|
1201
|
-
type: "schedule",
|
|
1202
|
-
name: spec.name
|
|
1203
|
-
}).id,
|
|
1204
|
-
name: spec.name,
|
|
1205
|
-
changed: true
|
|
1206
|
-
};
|
|
1207
|
-
}
|
|
1208
|
-
findExistingSlack(channelName, connectorName) {
|
|
1209
|
-
const existing = this.channels.getConnector(channelName, connectorName);
|
|
1210
|
-
if (!existing) return null;
|
|
1211
|
-
if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
|
|
1212
|
-
return existing;
|
|
1213
|
-
}
|
|
1214
|
-
findExistingDiscord(channelName, connectorName) {
|
|
1215
|
-
const existing = this.channels.getConnector(channelName, connectorName);
|
|
1216
|
-
if (!existing) return null;
|
|
1217
|
-
if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
|
|
1218
|
-
return existing;
|
|
1219
|
-
}
|
|
1220
|
-
removeExtras(channelName, touched) {
|
|
1221
|
-
const channel = this.channels.get(channelName);
|
|
1222
|
-
if (!channel) return [];
|
|
1223
|
-
const stale = channel.connectors.filter((c) => !touched.has(c.id));
|
|
1224
|
-
for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
|
|
1225
|
-
return stale.map((c) => c.name);
|
|
1226
|
-
}
|
|
1227
|
-
/**
|
|
1228
|
-
* Decides how a single token slot is stored in settings.json. funnel.json
|
|
1229
|
-
* never carries tokens, so the only sources are a value already in
|
|
1230
|
-
* settings.json (carried over verbatim, whichever form it was — literal or an
|
|
1231
|
-
* `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
|
|
1232
|
-
* literal (throws when stdin is not a TTY). Either way the secret lands in the
|
|
1233
|
-
* repo-scoped settings, never in the repo itself.
|
|
1234
|
-
*/
|
|
1235
|
-
async resolveSlot(input) {
|
|
1236
|
-
if (input.existingEnv !== void 0) return {
|
|
1237
|
-
token: void 0,
|
|
1238
|
-
tokenEnv: input.existingEnv
|
|
1239
|
-
};
|
|
1240
|
-
if (input.existingLiteral !== void 0) return {
|
|
1241
|
-
token: input.existingLiteral,
|
|
1242
|
-
tokenEnv: void 0
|
|
1243
|
-
};
|
|
1244
|
-
return {
|
|
1245
|
-
token: await this.prompter.promptSecret(input.label),
|
|
1246
|
-
tokenEnv: void 0
|
|
1247
|
-
};
|
|
1248
|
-
}
|
|
1249
|
-
};
|
|
1250
|
-
//#endregion
|
|
1251
|
-
//#region lib/engine/local-config/local-config-writer.ts
|
|
1252
|
-
const isRecord = (value) => {
|
|
1253
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1254
|
-
};
|
|
1255
|
-
const withIdFirst = (config, id) => {
|
|
1256
|
-
const ordered = {};
|
|
1257
|
-
if (config.$schema !== void 0) ordered.$schema = config.$schema;
|
|
1258
|
-
ordered.id = id;
|
|
1259
|
-
for (const key of Object.keys(config)) {
|
|
1260
|
-
if (key === "$schema" || key === "id") continue;
|
|
1261
|
-
ordered[key] = config[key];
|
|
1262
|
-
}
|
|
1263
|
-
return ordered;
|
|
1264
|
-
};
|
|
1265
|
-
/**
|
|
1266
|
-
* The one path that mutates the repo-committed funnel.json, and it only ever
|
|
1267
|
-
* inserts `id`. On first launch a repo has no `id`; funnel generates one and
|
|
1268
|
-
* writes it back here so future launches resolve the same `~/.funnel/projects/<id>/`.
|
|
1269
|
-
* Idempotent — a no-op once `id` is present. Kept separate from the read-only
|
|
1270
|
-
* FunnelLocalConfig so reads stay side-effect free.
|
|
1271
|
-
*/
|
|
1272
|
-
var FunnelLocalConfigWriter = class {
|
|
1273
|
-
fs;
|
|
1274
|
-
constructor(deps) {
|
|
1275
|
-
this.fs = deps.fs;
|
|
1276
|
-
Object.freeze(this);
|
|
1277
|
-
}
|
|
1278
|
-
ensureId(cwd, id) {
|
|
1279
|
-
const path = join(cwd, LOCAL_CONFIG_FILENAME);
|
|
1280
|
-
if (!this.fs.existsSync(path)) return;
|
|
1281
|
-
const parsed = JSON.parse(this.fs.readFileSync(path));
|
|
1282
|
-
if (!isRecord(parsed)) return;
|
|
1283
|
-
if (typeof parsed.id === "string" && parsed.id !== "") return;
|
|
1284
|
-
const ordered = withIdFirst(parsed, id);
|
|
1285
|
-
this.fs.writeFileSync(path, `${JSON.stringify(ordered, null, 2)}\n`);
|
|
1286
|
-
}
|
|
1287
|
-
};
|
|
1288
|
-
//#endregion
|
|
1289
549
|
//#region lib/engine/logger/memory-logger.ts
|
|
1290
550
|
var MemoryFunnelLogger = class extends FunnelLogger {
|
|
1291
551
|
file = null;
|
|
@@ -1316,93 +576,6 @@ var MemoryFunnelLogger = class extends FunnelLogger {
|
|
|
1316
576
|
}
|
|
1317
577
|
};
|
|
1318
578
|
//#endregion
|
|
1319
|
-
//#region lib/engine/mcp/mcp.ts
|
|
1320
|
-
const FUNNEL_MCP_COMMAND = "bun";
|
|
1321
|
-
const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
|
|
1322
|
-
const FUNNEL_MCP_NAME = "funnel";
|
|
1323
|
-
const mcpEntrySchema = z.object({
|
|
1324
|
-
command: z.string().optional(),
|
|
1325
|
-
args: z.array(z.string()).optional()
|
|
1326
|
-
});
|
|
1327
|
-
const mcpConfigSchema = z.object({ mcpServers: z.record(z.string(), mcpEntrySchema).optional() });
|
|
1328
|
-
const defaultFs$2 = new NodeFunnelFileSystem();
|
|
1329
|
-
/**
|
|
1330
|
-
* Installs/uninstalls the funnel MCP entry into a target repository's
|
|
1331
|
-
* `.mcp.json`. Detects an existing entry by command match so renaming is
|
|
1332
|
-
* preserved across re-installs.
|
|
1333
|
-
*/
|
|
1334
|
-
var FunnelMcp = class {
|
|
1335
|
-
fs;
|
|
1336
|
-
constructor(deps = {}) {
|
|
1337
|
-
this.fs = deps.fs ?? defaultFs$2;
|
|
1338
|
-
Object.freeze(this);
|
|
1339
|
-
}
|
|
1340
|
-
install(repoPath) {
|
|
1341
|
-
if (!this.fs.existsSync(repoPath)) throw new Error(`repository does not exist: ${repoPath}`);
|
|
1342
|
-
const config = this.readConfig(repoPath);
|
|
1343
|
-
const servers = config.mcpServers ?? {};
|
|
1344
|
-
const targetName = this.findServerName(servers) ?? "funnel";
|
|
1345
|
-
servers[targetName] = {
|
|
1346
|
-
command: "bun",
|
|
1347
|
-
args: FUNNEL_MCP_ARGS
|
|
1348
|
-
};
|
|
1349
|
-
this.writeConfig(repoPath, {
|
|
1350
|
-
...config,
|
|
1351
|
-
mcpServers: servers
|
|
1352
|
-
});
|
|
1353
|
-
}
|
|
1354
|
-
uninstall(repoPath) {
|
|
1355
|
-
if (!this.fs.existsSync(repoPath)) return;
|
|
1356
|
-
const config = this.readConfig(repoPath);
|
|
1357
|
-
const servers = config.mcpServers ?? {};
|
|
1358
|
-
const name = this.findServerName(servers);
|
|
1359
|
-
if (!name) return;
|
|
1360
|
-
const next = { ...servers };
|
|
1361
|
-
delete next[name];
|
|
1362
|
-
this.writeConfig(repoPath, {
|
|
1363
|
-
...config,
|
|
1364
|
-
mcpServers: next
|
|
1365
|
-
});
|
|
1366
|
-
}
|
|
1367
|
-
findInstalledName(cwd) {
|
|
1368
|
-
const config = this.readConfig(cwd);
|
|
1369
|
-
return this.findServerName(config.mcpServers ?? {});
|
|
1370
|
-
}
|
|
1371
|
-
findServerName(servers) {
|
|
1372
|
-
for (const entry of Object.entries(servers)) {
|
|
1373
|
-
const name = entry[0];
|
|
1374
|
-
const value = entry[1];
|
|
1375
|
-
if (this.isFunnelEntry(value)) return name;
|
|
1376
|
-
}
|
|
1377
|
-
return null;
|
|
1378
|
-
}
|
|
1379
|
-
isFunnelEntry(value) {
|
|
1380
|
-
if (!value) return false;
|
|
1381
|
-
if (value.command === "bun" && value.args?.[0] === "funnel") return true;
|
|
1382
|
-
if (value.command === "funnel") return true;
|
|
1383
|
-
return false;
|
|
1384
|
-
}
|
|
1385
|
-
readConfig(repoPath) {
|
|
1386
|
-
const mcpPath = join(repoPath, ".mcp.json");
|
|
1387
|
-
if (!this.fs.existsSync(mcpPath)) return {};
|
|
1388
|
-
const content = this.fs.readFileSync(mcpPath).trim();
|
|
1389
|
-
if (!content) return {};
|
|
1390
|
-
let parsed;
|
|
1391
|
-
try {
|
|
1392
|
-
parsed = JSON.parse(content);
|
|
1393
|
-
} catch (error) {
|
|
1394
|
-
throw new Error(`invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1395
|
-
}
|
|
1396
|
-
const result = mcpConfigSchema.safeParse(parsed);
|
|
1397
|
-
if (!result.success) throw new Error(`invalid .mcp.json (${mcpPath}): ${result.error.message}`);
|
|
1398
|
-
return result.data;
|
|
1399
|
-
}
|
|
1400
|
-
writeConfig(repoPath, config) {
|
|
1401
|
-
const mcpPath = join(repoPath, ".mcp.json");
|
|
1402
|
-
this.fs.writeFileSync(mcpPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1403
|
-
}
|
|
1404
|
-
};
|
|
1405
|
-
//#endregion
|
|
1406
579
|
//#region lib/engine/process/memory-process-runner.ts
|
|
1407
580
|
const empty = {
|
|
1408
581
|
exitCode: 0,
|
|
@@ -1503,182 +676,6 @@ var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
|
|
|
1503
676
|
}
|
|
1504
677
|
};
|
|
1505
678
|
//#endregion
|
|
1506
|
-
//#region lib/engine/profiles/profiles.ts
|
|
1507
|
-
/**
|
|
1508
|
-
* Named launch presets for `fnl claude`. Each profile bundles a working
|
|
1509
|
-
* directory, the channel id its Claude instance subscribes to, and the launch
|
|
1510
|
-
* recipe (`options` prepended to the claude argv, `env` layered under the
|
|
1511
|
-
* process, `resume` toggling session reuse). Implements ProfileChannelChecker
|
|
1512
|
-
* so FunnelChannels can refuse to remove a channel that is still referenced.
|
|
1513
|
-
*
|
|
1514
|
-
* Each profile has a stable `id` (uuid) minted at `add`. That id is the unit
|
|
1515
|
-
* everything internal keys on — the PID file, the resumable session id — so a
|
|
1516
|
-
* rename never strands either. `name` is purely the CLI/TUI handle; the CRUD
|
|
1517
|
-
* methods here take it because that is what the user types, but resolve to the
|
|
1518
|
-
* id before touching id-keyed state. The first array entry is the default
|
|
1519
|
-
* profile; `asDefault` reorders to put one first.
|
|
1520
|
-
*
|
|
1521
|
-
* `channelId` always stores the channel's stable id (uuid). CLI surfaces
|
|
1522
|
-
* resolve channel name → id before calling `add`/`update` here.
|
|
1523
|
-
*/
|
|
1524
|
-
var FunnelProfiles = class {
|
|
1525
|
-
store;
|
|
1526
|
-
idGenerator;
|
|
1527
|
-
constructor(deps) {
|
|
1528
|
-
this.store = deps.store;
|
|
1529
|
-
this.idGenerator = deps.idGenerator;
|
|
1530
|
-
Object.freeze(this);
|
|
1531
|
-
}
|
|
1532
|
-
list() {
|
|
1533
|
-
return this.store.read().profiles;
|
|
1534
|
-
}
|
|
1535
|
-
get(name) {
|
|
1536
|
-
return this.list().find((p) => p.name === name) ?? null;
|
|
1537
|
-
}
|
|
1538
|
-
getById(id) {
|
|
1539
|
-
return this.list().find((p) => p.id === id) ?? null;
|
|
1540
|
-
}
|
|
1541
|
-
getDefault() {
|
|
1542
|
-
return this.list()[0] ?? null;
|
|
1543
|
-
}
|
|
1544
|
-
add(input) {
|
|
1545
|
-
const settings = this.store.read();
|
|
1546
|
-
if (settings.profiles.some((p) => p.name === input.name)) throw new Error(`profile "${input.name}" already exists`);
|
|
1547
|
-
if (!settings.channels.some((c) => c.id === input.channelId)) throw new Error(`channel id "${input.channelId}" not found`);
|
|
1548
|
-
settings.profiles.push({
|
|
1549
|
-
id: this.idGenerator.generate(),
|
|
1550
|
-
name: input.name,
|
|
1551
|
-
path: input.path,
|
|
1552
|
-
channelId: input.channelId,
|
|
1553
|
-
options: input.options ?? [],
|
|
1554
|
-
env: input.env ?? {},
|
|
1555
|
-
resume: input.resume ?? true
|
|
1556
|
-
});
|
|
1557
|
-
this.store.write(settings);
|
|
1558
|
-
}
|
|
1559
|
-
remove(name) {
|
|
1560
|
-
const settings = this.store.read();
|
|
1561
|
-
const index = settings.profiles.findIndex((p) => p.name === name);
|
|
1562
|
-
if (index < 0) throw new Error(`profile "${name}" not found`);
|
|
1563
|
-
settings.profiles.splice(index, 1);
|
|
1564
|
-
this.store.write(settings);
|
|
1565
|
-
}
|
|
1566
|
-
rename(oldName, newName) {
|
|
1567
|
-
const settings = this.store.read();
|
|
1568
|
-
const profile = settings.profiles.find((p) => p.name === oldName);
|
|
1569
|
-
if (!profile) throw new Error(`profile "${oldName}" not found`);
|
|
1570
|
-
if (settings.profiles.some((p) => p.name === newName)) throw new Error(`profile "${newName}" already exists`);
|
|
1571
|
-
profile.name = newName;
|
|
1572
|
-
this.store.write(settings);
|
|
1573
|
-
}
|
|
1574
|
-
asDefault(name) {
|
|
1575
|
-
const settings = this.store.read();
|
|
1576
|
-
const index = settings.profiles.findIndex((p) => p.name === name);
|
|
1577
|
-
if (index < 0) throw new Error(`profile "${name}" not found`);
|
|
1578
|
-
if (index === 0) return;
|
|
1579
|
-
const [profile] = settings.profiles.splice(index, 1);
|
|
1580
|
-
if (!profile) return;
|
|
1581
|
-
settings.profiles.unshift(profile);
|
|
1582
|
-
this.store.write(settings);
|
|
1583
|
-
}
|
|
1584
|
-
hasChannelRef(channelId) {
|
|
1585
|
-
return this.store.read().profiles.some((p) => p.channelId === channelId);
|
|
1586
|
-
}
|
|
1587
|
-
/** Resumable claude session id last launched by this profile (by id), or null. */
|
|
1588
|
-
getSessionId(id) {
|
|
1589
|
-
return this.getById(id)?.sessionId ?? null;
|
|
1590
|
-
}
|
|
1591
|
-
/** Records the claude session id this profile launched, overwriting any prior one. */
|
|
1592
|
-
setSessionId(id, sessionId) {
|
|
1593
|
-
const settings = this.store.read();
|
|
1594
|
-
const profile = settings.profiles.find((p) => p.id === id);
|
|
1595
|
-
if (!profile) throw new Error(`profile id "${id}" not found`);
|
|
1596
|
-
profile.sessionId = sessionId;
|
|
1597
|
-
this.store.write(settings);
|
|
1598
|
-
}
|
|
1599
|
-
update(name, fields) {
|
|
1600
|
-
const settings = this.store.read();
|
|
1601
|
-
const profile = settings.profiles.find((p) => p.name === name);
|
|
1602
|
-
if (!profile) throw new Error(`profile "${name}" not found`);
|
|
1603
|
-
if (fields.channelId !== void 0) {
|
|
1604
|
-
if (!settings.channels.some((c) => c.id === fields.channelId)) throw new Error(`channel id "${fields.channelId}" not found`);
|
|
1605
|
-
profile.channelId = fields.channelId;
|
|
1606
|
-
}
|
|
1607
|
-
if (fields.path !== void 0) profile.path = fields.path;
|
|
1608
|
-
if (fields.options !== void 0) profile.options = fields.options;
|
|
1609
|
-
if (fields.env !== void 0) profile.env = fields.env;
|
|
1610
|
-
if (fields.resume !== void 0) profile.resume = fields.resume;
|
|
1611
|
-
this.store.write(settings);
|
|
1612
|
-
}
|
|
1613
|
-
};
|
|
1614
|
-
//#endregion
|
|
1615
|
-
//#region lib/engine/token-prompter/node-token-prompter.ts
|
|
1616
|
-
const STAR = "*";
|
|
1617
|
-
const CR = "\r";
|
|
1618
|
-
const LF = "\n";
|
|
1619
|
-
const BACKSPACE = String.fromCharCode(8);
|
|
1620
|
-
const DEL = String.fromCharCode(127);
|
|
1621
|
-
const CTRL_C = String.fromCharCode(3);
|
|
1622
|
-
const CTRL_D = String.fromCharCode(4);
|
|
1623
|
-
/**
|
|
1624
|
-
* Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
|
|
1625
|
-
* can see progress without exposing the token. Refuses to prompt when stdin
|
|
1626
|
-
* is not a TTY — callers should surface the resulting error with a hint
|
|
1627
|
-
* pointing at the corresponding env var or CLI command.
|
|
1628
|
-
*/
|
|
1629
|
-
var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
|
|
1630
|
-
async promptSecret(label) {
|
|
1631
|
-
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.`);
|
|
1632
|
-
stderr.write(`${label}: `);
|
|
1633
|
-
const wasRaw = stdin.isRaw;
|
|
1634
|
-
stdin.setRawMode(true);
|
|
1635
|
-
stdin.resume();
|
|
1636
|
-
try {
|
|
1637
|
-
return await this.readSecret();
|
|
1638
|
-
} finally {
|
|
1639
|
-
stdin.setRawMode(wasRaw);
|
|
1640
|
-
stdin.pause();
|
|
1641
|
-
stderr.write(LF);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
readSecret() {
|
|
1645
|
-
return new Promise((resolve, reject) => {
|
|
1646
|
-
let buffer = "";
|
|
1647
|
-
const onData = (chunk) => {
|
|
1648
|
-
for (const byte of chunk) {
|
|
1649
|
-
const char = String.fromCharCode(byte);
|
|
1650
|
-
if (char === LF || char === CR) {
|
|
1651
|
-
stdin.off("data", onData);
|
|
1652
|
-
resolve(buffer);
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
if (char === CTRL_C) {
|
|
1656
|
-
stdin.off("data", onData);
|
|
1657
|
-
reject(/* @__PURE__ */ new Error("prompt cancelled"));
|
|
1658
|
-
return;
|
|
1659
|
-
}
|
|
1660
|
-
if (char === CTRL_D) {
|
|
1661
|
-
stdin.off("data", onData);
|
|
1662
|
-
if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
|
|
1663
|
-
else resolve(buffer);
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
if (char === BACKSPACE || char === DEL) {
|
|
1667
|
-
if (buffer.length > 0) {
|
|
1668
|
-
buffer = buffer.slice(0, -1);
|
|
1669
|
-
stderr.write("\b \b");
|
|
1670
|
-
}
|
|
1671
|
-
continue;
|
|
1672
|
-
}
|
|
1673
|
-
buffer += char;
|
|
1674
|
-
stderr.write(STAR);
|
|
1675
|
-
}
|
|
1676
|
-
};
|
|
1677
|
-
stdin.on("data", onData);
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1680
|
-
};
|
|
1681
|
-
//#endregion
|
|
1682
679
|
//#region lib/engine/settings/mock-settings-reader.ts
|
|
1683
680
|
const createSettings = (partial = {}) => ({
|
|
1684
681
|
version: 1,
|
|
@@ -1700,18 +697,6 @@ var MockFunnelSettingsReader = class extends FunnelSettingsReader {
|
|
|
1700
697
|
}
|
|
1701
698
|
};
|
|
1702
699
|
//#endregion
|
|
1703
|
-
//#region lib/engine/settings/tmp-dir.ts
|
|
1704
|
-
/**
|
|
1705
|
-
* Resolves the funnel temp/log root for the current OS. Defaults to
|
|
1706
|
-
* `<os.tmpdir()>/funnel` so Windows lands under `%TEMP%\funnel` and POSIX
|
|
1707
|
-
* lands under `/tmp/funnel`. Callers may override via `FUNNEL_TMP_DIR`.
|
|
1708
|
-
*/
|
|
1709
|
-
function funnelTmpDir() {
|
|
1710
|
-
const override = process.env.FUNNEL_TMP_DIR;
|
|
1711
|
-
if (override && override.length > 0) return override;
|
|
1712
|
-
return join(tmpdir(), "funnel");
|
|
1713
|
-
}
|
|
1714
|
-
//#endregion
|
|
1715
700
|
//#region lib/engine/time/memory-clock.ts
|
|
1716
701
|
var MemoryFunnelClock = class extends FunnelClock {
|
|
1717
702
|
current;
|
|
@@ -1730,85 +715,6 @@ var MemoryFunnelClock = class extends FunnelClock {
|
|
|
1730
715
|
}
|
|
1731
716
|
};
|
|
1732
717
|
//#endregion
|
|
1733
|
-
//#region lib/gateway/publish-schema.ts
|
|
1734
|
-
/**
|
|
1735
|
-
* Shared schema for `POST /channels/:channel/publish` — used by both the
|
|
1736
|
-
* gateway route handler (input validation) and the CLI / programmable client
|
|
1737
|
-
* (request shape). The route resolves `channel` from the path; this body
|
|
1738
|
-
* covers everything else.
|
|
1739
|
-
*/
|
|
1740
|
-
const publishRequestSchema = z.object({
|
|
1741
|
-
content: z.string().min(1),
|
|
1742
|
-
meta: z.record(z.string(), z.string()).optional(),
|
|
1743
|
-
connector: z.string().min(1).optional(),
|
|
1744
|
-
/**
|
|
1745
|
-
* Address the event to a single subscriber. When set, only the WS client that
|
|
1746
|
-
* declared this id at upgrade time (`?id=<subscriberId>`) receives it among the
|
|
1747
|
-
* channel's regular subscribers; tap=all observers still see it. Omit for the
|
|
1748
|
-
* default fanout. The route surfaces it to subscribers as `meta.target`.
|
|
1749
|
-
*/
|
|
1750
|
-
target: z.string().min(1).optional()
|
|
1751
|
-
});
|
|
1752
|
-
const publishResponseSchema = z.object({
|
|
1753
|
-
ok: z.literal(true),
|
|
1754
|
-
offset: z.number().int().nonnegative()
|
|
1755
|
-
});
|
|
1756
|
-
//#endregion
|
|
1757
|
-
//#region lib/gateway/channel-publisher.ts
|
|
1758
|
-
const OFFLINE$1 = { state: "offline" };
|
|
1759
|
-
/**
|
|
1760
|
-
* HTTP client for `POST /channels/:channel/publish` on a running gateway
|
|
1761
|
-
* daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
|
|
1762
|
-
* can branch without exceptions, mirroring `FunnelListenersClient`.
|
|
1763
|
-
*/
|
|
1764
|
-
var FunnelChannelPublisher = class {
|
|
1765
|
-
port;
|
|
1766
|
-
isDaemonRunning;
|
|
1767
|
-
getToken;
|
|
1768
|
-
constructor(deps) {
|
|
1769
|
-
this.port = deps.port;
|
|
1770
|
-
this.isDaemonRunning = deps.isDaemonRunning;
|
|
1771
|
-
this.getToken = deps.getToken ?? (() => null);
|
|
1772
|
-
Object.freeze(this);
|
|
1773
|
-
}
|
|
1774
|
-
async publish(channelName, request) {
|
|
1775
|
-
if (!this.isDaemonRunning()) return OFFLINE$1;
|
|
1776
|
-
try {
|
|
1777
|
-
const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
|
|
1778
|
-
const res = await fetch(url, {
|
|
1779
|
-
method: "POST",
|
|
1780
|
-
headers: {
|
|
1781
|
-
...this.authHeaders(),
|
|
1782
|
-
"content-type": "application/json"
|
|
1783
|
-
},
|
|
1784
|
-
body: JSON.stringify(request)
|
|
1785
|
-
});
|
|
1786
|
-
if (!res.ok) return {
|
|
1787
|
-
state: "error",
|
|
1788
|
-
reason: await res.text() || `HTTP ${res.status}`
|
|
1789
|
-
};
|
|
1790
|
-
const parsed = publishResponseSchema.safeParse(await res.json());
|
|
1791
|
-
if (!parsed.success) return {
|
|
1792
|
-
state: "error",
|
|
1793
|
-
reason: "malformed daemon response"
|
|
1794
|
-
};
|
|
1795
|
-
return {
|
|
1796
|
-
state: "ok",
|
|
1797
|
-
offset: parsed.data.offset
|
|
1798
|
-
};
|
|
1799
|
-
} catch (error) {
|
|
1800
|
-
return {
|
|
1801
|
-
state: "error",
|
|
1802
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
1803
|
-
};
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
authHeaders() {
|
|
1807
|
-
const token = this.getToken();
|
|
1808
|
-
return token ? { authorization: `Bearer ${token}` } : {};
|
|
1809
|
-
}
|
|
1810
|
-
};
|
|
1811
|
-
//#endregion
|
|
1812
718
|
//#region lib/gateway/resolve-daemon-script.ts
|
|
1813
719
|
/**
|
|
1814
720
|
* Locate the daemon entry script. Works in both dev (running from source)
|
|
@@ -1840,10 +746,10 @@ const STARTUP_TIMEOUT_MS = 5e3;
|
|
|
1840
746
|
const SIGTERM_TIMEOUT_MS = 2e3;
|
|
1841
747
|
const POLL_INTERVAL_MS = 100;
|
|
1842
748
|
const SIGKILL_GRACE_MS = 200;
|
|
1843
|
-
const defaultProcess
|
|
1844
|
-
const defaultFs
|
|
749
|
+
const defaultProcess = new NodeFunnelProcessRunner();
|
|
750
|
+
const defaultFs = new NodeFunnelFileSystem();
|
|
1845
751
|
const defaultClock = new NodeFunnelClock();
|
|
1846
|
-
const defaultSleep
|
|
752
|
+
const defaultSleep = (ms) => new Promise((r) => {
|
|
1847
753
|
setTimeout(r, ms);
|
|
1848
754
|
});
|
|
1849
755
|
/**
|
|
@@ -1862,15 +768,15 @@ var FunnelGateway = class {
|
|
|
1862
768
|
port;
|
|
1863
769
|
sleep;
|
|
1864
770
|
constructor(deps = {}) {
|
|
1865
|
-
this.process = deps.process ?? defaultProcess
|
|
1866
|
-
this.fs = deps.fs ?? defaultFs
|
|
771
|
+
this.process = deps.process ?? defaultProcess;
|
|
772
|
+
this.fs = deps.fs ?? defaultFs;
|
|
1867
773
|
this.clock = deps.clock ?? defaultClock;
|
|
1868
774
|
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
1869
775
|
this.tmpDir = deps.tmpDir ?? funnelTmpDir();
|
|
1870
776
|
this.pidFile = join(this.dir, "gateway.pid");
|
|
1871
777
|
this.gatewayLog = join(this.tmpDir, "gateway.log");
|
|
1872
778
|
this.port = deps.port ?? resolveFunnelPort();
|
|
1873
|
-
this.sleep = deps.sleep ?? defaultSleep
|
|
779
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
1874
780
|
Object.freeze(this);
|
|
1875
781
|
}
|
|
1876
782
|
isRunning() {
|
|
@@ -1986,1712 +892,13 @@ var FunnelGateway = class {
|
|
|
1986
892
|
return null;
|
|
1987
893
|
}
|
|
1988
894
|
}
|
|
1989
|
-
removePid() {
|
|
1990
|
-
this.fs.unlink(this.pidFile);
|
|
1991
|
-
}
|
|
1992
|
-
isProcessAlive(pid) {
|
|
1993
|
-
return this.process.isAlive(pid);
|
|
1994
|
-
}
|
|
1995
|
-
};
|
|
1996
|
-
//#endregion
|
|
1997
|
-
//#region lib/gateway/auth-middleware.ts
|
|
1998
|
-
/**
|
|
1999
|
-
* Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
|
|
2000
|
-
* Mounted on the routes that mutate listener state or expose detailed status.
|
|
2001
|
-
* `/health` is intentionally left unauthenticated so the daemon manager can
|
|
2002
|
-
* probe liveness without needing the token.
|
|
2003
|
-
*/
|
|
2004
|
-
const requireBearerToken = (deps) => {
|
|
2005
|
-
return async (c, next) => {
|
|
2006
|
-
if (!constantTimeEqual((c.req.header("authorization") ?? "").match(/^Bearer\s+(.+)$/i)?.[1] ?? "", deps.expected)) return c.text("unauthorized", 401);
|
|
2007
|
-
return await next();
|
|
2008
|
-
};
|
|
2009
|
-
};
|
|
2010
|
-
const constantTimeEqual = (a, b) => {
|
|
2011
|
-
const bufA = Buffer.from(a, "utf-8");
|
|
2012
|
-
const bufB = Buffer.from(b, "utf-8");
|
|
2013
|
-
const maxLen = Math.max(bufA.length, bufB.length, 1);
|
|
2014
|
-
const padA = Buffer.alloc(maxLen);
|
|
2015
|
-
const padB = Buffer.alloc(maxLen);
|
|
2016
|
-
bufA.copy(padA);
|
|
2017
|
-
bufB.copy(padB);
|
|
2018
|
-
return timingSafeEqual(padA, padB) && bufA.length === bufB.length;
|
|
2019
|
-
};
|
|
2020
|
-
//#endregion
|
|
2021
|
-
//#region lib/gateway/factory.ts
|
|
2022
|
-
const factory$1 = createFactory();
|
|
2023
|
-
//#endregion
|
|
2024
|
-
//#region lib/gateway/broadcaster.ts
|
|
2025
|
-
const byteLengthOf = (event) => {
|
|
2026
|
-
let bytes = Buffer.byteLength(event.content, "utf-8");
|
|
2027
|
-
if (event.meta) for (const [k, v] of Object.entries(event.meta)) bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8");
|
|
2028
|
-
return bytes;
|
|
2029
|
-
};
|
|
2030
|
-
const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
2031
|
-
const DEFAULT_REPLAY_BUFFER_SIZE = 200;
|
|
2032
|
-
const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
|
|
2033
|
-
const defaultOnError$2 = () => {};
|
|
2034
|
-
/**
|
|
2035
|
-
* In-process pub/sub for connector events.
|
|
2036
|
-
*
|
|
2037
|
-
* Two outbound paths:
|
|
2038
|
-
* - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
|
|
2039
|
-
* - In-process subscribers registered via `subscribe()` (programmable API)
|
|
2040
|
-
*
|
|
2041
|
-
* Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
|
|
2042
|
-
* (default 1 MiB), the client is closed with code 1009 and dropped from the
|
|
2043
|
-
* registry to keep one slow consumer from blocking the daemon.
|
|
2044
|
-
*
|
|
2045
|
-
* Replay: every emitted event gets a strictly increasing `offset`. The latest
|
|
2046
|
-
* `replayBufferSize` events are kept in memory; reconnecting WS clients can
|
|
2047
|
-
* pass `?since=<offset>` and the broadcaster resends matching events before
|
|
2048
|
-
* resuming the live stream. The in-memory ring covers short reconnects;
|
|
2049
|
-
* older history is served from the event log wired in as `persistentReplay`.
|
|
2050
|
-
*/
|
|
2051
|
-
var FunnelBroadcaster = class {
|
|
2052
|
-
clients = /* @__PURE__ */ new Map();
|
|
2053
|
-
subscribers = /* @__PURE__ */ new Set();
|
|
2054
|
-
logger;
|
|
2055
|
-
onError;
|
|
2056
|
-
maxBufferedBytes;
|
|
2057
|
-
now;
|
|
2058
|
-
replayBufferSize;
|
|
2059
|
-
replayBufferMaxBytes;
|
|
2060
|
-
replayBuffer = [];
|
|
2061
|
-
persistentReplay;
|
|
2062
|
-
exclusiveCursor = /* @__PURE__ */ new Map();
|
|
2063
|
-
replayBufferBytes = 0;
|
|
2064
|
-
eventsBroadcast = 0;
|
|
2065
|
-
droppedSlowClients = 0;
|
|
2066
|
-
lastBroadcastAt = null;
|
|
2067
|
-
latestOffset = 0;
|
|
2068
|
-
constructor(deps = {}) {
|
|
2069
|
-
this.logger = deps.logger;
|
|
2070
|
-
this.onError = deps.onError ?? defaultOnError$2;
|
|
2071
|
-
this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
2072
|
-
this.now = deps.now ?? (() => Date.now());
|
|
2073
|
-
this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE);
|
|
2074
|
-
this.replayBufferMaxBytes = Math.max(0, deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES);
|
|
2075
|
-
this.persistentReplay = deps.persistentReplay ?? null;
|
|
2076
|
-
}
|
|
2077
|
-
getMetrics() {
|
|
2078
|
-
return {
|
|
2079
|
-
clients: this.clients.size,
|
|
2080
|
-
subscribers: this.subscribers.size,
|
|
2081
|
-
eventsBroadcast: this.eventsBroadcast,
|
|
2082
|
-
droppedSlowClients: this.droppedSlowClients,
|
|
2083
|
-
lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
|
|
2084
|
-
latestOffset: this.latestOffset,
|
|
2085
|
-
oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null
|
|
2086
|
-
};
|
|
2087
|
-
}
|
|
2088
|
-
/**
|
|
2089
|
-
* Returns events with offset > since, filtered by the connector subscription
|
|
2090
|
-
* rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
|
|
2091
|
-
*
|
|
2092
|
-
* Two-tier lookup:
|
|
2093
|
-
* 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
|
|
2094
|
-
* 2. If `since` predates the oldest in-memory entry and a persistent replay source
|
|
2095
|
-
* is wired in (SQLite by default), the gap is filled from it. This covers reconnects
|
|
2096
|
-
* across daemon restarts where the in-memory buffer was lost.
|
|
2097
|
-
*
|
|
2098
|
-
* Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
|
|
2099
|
-
*/
|
|
2100
|
-
replaySince(since, data) {
|
|
2101
|
-
const oldestInMemory = this.replayBuffer[0]?.offset;
|
|
2102
|
-
const needFallback = this.persistentReplay && (oldestInMemory === void 0 || since < oldestInMemory - 1);
|
|
2103
|
-
const fromMemory = this.replayBuffer.filter((event) => event.offset > since && this.matchesClient(event, data));
|
|
2104
|
-
if (!needFallback) return fromMemory;
|
|
2105
|
-
const persisted = this.persistentReplay ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data)) : [];
|
|
2106
|
-
const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY;
|
|
2107
|
-
return [...persisted.filter((event) => event.offset < cutoff), ...fromMemory];
|
|
2108
|
-
}
|
|
2109
|
-
matchesClient(event, data) {
|
|
2110
|
-
if (data.tapAll) return true;
|
|
2111
|
-
const target = event.meta?.target;
|
|
2112
|
-
if (target && target !== data.subscriberId) return false;
|
|
2113
|
-
const channelId = event.meta?.channelId;
|
|
2114
|
-
if (channelId && channelId !== data.channel) return false;
|
|
2115
|
-
const connector = event.meta?.connector;
|
|
2116
|
-
if (!connector) return true;
|
|
2117
|
-
return data.connectors.includes(connector);
|
|
2118
|
-
}
|
|
2119
|
-
/**
|
|
2120
|
-
* Returns the list of WS clients that should receive `event`. Tap=all clients always
|
|
2121
|
-
* receive (passive observation). For each per-channel group:
|
|
2122
|
-
* - fanout → every matching client receives
|
|
2123
|
-
* - exclusive → exactly one client receives, picked round-robin per channel
|
|
2124
|
-
*
|
|
2125
|
-
* `meta.target` narrows the regular (non-tap) recipient set first via
|
|
2126
|
-
* `matchesClient`: only the subscriber whose `subscriberId` equals `target`
|
|
2127
|
-
* stays in the running, so a targeted event reaches one named instance while
|
|
2128
|
-
* still being observable by tap=all clients.
|
|
2129
|
-
*/
|
|
2130
|
-
pickRecipients(event) {
|
|
2131
|
-
const exclusiveByChannel = /* @__PURE__ */ new Map();
|
|
2132
|
-
const recipients = [];
|
|
2133
|
-
for (const [ws, data] of this.clients) {
|
|
2134
|
-
if (!this.matchesClient(event, data)) continue;
|
|
2135
|
-
if (data.tapAll) {
|
|
2136
|
-
recipients.push(ws);
|
|
2137
|
-
continue;
|
|
2138
|
-
}
|
|
2139
|
-
if (data.delivery === "exclusive") {
|
|
2140
|
-
const list = exclusiveByChannel.get(data.channel) ?? [];
|
|
2141
|
-
list.push(ws);
|
|
2142
|
-
exclusiveByChannel.set(data.channel, list);
|
|
2143
|
-
continue;
|
|
2144
|
-
}
|
|
2145
|
-
recipients.push(ws);
|
|
2146
|
-
}
|
|
2147
|
-
for (const [channel, candidates] of exclusiveByChannel) {
|
|
2148
|
-
if (candidates.length === 0) continue;
|
|
2149
|
-
const cursor = this.exclusiveCursor.get(channel) ?? 0;
|
|
2150
|
-
const picked = candidates[cursor % candidates.length];
|
|
2151
|
-
if (picked) recipients.push(picked);
|
|
2152
|
-
this.exclusiveCursor.set(channel, cursor + 1);
|
|
2153
|
-
}
|
|
2154
|
-
return recipients;
|
|
2155
|
-
}
|
|
2156
|
-
addClient(ws, data) {
|
|
2157
|
-
this.clients.set(ws, data);
|
|
2158
|
-
}
|
|
2159
|
-
removeClient(ws) {
|
|
2160
|
-
this.clients.delete(ws);
|
|
2161
|
-
}
|
|
2162
|
-
getClientCount() {
|
|
2163
|
-
return this.clients.size;
|
|
2164
|
-
}
|
|
2165
|
-
listChannels() {
|
|
2166
|
-
return [...this.clients.values()].map((d) => ({ ...d }));
|
|
2167
|
-
}
|
|
2168
|
-
subscribe(handler) {
|
|
2169
|
-
this.subscribers.add(handler);
|
|
2170
|
-
return () => {
|
|
2171
|
-
this.subscribers.delete(handler);
|
|
2172
|
-
};
|
|
2173
|
-
}
|
|
2174
|
-
broadcast(content, meta) {
|
|
2175
|
-
this.latestOffset += 1;
|
|
2176
|
-
const event = {
|
|
2177
|
-
content,
|
|
2178
|
-
meta,
|
|
2179
|
-
offset: this.latestOffset
|
|
2180
|
-
};
|
|
2181
|
-
const payload = JSON.stringify(event);
|
|
2182
|
-
meta?.connector;
|
|
2183
|
-
this.eventsBroadcast += 1;
|
|
2184
|
-
this.lastBroadcastAt = this.now();
|
|
2185
|
-
if (this.replayBufferSize > 0) {
|
|
2186
|
-
const eventBytes = byteLengthOf(event);
|
|
2187
|
-
this.replayBuffer.push(event);
|
|
2188
|
-
this.replayBufferBytes += eventBytes;
|
|
2189
|
-
while ((this.replayBuffer.length > this.replayBufferSize || this.replayBufferBytes > this.replayBufferMaxBytes) && this.replayBuffer.length > 0) {
|
|
2190
|
-
const dropped = this.replayBuffer.shift();
|
|
2191
|
-
if (dropped) this.replayBufferBytes -= byteLengthOf(dropped);
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
const recipients = this.pickRecipients(event);
|
|
2195
|
-
for (const ws of recipients) {
|
|
2196
|
-
const buffered = ws.getBufferedAmount();
|
|
2197
|
-
if (buffered > this.maxBufferedBytes) {
|
|
2198
|
-
const data = this.clients.get(ws);
|
|
2199
|
-
this.logger?.warn("dropping slow WS client (backpressure)", {
|
|
2200
|
-
channel: data?.channel,
|
|
2201
|
-
buffered,
|
|
2202
|
-
max: this.maxBufferedBytes
|
|
2203
|
-
});
|
|
2204
|
-
try {
|
|
2205
|
-
ws.close(1009, "backpressure");
|
|
2206
|
-
} catch {}
|
|
2207
|
-
this.clients.delete(ws);
|
|
2208
|
-
this.droppedSlowClients += 1;
|
|
2209
|
-
continue;
|
|
2210
|
-
}
|
|
2211
|
-
ws.send(payload);
|
|
2212
|
-
}
|
|
2213
|
-
for (const handler of this.subscribers) try {
|
|
2214
|
-
handler(event);
|
|
2215
|
-
} catch (error) {
|
|
2216
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2217
|
-
this.logger?.error("broadcast subscriber threw", { error: err.message });
|
|
2218
|
-
this.onError(err, {
|
|
2219
|
-
component: "broadcaster.subscriber",
|
|
2220
|
-
offset: event.offset,
|
|
2221
|
-
connector: event.meta?.connector ?? null,
|
|
2222
|
-
channel: event.meta?.channel ?? null
|
|
2223
|
-
});
|
|
2224
|
-
}
|
|
2225
|
-
return event;
|
|
2226
|
-
}
|
|
2227
|
-
/** Forward-seed the offset counter (used at startup from the persisted event store). */
|
|
2228
|
-
seedLatestOffset(offset) {
|
|
2229
|
-
if (offset > this.latestOffset) this.latestOffset = offset;
|
|
2230
|
-
}
|
|
2231
|
-
};
|
|
2232
|
-
//#endregion
|
|
2233
|
-
//#region lib/gateway/funnel-event-log.ts
|
|
2234
|
-
/**
|
|
2235
|
-
* Replayable event payload persisted by the gateway. Domain events the
|
|
2236
|
-
* broadcaster emits to WS clients land here so reconnects across daemon
|
|
2237
|
-
* restarts can be served from disk. System events (gateway start, channel
|
|
2238
|
-
* connected, etc.) are routed to `FunnelLogger` instead — they never go
|
|
2239
|
-
* through this log, which keeps the offset space clean for replay.
|
|
2240
|
-
*/
|
|
2241
|
-
const funnelEventSchema = z.object({
|
|
2242
|
-
type: z.string(),
|
|
2243
|
-
content: z.string(),
|
|
2244
|
-
channel_id: z.string().nullable(),
|
|
2245
|
-
connector_id: z.string().nullable(),
|
|
2246
|
-
meta: z.record(z.string(), z.string()).nullable()
|
|
2247
|
-
});
|
|
2248
|
-
/**
|
|
2249
|
-
* Durable, append-only log of broadcaster events keyed by the offset the
|
|
2250
|
-
* broadcaster assigns. The gateway persists every domain event here, and
|
|
2251
|
-
* across restarts it both seeds the broadcaster's offset counter
|
|
2252
|
-
* (`findMaxOffset`) and serves reconnect replay (`loadSince`) from it.
|
|
2253
|
-
*
|
|
2254
|
-
* `loadSince` is the only method the broadcaster itself needs, which makes
|
|
2255
|
-
* any implementation assignable to the broadcaster's narrow `ReplaySource`.
|
|
2256
|
-
*
|
|
2257
|
-
* Implementations:
|
|
2258
|
-
* - `SqliteFunnelEventLog` — the default; durable across daemon restarts.
|
|
2259
|
-
* - `MemoryFunnelEventLog` — an in-process double for tests and embedders
|
|
2260
|
-
* that do not need durability (replay is lost when the process exits).
|
|
2261
|
-
*/
|
|
2262
|
-
var FunnelEventLog = class {};
|
|
2263
|
-
//#endregion
|
|
2264
|
-
//#region lib/logger/leuco-logger-sqlite-sink.ts
|
|
2265
|
-
/** Conservative whitelist for column names interpolated into SQL. */
|
|
2266
|
-
const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
|
|
2267
|
-
/** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
|
|
2268
|
-
const BYTE_CHECK_INTERVAL = 500;
|
|
2269
|
-
const RESERVED_COLUMNS = new Set([
|
|
2270
|
-
"seq",
|
|
2271
|
-
"ts",
|
|
2272
|
-
"type",
|
|
2273
|
-
"event"
|
|
2274
|
-
]);
|
|
2275
|
-
/**
|
|
2276
|
-
* Schema versions. Each entry is the list of DDL statements that take the
|
|
2277
|
-
* database from version i to version i + 1. Migrations run in a transaction
|
|
2278
|
-
* so a partial failure rolls back. Adding a new version is append-only —
|
|
2279
|
-
* never edit a published one. Caller-defined index columns are added
|
|
2280
|
-
* dynamically on construct (independent of versioned migrations) because
|
|
2281
|
-
* they are configuration, not schema evolution.
|
|
2282
|
-
*/
|
|
2283
|
-
const MIGRATIONS = [[
|
|
2284
|
-
"CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
|
|
2285
|
-
"CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
|
|
2286
|
-
"CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
|
|
2287
|
-
]];
|
|
2288
|
-
/**
|
|
2289
|
-
* SQLite-backed sink built on `bun:sqlite`. Implements both primary and
|
|
2290
|
-
* relay roles so the same instance can own seq generation for one bus and
|
|
2291
|
-
* mirror records from another (e.g. cross-process replication, restore
|
|
2292
|
-
* from a backup stream).
|
|
2293
|
-
*
|
|
2294
|
-
* Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
|
|
2295
|
-
* atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
|
|
2296
|
-
* at the same database file therefore see one monotonically increasing
|
|
2297
|
-
* seq stream without any bus-level coordination — the database itself is
|
|
2298
|
-
* the synchronization point.
|
|
2299
|
-
*
|
|
2300
|
-
* Schema is version-managed via `PRAGMA user_version`. Migrations are
|
|
2301
|
-
* append-only and run in a transaction on every construct so a partial
|
|
2302
|
-
* upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
|
|
2303
|
-
* via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
|
|
2304
|
-
* a new index to an existing database is a no-downtime operation.
|
|
2305
|
-
*
|
|
2306
|
-
* Type safety: the second generic parameter `I` is the literal tuple of
|
|
2307
|
-
* index column names. `extractIndexes` and `getRecords({ where })` are
|
|
2308
|
-
* both type-checked against this tuple, so a typo at the call site is a
|
|
2309
|
-
* compile-time error rather than a silent miss at runtime.
|
|
2310
|
-
*
|
|
2311
|
-
* Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
|
|
2312
|
-
* insert as a single indexed DELETE that no-ops below the cap.
|
|
2313
|
-
*
|
|
2314
|
-
* Bulk inserts use `insertMany`, which wraps the batch in one transaction
|
|
2315
|
-
* for ~10–100x throughput at the cost of one fsync per batch instead of
|
|
2316
|
-
* one per row.
|
|
2317
|
-
*/
|
|
2318
|
-
var LeucoLoggerSqliteSink = class {
|
|
2319
|
-
db;
|
|
2320
|
-
maxRows;
|
|
2321
|
-
maxAgeMs;
|
|
2322
|
-
maxBytes;
|
|
2323
|
-
targetBytes;
|
|
2324
|
-
now;
|
|
2325
|
-
indexes;
|
|
2326
|
-
extractIndexes;
|
|
2327
|
-
insertStmt;
|
|
2328
|
-
insertWithSeqStmt;
|
|
2329
|
-
maxSeqStmt;
|
|
2330
|
-
countStmt;
|
|
2331
|
-
trimRowsStmt;
|
|
2332
|
-
trimAgeStmt;
|
|
2333
|
-
trimOldestStmt;
|
|
2334
|
-
insertsSinceByteCheck = 0;
|
|
2335
|
-
constructor(props) {
|
|
2336
|
-
this.db = new Database(props.path);
|
|
2337
|
-
this.db.run("PRAGMA journal_mode = WAL");
|
|
2338
|
-
this.migrate();
|
|
2339
|
-
this.maxRows = props.maxRows ?? null;
|
|
2340
|
-
this.maxAgeMs = props.maxAgeMs ?? null;
|
|
2341
|
-
this.maxBytes = props.maxBytes ?? null;
|
|
2342
|
-
this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
|
|
2343
|
-
this.now = props.now ?? (() => Date.now());
|
|
2344
|
-
this.indexes = props.indexes ?? [];
|
|
2345
|
-
if (this.indexes.length > 0) {
|
|
2346
|
-
validateIndexNames(this.indexes);
|
|
2347
|
-
this.extractIndexes = props.extractIndexes ?? null;
|
|
2348
|
-
this.syncIndexColumns();
|
|
2349
|
-
} else this.extractIndexes = null;
|
|
2350
|
-
const cols = [
|
|
2351
|
-
"ts",
|
|
2352
|
-
"type",
|
|
2353
|
-
"event",
|
|
2354
|
-
...this.indexes
|
|
2355
|
-
];
|
|
2356
|
-
const placeholders = cols.map(() => "?").join(", ");
|
|
2357
|
-
this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
|
|
2358
|
-
const colsWithSeq = ["seq", ...cols];
|
|
2359
|
-
const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
|
|
2360
|
-
this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
|
|
2361
|
-
this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
|
|
2362
|
-
this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
|
|
2363
|
-
this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
|
|
2364
|
-
this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
|
|
2365
|
-
this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
|
|
2366
|
-
}
|
|
2367
|
-
insert(input) {
|
|
2368
|
-
try {
|
|
2369
|
-
const params = this.buildInsertParams(input.ts, input.event);
|
|
2370
|
-
const result = this.insertStmt.run(...params);
|
|
2371
|
-
const seq = Number(result.lastInsertRowid);
|
|
2372
|
-
this.trim();
|
|
2373
|
-
return {
|
|
2374
|
-
seq,
|
|
2375
|
-
ts: input.ts,
|
|
2376
|
-
event: input.event
|
|
2377
|
-
};
|
|
2378
|
-
} catch (e) {
|
|
2379
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
insertMany(inputs) {
|
|
2383
|
-
if (inputs.length === 0) return [];
|
|
2384
|
-
try {
|
|
2385
|
-
const records = [];
|
|
2386
|
-
this.db.transaction((batch) => {
|
|
2387
|
-
for (const input of batch) {
|
|
2388
|
-
const params = this.buildInsertParams(input.ts, input.event);
|
|
2389
|
-
const result = this.insertStmt.run(...params);
|
|
2390
|
-
records.push({
|
|
2391
|
-
seq: Number(result.lastInsertRowid),
|
|
2392
|
-
ts: input.ts,
|
|
2393
|
-
event: input.event
|
|
2394
|
-
});
|
|
2395
|
-
}
|
|
2396
|
-
})(inputs);
|
|
2397
|
-
this.trim();
|
|
2398
|
-
return records;
|
|
2399
|
-
} catch (e) {
|
|
2400
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
write(record) {
|
|
2404
|
-
try {
|
|
2405
|
-
const params = [record.seq, ...this.buildInsertParams(record.ts, record.event)];
|
|
2406
|
-
this.insertWithSeqStmt.run(...params);
|
|
2407
|
-
this.trim();
|
|
2408
|
-
} catch (e) {
|
|
2409
|
-
return e instanceof Error ? e : new Error(String(e));
|
|
2410
|
-
}
|
|
2411
|
-
}
|
|
2412
|
-
getMaxSeq() {
|
|
2413
|
-
const row = this.maxSeqStmt.get();
|
|
2414
|
-
return row ? row.max : 0;
|
|
2415
|
-
}
|
|
2416
|
-
getRecords(props = {}) {
|
|
2417
|
-
const conditions = ["seq > ?"];
|
|
2418
|
-
const params = [props.sinceSeq ?? 0];
|
|
2419
|
-
if (typeof props.type === "string") {
|
|
2420
|
-
conditions.push("type = ?");
|
|
2421
|
-
params.push(props.type);
|
|
2422
|
-
}
|
|
2423
|
-
if (props.where) this.appendWhereConditions(props.where, conditions, params);
|
|
2424
|
-
const limit = props.limit ?? 1e3;
|
|
2425
|
-
params.push(limit);
|
|
2426
|
-
const dir = props.order === "desc" ? "DESC" : "ASC";
|
|
2427
|
-
const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
|
|
2428
|
-
const rows = this.db.prepare(sql).all(...params);
|
|
2429
|
-
if (dir === "DESC") rows.reverse();
|
|
2430
|
-
return rows.map(toRecord);
|
|
2431
|
-
}
|
|
2432
|
-
/**
|
|
2433
|
-
* Current schema version. Useful for diagnostics and for tests that want
|
|
2434
|
-
* to verify migrations ran. Reads `PRAGMA user_version` once per call.
|
|
2435
|
-
*/
|
|
2436
|
-
getSchemaVersion() {
|
|
2437
|
-
return this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
|
|
2438
|
-
}
|
|
2439
|
-
close() {
|
|
2440
|
-
this.db.close();
|
|
2441
|
-
}
|
|
2442
|
-
buildInsertParams(ts, event) {
|
|
2443
|
-
const type = extractType(event);
|
|
2444
|
-
const json = JSON.stringify(event);
|
|
2445
|
-
if (this.indexes.length === 0) return [
|
|
2446
|
-
ts,
|
|
2447
|
-
type,
|
|
2448
|
-
json
|
|
2449
|
-
];
|
|
2450
|
-
const values = this.extractIndexes ? this.extractIndexes(event) : null;
|
|
2451
|
-
return [
|
|
2452
|
-
ts,
|
|
2453
|
-
type,
|
|
2454
|
-
json,
|
|
2455
|
-
...this.indexes.map((col) => values?.[col] ?? null)
|
|
2456
|
-
];
|
|
2457
|
-
}
|
|
2458
|
-
appendWhereConditions(where, conditions, params) {
|
|
2459
|
-
const widened = where;
|
|
2460
|
-
for (const col of this.indexes) {
|
|
2461
|
-
const value = widened[col];
|
|
2462
|
-
if (value === void 0) continue;
|
|
2463
|
-
if (value === null) conditions.push(`${col} IS NULL`);
|
|
2464
|
-
else {
|
|
2465
|
-
conditions.push(`${col} = ?`);
|
|
2466
|
-
params.push(value);
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
}
|
|
2470
|
-
trim() {
|
|
2471
|
-
if (this.maxRows !== null) {
|
|
2472
|
-
const row = this.countStmt.get();
|
|
2473
|
-
if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
|
|
2474
|
-
}
|
|
2475
|
-
if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
|
|
2476
|
-
this.maybeTrimBytes();
|
|
2477
|
-
}
|
|
2478
|
-
/**
|
|
2479
|
-
* Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
|
|
2480
|
-
* we measure the file; on overflow we estimate how many of the oldest rows to
|
|
2481
|
-
* drop to land near targetBytes (by the byte/row ratio), delete them in one
|
|
2482
|
-
* statement, then VACUUM once to return the freed pages to the filesystem (a
|
|
2483
|
-
* plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
|
|
2484
|
-
* overflow keeps the expensive rewrite rare — the file must refill the whole
|
|
2485
|
-
* maxBytes→targetBytes delta before the next overflow can trigger.
|
|
2486
|
-
*/
|
|
2487
|
-
maybeTrimBytes() {
|
|
2488
|
-
if (this.maxBytes === null || this.targetBytes === null) return;
|
|
2489
|
-
this.insertsSinceByteCheck += 1;
|
|
2490
|
-
if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
|
|
2491
|
-
this.insertsSinceByteCheck = 0;
|
|
2492
|
-
const bytes = this.byteSize();
|
|
2493
|
-
if (bytes <= this.maxBytes) return;
|
|
2494
|
-
const rows = this.countStmt.get()?.n ?? 0;
|
|
2495
|
-
if (rows === 0) return;
|
|
2496
|
-
const bytesToFree = bytes - this.targetBytes;
|
|
2497
|
-
const bytesPerRow = bytes / rows;
|
|
2498
|
-
const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
|
|
2499
|
-
this.trimOldestStmt.run(rowsToDrop);
|
|
2500
|
-
this.db.run("VACUUM");
|
|
2501
|
-
}
|
|
2502
|
-
byteSize() {
|
|
2503
|
-
return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
|
|
2504
|
-
}
|
|
2505
|
-
/** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
|
|
2506
|
-
clear() {
|
|
2507
|
-
this.db.run("DELETE FROM leuco_log");
|
|
2508
|
-
this.db.run("VACUUM");
|
|
2509
|
-
this.insertsSinceByteCheck = 0;
|
|
2510
|
-
}
|
|
2511
|
-
syncIndexColumns() {
|
|
2512
|
-
const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
|
|
2513
|
-
for (const col of this.indexes) {
|
|
2514
|
-
if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
|
|
2515
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
migrate() {
|
|
2519
|
-
const current = this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
|
|
2520
|
-
if (current >= MIGRATIONS.length) return;
|
|
2521
|
-
const pending = MIGRATIONS.slice(current);
|
|
2522
|
-
let version = current;
|
|
2523
|
-
for (const stmts of pending) {
|
|
2524
|
-
version += 1;
|
|
2525
|
-
this.db.transaction(() => {
|
|
2526
|
-
for (const stmt of stmts) this.db.run(stmt);
|
|
2527
|
-
this.db.run(`PRAGMA user_version = ${version}`);
|
|
2528
|
-
})();
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
};
|
|
2532
|
-
function validateIndexNames(names) {
|
|
2533
|
-
for (const name of names) {
|
|
2534
|
-
if (!COLUMN_NAME_RE.test(name)) throw new Error(`invalid index column name: ${name}`);
|
|
2535
|
-
if (RESERVED_COLUMNS.has(name)) throw new Error(`reserved index column name: ${name}`);
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
function extractType(event) {
|
|
2539
|
-
if (typeof event !== "object" || event === null) return null;
|
|
2540
|
-
if (!("type" in event)) return null;
|
|
2541
|
-
const t = event.type;
|
|
2542
|
-
return typeof t === "string" ? t : null;
|
|
2543
|
-
}
|
|
2544
|
-
function toRecord(row) {
|
|
2545
|
-
return {
|
|
2546
|
-
seq: row.seq,
|
|
2547
|
-
ts: row.ts,
|
|
2548
|
-
event: JSON.parse(row.event)
|
|
2549
|
-
};
|
|
2550
|
-
}
|
|
2551
|
-
//#endregion
|
|
2552
|
-
//#region lib/gateway/sqlite-funnel-event-log.ts
|
|
2553
|
-
const MAX_CONTENT_CHARS = 2e3;
|
|
2554
|
-
/**
|
|
2555
|
-
* SQLite-backed `FunnelEventLog`. One indexed table holds every broadcaster
|
|
2556
|
-
* event with `channel_id` and `connector_id` as dedicated columns, so
|
|
2557
|
-
* per-channel and per-connector replay is an indexed range scan.
|
|
2558
|
-
*
|
|
2559
|
-
* Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
|
|
2560
|
-
* atomically. The broadcaster owns its own offset counter at runtime
|
|
2561
|
-
* (seeded from `findMaxOffset()` at startup); each broadcaster event
|
|
2562
|
-
* flows in here via `record()` with that pre-assigned offset, which the
|
|
2563
|
-
* sink stores via `write()` — PK uniqueness catches double-emit bugs.
|
|
2564
|
-
*
|
|
2565
|
-
* System events (gateway lifecycle, channel connect/disconnect, etc.) do
|
|
2566
|
-
* NOT go through this store. They are diagnostic only and live in
|
|
2567
|
-
* `FunnelLogger`'s file so the seq space here stays exclusive to
|
|
2568
|
-
* broadcaster traffic. This is what makes the broadcaster's seq seeding
|
|
2569
|
-
* (`getMaxSeq()` at startup) correct without per-event coordination.
|
|
2570
|
-
*/
|
|
2571
|
-
var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
2572
|
-
sink;
|
|
2573
|
-
now;
|
|
2574
|
-
constructor(props) {
|
|
2575
|
-
super();
|
|
2576
|
-
this.now = props.now ?? (() => Date.now());
|
|
2577
|
-
this.sink = new LeucoLoggerSqliteSink({
|
|
2578
|
-
path: props.path,
|
|
2579
|
-
indexes: ["channel_id", "connector_id"],
|
|
2580
|
-
extractIndexes: (event) => ({
|
|
2581
|
-
channel_id: event.channel_id,
|
|
2582
|
-
connector_id: event.connector_id
|
|
2583
|
-
}),
|
|
2584
|
-
now: this.now,
|
|
2585
|
-
...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {},
|
|
2586
|
-
...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {},
|
|
2587
|
-
...props.maxBytes !== void 0 ? { maxBytes: props.maxBytes } : {},
|
|
2588
|
-
...props.targetBytes !== void 0 ? { targetBytes: props.targetBytes } : {}
|
|
2589
|
-
});
|
|
2590
|
-
}
|
|
2591
|
-
/**
|
|
2592
|
-
* Persist a broadcaster-driven event with its assigned offset. Caller
|
|
2593
|
-
* (the gateway-server) supplies the offset from `broadcaster.broadcast()`
|
|
2594
|
-
* so this store and the broadcaster's in-memory ring stay aligned.
|
|
2595
|
-
*/
|
|
2596
|
-
record(record) {
|
|
2597
|
-
const event = {
|
|
2598
|
-
type: record.meta?.event_type ?? "unknown",
|
|
2599
|
-
content: truncate$1(record.content),
|
|
2600
|
-
channel_id: record.channelId,
|
|
2601
|
-
connector_id: record.connectorId,
|
|
2602
|
-
meta: record.meta
|
|
2603
|
-
};
|
|
2604
|
-
this.sink.write({
|
|
2605
|
-
seq: record.offset,
|
|
2606
|
-
ts: this.now(),
|
|
2607
|
-
event
|
|
2608
|
-
});
|
|
2609
|
-
}
|
|
2610
|
-
/**
|
|
2611
|
-
* Returns events with offset > since. Filtering by channel/connector is
|
|
2612
|
-
* the broadcaster's responsibility (it knows the client's subscription),
|
|
2613
|
-
* so this returns the full slice and lets the caller filter.
|
|
2614
|
-
*/
|
|
2615
|
-
loadSince(since) {
|
|
2616
|
-
const records = this.sink.getRecords({ sinceSeq: since });
|
|
2617
|
-
const out = [];
|
|
2618
|
-
for (const record of records) out.push({
|
|
2619
|
-
content: record.event.content,
|
|
2620
|
-
meta: record.event.meta ?? void 0,
|
|
2621
|
-
offset: record.seq
|
|
2622
|
-
});
|
|
2623
|
-
return out;
|
|
2624
|
-
}
|
|
2625
|
-
/**
|
|
2626
|
-
* Returns events for one channel (and optionally one connector). Used
|
|
2627
|
-
* by the gateway logs CLI for scoped queries. Channel/connector filters
|
|
2628
|
-
* are indexed columns, so this is an indexed range scan.
|
|
2629
|
-
*/
|
|
2630
|
-
loadForChannel(props) {
|
|
2631
|
-
const where = { channel_id: props.channelId };
|
|
2632
|
-
if (props.connectorId !== void 0) where.connector_id = props.connectorId;
|
|
2633
|
-
const records = this.sink.getRecords({
|
|
2634
|
-
where,
|
|
2635
|
-
...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
|
|
2636
|
-
...props.limit !== void 0 ? { limit: props.limit } : {}
|
|
2637
|
-
});
|
|
2638
|
-
const out = [];
|
|
2639
|
-
for (const record of records) out.push({
|
|
2640
|
-
content: record.event.content,
|
|
2641
|
-
meta: record.event.meta ?? void 0,
|
|
2642
|
-
offset: record.seq
|
|
2643
|
-
});
|
|
2644
|
-
return out;
|
|
2645
|
-
}
|
|
2646
|
-
findMaxOffset() {
|
|
2647
|
-
return this.sink.getMaxSeq();
|
|
2648
|
-
}
|
|
2649
|
-
clear() {
|
|
2650
|
-
this.sink.clear();
|
|
2651
|
-
}
|
|
2652
|
-
close() {
|
|
2653
|
-
this.sink.close();
|
|
2654
|
-
}
|
|
2655
|
-
};
|
|
2656
|
-
function truncate$1(content) {
|
|
2657
|
-
if (content.length <= MAX_CONTENT_CHARS) return content;
|
|
2658
|
-
return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
|
|
2659
|
-
}
|
|
2660
|
-
//#endregion
|
|
2661
|
-
//#region lib/gateway/listener-supervisor.ts
|
|
2662
|
-
const defaultOnError$1 = () => {};
|
|
2663
|
-
const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
|
|
2664
|
-
const DEFAULT_MAX_BACKOFF_MS = 6e4;
|
|
2665
|
-
const defaultSleep = (ms) => new Promise((r) => {
|
|
2666
|
-
setTimeout(r, ms);
|
|
2667
|
-
});
|
|
2668
|
-
/**
|
|
2669
|
-
* Owns the running listener instances and their lifecycle.
|
|
2670
|
-
*
|
|
2671
|
-
* Lives in the gateway process and is the only place that calls
|
|
2672
|
-
* `listener.start()` / `listener.stop()`. Each entry is keyed by
|
|
2673
|
-
* `${channelName}/${connectorName}` so the same connector name can exist in
|
|
2674
|
-
* multiple channels without colliding.
|
|
2675
|
-
*
|
|
2676
|
-
* Periodically polls each running listener's `isAlive()` and auto-restarts
|
|
2677
|
-
* dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
|
|
2678
|
-
* the backoff counter on successful restart.
|
|
2679
|
-
*/
|
|
2680
|
-
var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
2681
|
-
channels;
|
|
2682
|
-
notify;
|
|
2683
|
-
logger;
|
|
2684
|
-
onError;
|
|
2685
|
-
running = /* @__PURE__ */ new Map();
|
|
2686
|
-
failureCounts = /* @__PURE__ */ new Map();
|
|
2687
|
-
stats = /* @__PURE__ */ new Map();
|
|
2688
|
-
healthCheckIntervalMs;
|
|
2689
|
-
maxBackoffMs;
|
|
2690
|
-
sleep;
|
|
2691
|
-
now;
|
|
2692
|
-
healthCheckTimer = null;
|
|
2693
|
-
healthCheckInFlight = false;
|
|
2694
|
-
constructor(deps) {
|
|
2695
|
-
this.channels = deps.channels;
|
|
2696
|
-
this.notify = deps.notify;
|
|
2697
|
-
this.logger = deps.logger;
|
|
2698
|
-
this.onError = deps.onError ?? defaultOnError$1;
|
|
2699
|
-
this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
|
|
2700
|
-
this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
2701
|
-
this.sleep = deps.sleep ?? defaultSleep;
|
|
2702
|
-
this.now = deps.now ?? (() => Date.now());
|
|
2703
|
-
}
|
|
2704
|
-
static keyOf(channelName, connectorName) {
|
|
2705
|
-
return `${channelName}/${connectorName}`;
|
|
2706
|
-
}
|
|
2707
|
-
isRunning(channelName, connectorName) {
|
|
2708
|
-
return this.running.has(FunnelListenerSupervisor.keyOf(channelName, connectorName));
|
|
2709
|
-
}
|
|
2710
|
-
list() {
|
|
2711
|
-
return [...this.running.entries()].map(([key, entry]) => {
|
|
2712
|
-
const stats = this.stats.get(key);
|
|
2713
|
-
return {
|
|
2714
|
-
channelName: entry.channelName,
|
|
2715
|
-
channelId: entry.channelId,
|
|
2716
|
-
name: entry.config.name,
|
|
2717
|
-
type: entry.config.type,
|
|
2718
|
-
alive: entry.listener.isAlive(),
|
|
2719
|
-
events: stats?.events ?? 0,
|
|
2720
|
-
errors: stats?.errors ?? 0,
|
|
2721
|
-
failureCount: this.failureCounts.get(key) ?? 0,
|
|
2722
|
-
lastEventAt: stats?.lastEventAt ?? null
|
|
2723
|
-
};
|
|
2724
|
-
});
|
|
2725
|
-
}
|
|
2726
|
-
async start(channelName, connectorName) {
|
|
2727
|
-
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
|
|
2728
|
-
if (this.running.has(key)) return {
|
|
2729
|
-
ok: true,
|
|
2730
|
-
reason: "already running"
|
|
2731
|
-
};
|
|
2732
|
-
const created = this.channels.createListener(channelName, connectorName);
|
|
2733
|
-
if (!created) return {
|
|
2734
|
-
ok: false,
|
|
2735
|
-
reason: `connector "${connectorName}" not found in channel "${channelName}"`
|
|
2736
|
-
};
|
|
2737
|
-
const bind = async (content, meta) => {
|
|
2738
|
-
try {
|
|
2739
|
-
await this.notify(channelName, connectorName, content, meta);
|
|
2740
|
-
this.recordEvent(key);
|
|
2741
|
-
} catch (error) {
|
|
2742
|
-
this.recordError(key);
|
|
2743
|
-
throw error;
|
|
2744
|
-
}
|
|
2745
|
-
};
|
|
2746
|
-
try {
|
|
2747
|
-
await created.listener.start(bind);
|
|
2748
|
-
this.running.set(key, {
|
|
2749
|
-
config: created.config,
|
|
2750
|
-
channelName,
|
|
2751
|
-
channelId: created.channelId,
|
|
2752
|
-
listener: created.listener
|
|
2753
|
-
});
|
|
2754
|
-
this.ensureStats(key);
|
|
2755
|
-
this.logger?.info(`${created.config.type} listener started`, {
|
|
2756
|
-
channel: channelName,
|
|
2757
|
-
connector: connectorName
|
|
2758
|
-
});
|
|
2759
|
-
return { ok: true };
|
|
2760
|
-
} catch (error) {
|
|
2761
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2762
|
-
this.logger?.error(`${created.config.type} listener failed to start`, {
|
|
2763
|
-
channel: channelName,
|
|
2764
|
-
connector: connectorName,
|
|
2765
|
-
error: err.message
|
|
2766
|
-
});
|
|
2767
|
-
this.onError(err, {
|
|
2768
|
-
component: "listener-supervisor.start",
|
|
2769
|
-
channel: channelName,
|
|
2770
|
-
connector: connectorName,
|
|
2771
|
-
type: created.config.type
|
|
2772
|
-
});
|
|
2773
|
-
return {
|
|
2774
|
-
ok: false,
|
|
2775
|
-
reason: err.message
|
|
2776
|
-
};
|
|
2777
|
-
}
|
|
2778
|
-
}
|
|
2779
|
-
async stop(channelName, connectorName) {
|
|
2780
|
-
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
|
|
2781
|
-
const entry = this.running.get(key);
|
|
2782
|
-
if (!entry) return {
|
|
2783
|
-
ok: true,
|
|
2784
|
-
reason: "not running"
|
|
2785
|
-
};
|
|
2786
|
-
try {
|
|
2787
|
-
await entry.listener.stop();
|
|
2788
|
-
this.running.delete(key);
|
|
2789
|
-
this.failureCounts.delete(key);
|
|
2790
|
-
this.logger?.info(`${entry.config.type} listener stopped`, {
|
|
2791
|
-
channel: channelName,
|
|
2792
|
-
connector: connectorName
|
|
2793
|
-
});
|
|
2794
|
-
return { ok: true };
|
|
2795
|
-
} catch (error) {
|
|
2796
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2797
|
-
this.logger?.error(`${entry.config.type} listener failed to stop`, {
|
|
2798
|
-
channel: channelName,
|
|
2799
|
-
connector: connectorName,
|
|
2800
|
-
error: err.message
|
|
2801
|
-
});
|
|
2802
|
-
this.onError(err, {
|
|
2803
|
-
component: "listener-supervisor.stop",
|
|
2804
|
-
channel: channelName,
|
|
2805
|
-
connector: connectorName,
|
|
2806
|
-
type: entry.config.type
|
|
2807
|
-
});
|
|
2808
|
-
return {
|
|
2809
|
-
ok: false,
|
|
2810
|
-
reason: err.message
|
|
2811
|
-
};
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
async restart(channelName, connectorName) {
|
|
2815
|
-
const stopped = await this.stop(channelName, connectorName);
|
|
2816
|
-
if (!stopped.ok) return stopped;
|
|
2817
|
-
return await this.start(channelName, connectorName);
|
|
2818
|
-
}
|
|
2819
|
-
async startAll() {
|
|
2820
|
-
const all = this.channels.listAllConnectors();
|
|
2821
|
-
for (const view of all) await this.start(view.channelName, view.name);
|
|
2822
|
-
this.startHealthCheck();
|
|
2823
|
-
}
|
|
2824
|
-
async stopAll() {
|
|
2825
|
-
this.stopHealthCheck();
|
|
2826
|
-
for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
|
|
2827
|
-
}
|
|
2828
|
-
ensureStats(key) {
|
|
2829
|
-
const existing = this.stats.get(key);
|
|
2830
|
-
if (existing) return existing;
|
|
2831
|
-
const fresh = {
|
|
2832
|
-
events: 0,
|
|
2833
|
-
errors: 0,
|
|
2834
|
-
failureCount: 0,
|
|
2835
|
-
lastEventAt: null
|
|
2836
|
-
};
|
|
2837
|
-
this.stats.set(key, fresh);
|
|
2838
|
-
return fresh;
|
|
2839
|
-
}
|
|
2840
|
-
recordEvent(key) {
|
|
2841
|
-
const stats = this.ensureStats(key);
|
|
2842
|
-
stats.events += 1;
|
|
2843
|
-
stats.lastEventAt = new Date(this.now()).toISOString();
|
|
2844
|
-
}
|
|
2845
|
-
recordError(key) {
|
|
2846
|
-
this.ensureStats(key).errors += 1;
|
|
2847
|
-
}
|
|
2848
|
-
startHealthCheck() {
|
|
2849
|
-
if (this.healthCheckTimer) return;
|
|
2850
|
-
this.healthCheckTimer = setInterval(() => {
|
|
2851
|
-
this.runHealthCheck();
|
|
2852
|
-
}, this.healthCheckIntervalMs);
|
|
2853
|
-
this.healthCheckTimer.unref();
|
|
2854
|
-
}
|
|
2855
|
-
stopHealthCheck() {
|
|
2856
|
-
if (!this.healthCheckTimer) return;
|
|
2857
|
-
clearInterval(this.healthCheckTimer);
|
|
2858
|
-
this.healthCheckTimer = null;
|
|
2859
|
-
}
|
|
2860
|
-
async runHealthCheck() {
|
|
2861
|
-
if (this.healthCheckInFlight) return;
|
|
2862
|
-
this.healthCheckInFlight = true;
|
|
2863
|
-
try {
|
|
2864
|
-
for (const [key, entry] of [...this.running.entries()]) {
|
|
2865
|
-
if (entry.listener.isAlive()) {
|
|
2866
|
-
this.failureCounts.delete(key);
|
|
2867
|
-
continue;
|
|
2868
|
-
}
|
|
2869
|
-
await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
|
|
2870
|
-
}
|
|
2871
|
-
} finally {
|
|
2872
|
-
this.healthCheckInFlight = false;
|
|
2873
|
-
}
|
|
2874
|
-
}
|
|
2875
|
-
async recoverDead(channelName, connectorName, type) {
|
|
2876
|
-
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
|
|
2877
|
-
const failureCount = this.failureCounts.get(key) ?? 0;
|
|
2878
|
-
const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
|
|
2879
|
-
this.logger?.warn(`${type} listener unhealthy, restarting`, {
|
|
2880
|
-
channel: channelName,
|
|
2881
|
-
connector: connectorName,
|
|
2882
|
-
attempt: failureCount + 1,
|
|
2883
|
-
backoffMs
|
|
2884
|
-
});
|
|
2885
|
-
await this.stop(channelName, connectorName);
|
|
2886
|
-
await this.sleep(backoffMs);
|
|
2887
|
-
if ((await this.start(channelName, connectorName)).ok) {
|
|
2888
|
-
this.failureCounts.delete(key);
|
|
2889
|
-
this.logger?.info(`${type} listener recovered`, {
|
|
2890
|
-
channel: channelName,
|
|
2891
|
-
connector: connectorName
|
|
2892
|
-
});
|
|
2893
|
-
} else this.failureCounts.set(key, failureCount + 1);
|
|
2894
|
-
}
|
|
2895
|
-
};
|
|
2896
|
-
//#endregion
|
|
2897
|
-
//#region lib/gateway/kill-competing-slack-gateways.ts
|
|
2898
|
-
const defaultProcess = new NodeFunnelProcessRunner();
|
|
2899
|
-
const titleFor = (dir) => `funnel-gateway[${dir}]`;
|
|
2900
|
-
/**
|
|
2901
|
-
* Kills other funnel daemon processes that share the SAME funnel home dir,
|
|
2902
|
-
* which is the only situation that causes a real conflict (duplicate Slack
|
|
2903
|
-
* Socket Mode connections with the same tokens). Daemons rooted at a
|
|
2904
|
-
* different `~/.funnel/` are left alone — they hold different tokens and
|
|
2905
|
-
* speak to different Slack apps. The daemon advertises its dir via the
|
|
2906
|
-
* `funnel-gateway[<dir>]` marker appended to argv (also assigned to
|
|
2907
|
-
* `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
|
|
2908
|
-
* absorbs the POSIX/Windows enumeration difference behind the marker match.
|
|
2909
|
-
*/
|
|
2910
|
-
const killCompetingSlackGateways = async (props) => {
|
|
2911
|
-
const runner = props.process ?? defaultProcess;
|
|
2912
|
-
const logger = props.logger;
|
|
2913
|
-
const expectedTitle = titleFor(props.dir);
|
|
2914
|
-
const snapshots = runner.listProcessesContaining(expectedTitle);
|
|
2915
|
-
const killed = [];
|
|
2916
|
-
for (const snapshot of snapshots) {
|
|
2917
|
-
if (snapshot.pid === props.selfPid) continue;
|
|
2918
|
-
runner.kill(snapshot.pid, "SIGTERM");
|
|
2919
|
-
killed.push(snapshot.pid);
|
|
2920
|
-
logger?.info("killed competing Slack gateway process", {
|
|
2921
|
-
pid: snapshot.pid,
|
|
2922
|
-
args: snapshot.command.slice(0, 160)
|
|
2923
|
-
});
|
|
2924
|
-
}
|
|
2925
|
-
return killed;
|
|
2926
|
-
};
|
|
2927
|
-
//#endregion
|
|
2928
|
-
//#region lib/gateway/routes/validator.ts
|
|
2929
|
-
/**
|
|
2930
|
-
* Path-param validator for gateway routes. On failure it answers with the same
|
|
2931
|
-
* `{ ok: false, reason }` shape the listener routes already use, so
|
|
2932
|
-
* `FunnelListenersClient` can surface the message without special-casing.
|
|
2933
|
-
*/
|
|
2934
|
-
const zParam = (schema) => zValidator("param", schema, (result, c) => {
|
|
2935
|
-
if (result.success) return;
|
|
2936
|
-
const issue = result.error.issues[0];
|
|
2937
|
-
const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid request";
|
|
2938
|
-
return c.json({
|
|
2939
|
-
ok: false,
|
|
2940
|
-
reason
|
|
2941
|
-
}, 400);
|
|
2942
|
-
});
|
|
2943
|
-
//#endregion
|
|
2944
|
-
//#region lib/gateway/routes/channels.connectors.call.ts
|
|
2945
|
-
const bodySchema = z.object({
|
|
2946
|
-
method: z.string().min(1),
|
|
2947
|
-
path: z.string().min(1),
|
|
2948
|
-
body: z.unknown().optional()
|
|
2949
|
-
});
|
|
2950
|
-
/**
|
|
2951
|
-
* POST /channels/:channel/connectors/:connector/call
|
|
2952
|
-
*
|
|
2953
|
-
* Generic adapter call. Used by the funnel MCP server (running in the Claude
|
|
2954
|
-
* Code process) to send replies/reactions/etc. without spawning a CLI
|
|
2955
|
-
* subprocess. Mirrors the CLI's `funnel channels <c> connectors <conn> request
|
|
2956
|
-
* --method=...` but with a structured JSON body and no shell.
|
|
2957
|
-
*/
|
|
2958
|
-
const channelsConnectorsCallHandler = factory$1.createHandlers(zParam(z.object({
|
|
2959
|
-
channel: z.string().min(1),
|
|
2960
|
-
connector: z.string().min(1)
|
|
2961
|
-
})), async (c) => {
|
|
2962
|
-
const param = c.req.valid("param");
|
|
2963
|
-
const raw = await c.req.json().catch(() => null);
|
|
2964
|
-
const parsed = bodySchema.safeParse(raw);
|
|
2965
|
-
if (!parsed.success) throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
2966
|
-
const result = await c.var.deps.channels.call(param.channel, param.connector, {
|
|
2967
|
-
method: parsed.data.method,
|
|
2968
|
-
path: parsed.data.path,
|
|
2969
|
-
body: parsed.data.body ?? {}
|
|
2970
|
-
});
|
|
2971
|
-
return c.json({
|
|
2972
|
-
ok: true,
|
|
2973
|
-
result
|
|
2974
|
-
});
|
|
2975
|
-
});
|
|
2976
|
-
//#endregion
|
|
2977
|
-
//#region lib/gateway/routes/channels.publish.ts
|
|
2978
|
-
/**
|
|
2979
|
-
* POST /channels/:channel/publish
|
|
2980
|
-
*
|
|
2981
|
-
* Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
|
|
2982
|
-
* path: events go through `broadcaster.broadcast` + `eventLog.record`, so
|
|
2983
|
-
* subscribers see them exactly as if a listener had produced them.
|
|
2984
|
-
*
|
|
2985
|
-
* Body validation is Zod-shared with the client (`publishRequestSchema`); the
|
|
2986
|
-
* response (`publishResponseSchema`) carries the assigned offset so callers can
|
|
2987
|
-
* correlate with the persistent event store.
|
|
2988
|
-
*/
|
|
2989
|
-
const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ channel: z.string().min(1) })), zValidator("json", publishRequestSchema, (result, c) => {
|
|
2990
|
-
if (result.success) return;
|
|
2991
|
-
const issue = result.error.issues[0];
|
|
2992
|
-
const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body";
|
|
2993
|
-
return c.json({
|
|
2994
|
-
ok: false,
|
|
2995
|
-
reason
|
|
2996
|
-
}, 400);
|
|
2997
|
-
}), (c) => {
|
|
2998
|
-
const param = c.req.valid("param");
|
|
2999
|
-
const body = c.req.valid("json");
|
|
3000
|
-
const meta = body.target ? {
|
|
3001
|
-
...body.meta,
|
|
3002
|
-
target: body.target
|
|
3003
|
-
} : body.meta;
|
|
3004
|
-
const response = {
|
|
3005
|
-
ok: true,
|
|
3006
|
-
offset: c.var.deps.emit({
|
|
3007
|
-
channel: param.channel,
|
|
3008
|
-
connector: body.connector,
|
|
3009
|
-
content: body.content,
|
|
3010
|
-
meta
|
|
3011
|
-
}).offset
|
|
3012
|
-
};
|
|
3013
|
-
return c.json(response);
|
|
3014
|
-
});
|
|
3015
|
-
//#endregion
|
|
3016
|
-
//#region lib/gateway/connector-diagnostic-sql-reader.ts
|
|
3017
|
-
/**
|
|
3018
|
-
* Read-only SQL surface over the three diagnostic tables, for Claude to query
|
|
3019
|
-
* the log with arbitrary `SELECT`s. It opens all files read-only and exposes
|
|
3020
|
-
* three views — `raw`, `processed`, `connection` — that hide the storage
|
|
3021
|
-
* details (the physical table is `leuco_log` and each row's columns live
|
|
3022
|
-
* inside a JSON `event` blob): the views surface the columns as plain fields,
|
|
3023
|
-
* with `payload` already pulled out of the nested JSON.
|
|
3024
|
-
*
|
|
3025
|
-
* The tables are separate files. `raw` and `processed` share an `event_id`,
|
|
3026
|
-
* so a `JOIN` answers "the event arrived, but what verdict did it get?";
|
|
3027
|
-
* `connection` answers the other half — "did the listener ever connect at
|
|
3028
|
-
* all?". Writes are impossible: the connection is read-only and `query`
|
|
3029
|
-
* rejects anything but a single `SELECT`.
|
|
3030
|
-
*/
|
|
3031
|
-
var ConnectorDiagnosticSqlReader = class {
|
|
3032
|
-
db;
|
|
3033
|
-
constructor(props) {
|
|
3034
|
-
const db = new Database(props.rawPath, { readonly: true });
|
|
3035
|
-
try {
|
|
3036
|
-
db.run("PRAGMA busy_timeout = 500");
|
|
3037
|
-
db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
|
|
3038
|
-
db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
|
|
3039
|
-
db.run(rawViewSql);
|
|
3040
|
-
db.run(processedViewSql);
|
|
3041
|
-
db.run(connectionViewSql);
|
|
3042
|
-
} catch (error) {
|
|
3043
|
-
db.close();
|
|
3044
|
-
throw error;
|
|
3045
|
-
}
|
|
3046
|
-
this.db = db;
|
|
3047
|
-
Object.freeze(this);
|
|
3048
|
-
}
|
|
3049
|
-
/**
|
|
3050
|
-
* Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
|
|
3051
|
-
* than throwing) for a non-SELECT statement or a SQL error, so the caller
|
|
3052
|
-
* can surface the message without a stack trace.
|
|
3053
|
-
*/
|
|
3054
|
-
query(sql, params = []) {
|
|
3055
|
-
const trimmed = sql.trim().replace(/;$/, "").trim();
|
|
3056
|
-
if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
|
|
3057
|
-
if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
|
|
3058
|
-
try {
|
|
3059
|
-
return this.db.prepare(trimmed).all(...params);
|
|
3060
|
-
} catch (error) {
|
|
3061
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
3062
|
-
}
|
|
3063
|
-
}
|
|
3064
|
-
close() {
|
|
3065
|
-
this.db.close();
|
|
3066
|
-
}
|
|
3067
|
-
};
|
|
3068
|
-
const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
|
|
3069
|
-
seq,
|
|
3070
|
-
ts,
|
|
3071
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
3072
|
-
json_extract(event, '$.type') AS type,
|
|
3073
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
3074
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
3075
|
-
json_extract(event, '$.payload') AS payload
|
|
3076
|
-
FROM main.leuco_log`;
|
|
3077
|
-
const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
|
|
3078
|
-
seq,
|
|
3079
|
-
ts,
|
|
3080
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
3081
|
-
json_extract(event, '$.type') AS type,
|
|
3082
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
3083
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
3084
|
-
json_extract(event, '$.outcome') AS outcome,
|
|
3085
|
-
json_extract(event, '$.payload') AS payload
|
|
3086
|
-
FROM processeddb.leuco_log`;
|
|
3087
|
-
const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
|
|
3088
|
-
seq,
|
|
3089
|
-
ts,
|
|
3090
|
-
json_extract(event, '$.type') AS type,
|
|
3091
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
3092
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
3093
|
-
json_extract(event, '$.status') AS status,
|
|
3094
|
-
json_extract(event, '$.detail') AS detail
|
|
3095
|
-
FROM connectiondb.leuco_log`;
|
|
3096
|
-
//#endregion
|
|
3097
|
-
//#region lib/gateway/routes/debug.ts
|
|
3098
|
-
const extractPreview = (payload) => {
|
|
3099
|
-
if (typeof payload !== "string" || payload.length === 0) return null;
|
|
3100
|
-
try {
|
|
3101
|
-
const parsed = JSON.parse(payload);
|
|
3102
|
-
if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
|
|
3103
|
-
const text = String(parsed.text);
|
|
3104
|
-
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
3105
|
-
}
|
|
3106
|
-
} catch {
|
|
3107
|
-
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3108
|
-
}
|
|
3109
|
-
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3110
|
-
};
|
|
3111
|
-
const buildChannelDiagnosis = (channel) => {
|
|
3112
|
-
const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
|
|
3113
|
-
if (channel.connectors.length === 0) return {
|
|
3114
|
-
status: "warn",
|
|
3115
|
-
message: "no connectors configured on this channel",
|
|
3116
|
-
nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
|
|
3117
|
-
rootCause: null
|
|
3118
|
-
};
|
|
3119
|
-
if (!channel.listener) return {
|
|
3120
|
-
status: "error",
|
|
3121
|
-
message: "no listener running for this channel",
|
|
3122
|
-
nextActions: ["fnl gateway restart"],
|
|
3123
|
-
rootCause
|
|
3124
|
-
};
|
|
3125
|
-
if (!channel.listener.alive) return {
|
|
3126
|
-
status: "error",
|
|
3127
|
-
message: "listener is dead",
|
|
3128
|
-
nextActions: ["fnl gateway logs", "fnl gateway restart"],
|
|
3129
|
-
rootCause
|
|
3130
|
-
};
|
|
3131
|
-
if (channel.claudeClients === 0) return {
|
|
3132
|
-
status: "warn",
|
|
3133
|
-
message: "no Claude connected to this channel",
|
|
3134
|
-
nextActions: [`fnl claude --channel ${channel.name}`],
|
|
3135
|
-
rootCause: null
|
|
3136
|
-
};
|
|
3137
|
-
if (channel.listener.errors > 0) return {
|
|
3138
|
-
status: "warn",
|
|
3139
|
-
message: "listener has errors",
|
|
3140
|
-
nextActions: ["fnl gateway logs"],
|
|
3141
|
-
rootCause
|
|
3142
|
-
};
|
|
3143
|
-
return {
|
|
3144
|
-
status: "ok",
|
|
3145
|
-
message: "healthy",
|
|
3146
|
-
nextActions: [],
|
|
3147
|
-
rootCause: null
|
|
3148
|
-
};
|
|
3149
|
-
};
|
|
3150
|
-
/** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
|
|
3151
|
-
const debugHandler$1 = factory$1.createHandlers(async (c) => {
|
|
3152
|
-
const deps = c.var.deps;
|
|
3153
|
-
const channelFilter = c.req.query("channel") ?? null;
|
|
3154
|
-
const allChannels = deps.channels.list();
|
|
3155
|
-
const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
|
|
3156
|
-
const gatewayListeners = deps.supervisor.list();
|
|
3157
|
-
const gatewayClients = deps.broadcaster.listChannels();
|
|
3158
|
-
const metrics = deps.broadcaster.getMetrics();
|
|
3159
|
-
const tmpDir = funnelTmpDir();
|
|
3160
|
-
const rawPath = join(tmpDir, "connector-raw.db");
|
|
3161
|
-
const processedPath = join(tmpDir, "connector-processed.db");
|
|
3162
|
-
const connectionPath = join(tmpDir, "connector-connection.db");
|
|
3163
|
-
const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
|
|
3164
|
-
const channels = targetChannels.map((ch) => {
|
|
3165
|
-
const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
|
|
3166
|
-
const listener = listenerEntry ? {
|
|
3167
|
-
alive: listenerEntry.alive,
|
|
3168
|
-
events: listenerEntry.events,
|
|
3169
|
-
errors: listenerEntry.errors,
|
|
3170
|
-
lastEventAt: listenerEntry.lastEventAt
|
|
3171
|
-
} : null;
|
|
3172
|
-
const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
|
|
3173
|
-
const recentEvents = [];
|
|
3174
|
-
const connectionErrors = [];
|
|
3175
|
-
if (hasStore) {
|
|
3176
|
-
const reader = new ConnectorDiagnosticSqlReader({
|
|
3177
|
-
rawPath,
|
|
3178
|
-
processedPath,
|
|
3179
|
-
connectionPath
|
|
3180
|
-
});
|
|
3181
|
-
const rows = (() => {
|
|
3182
|
-
try {
|
|
3183
|
-
return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
|
|
3184
|
-
} finally {
|
|
3185
|
-
reader.close();
|
|
3186
|
-
}
|
|
3187
|
-
})();
|
|
3188
|
-
if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
|
|
3189
|
-
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
3190
|
-
let payloadParsed = null;
|
|
3191
|
-
if (rawPayload) try {
|
|
3192
|
-
const parsed = JSON.parse(rawPayload);
|
|
3193
|
-
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
3194
|
-
} catch {
|
|
3195
|
-
payloadParsed = null;
|
|
3196
|
-
}
|
|
3197
|
-
recentEvents.push({
|
|
3198
|
-
seq: typeof row.seq === "number" ? row.seq : null,
|
|
3199
|
-
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3200
|
-
type: typeof row.type === "string" ? row.type : "?",
|
|
3201
|
-
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
3202
|
-
payload: rawPayload,
|
|
3203
|
-
payloadParsed,
|
|
3204
|
-
preview: extractPreview(row.payload)
|
|
3205
|
-
});
|
|
3206
|
-
}
|
|
3207
|
-
if (listener && (!listener.alive || listener.errors > 0) || !listener) {
|
|
3208
|
-
const errReader = new ConnectorDiagnosticSqlReader({
|
|
3209
|
-
rawPath,
|
|
3210
|
-
processedPath,
|
|
3211
|
-
connectionPath
|
|
3212
|
-
});
|
|
3213
|
-
const errRows = (() => {
|
|
3214
|
-
try {
|
|
3215
|
-
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]);
|
|
3216
|
-
} finally {
|
|
3217
|
-
errReader.close();
|
|
3218
|
-
}
|
|
3219
|
-
})();
|
|
3220
|
-
if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
|
|
3221
|
-
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3222
|
-
type: typeof row.type === "string" ? row.type : "?",
|
|
3223
|
-
status: typeof row.status === "string" ? row.status : "?",
|
|
3224
|
-
detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
|
|
3225
|
-
});
|
|
3226
|
-
}
|
|
3227
|
-
}
|
|
3228
|
-
const base = {
|
|
3229
|
-
id: ch.id,
|
|
3230
|
-
name: ch.name,
|
|
3231
|
-
connectors: ch.connectors.map((conn) => conn.name),
|
|
3232
|
-
listener,
|
|
3233
|
-
claudeClients,
|
|
3234
|
-
recentEvents,
|
|
3235
|
-
connectionErrors
|
|
3236
|
-
};
|
|
3237
|
-
return {
|
|
3238
|
-
...base,
|
|
3239
|
-
diagnosis: buildChannelDiagnosis(base)
|
|
3240
|
-
};
|
|
3241
|
-
});
|
|
3242
|
-
return c.json({
|
|
3243
|
-
pid: deps.selfPid,
|
|
3244
|
-
uptimeMs: deps.uptimeMs(),
|
|
3245
|
-
eventsBroadcast: metrics.eventsBroadcast,
|
|
3246
|
-
channels
|
|
3247
|
-
});
|
|
3248
|
-
});
|
|
3249
|
-
//#endregion
|
|
3250
|
-
//#region lib/gateway/routes/health.ts
|
|
3251
|
-
/** GET /health — liveness + listener registry snapshot. */
|
|
3252
|
-
const healthHandler = factory$1.createHandlers((c) => {
|
|
3253
|
-
const deps = c.var.deps;
|
|
3254
|
-
return c.json({
|
|
3255
|
-
ok: true,
|
|
3256
|
-
pid: deps.selfPid,
|
|
3257
|
-
clients: deps.broadcaster.getClientCount(),
|
|
3258
|
-
listeners: deps.supervisor.list()
|
|
3259
|
-
});
|
|
3260
|
-
});
|
|
3261
|
-
//#endregion
|
|
3262
|
-
//#region lib/gateway/routes/listeners.list.ts
|
|
3263
|
-
/** GET /listeners — running connector listeners with alive/dead status. */
|
|
3264
|
-
const listenersListHandler = factory$1.createHandlers((c) => {
|
|
3265
|
-
return c.json({ listeners: c.var.deps.supervisor.list() });
|
|
3266
|
-
});
|
|
3267
|
-
//#endregion
|
|
3268
|
-
//#region lib/gateway/routes/listeners.restart.ts
|
|
3269
|
-
/** POST /listeners/:channel/:connector/restart — stop + start a connector listener. */
|
|
3270
|
-
const listenersRestartHandler = factory$1.createHandlers(zParam(z.object({
|
|
3271
|
-
channel: z.string().min(1),
|
|
3272
|
-
connector: z.string().min(1)
|
|
3273
|
-
})), async (c) => {
|
|
3274
|
-
const param = c.req.valid("param");
|
|
3275
|
-
const result = await c.var.deps.supervisor.restart(param.channel, param.connector);
|
|
3276
|
-
return c.json(result, result.ok ? 200 : 400);
|
|
3277
|
-
});
|
|
3278
|
-
//#endregion
|
|
3279
|
-
//#region lib/gateway/routes/listeners.start.ts
|
|
3280
|
-
/** POST /listeners/:channel/:connector/start — start a connector listener. */
|
|
3281
|
-
const listenersStartHandler = factory$1.createHandlers(zParam(z.object({
|
|
3282
|
-
channel: z.string().min(1),
|
|
3283
|
-
connector: z.string().min(1)
|
|
3284
|
-
})), async (c) => {
|
|
3285
|
-
const param = c.req.valid("param");
|
|
3286
|
-
const result = await c.var.deps.supervisor.start(param.channel, param.connector);
|
|
3287
|
-
return c.json(result, result.ok ? 200 : 400);
|
|
3288
|
-
});
|
|
3289
|
-
//#endregion
|
|
3290
|
-
//#region lib/gateway/routes/listeners.stop.ts
|
|
3291
|
-
/** DELETE /listeners/:channel/:connector — stop a connector listener. */
|
|
3292
|
-
const listenersStopHandler = factory$1.createHandlers(zParam(z.object({
|
|
3293
|
-
channel: z.string().min(1),
|
|
3294
|
-
connector: z.string().min(1)
|
|
3295
|
-
})), async (c) => {
|
|
3296
|
-
const param = c.req.valid("param");
|
|
3297
|
-
const result = await c.var.deps.supervisor.stop(param.channel, param.connector);
|
|
3298
|
-
return c.json(result, result.ok ? 200 : 400);
|
|
3299
|
-
});
|
|
3300
|
-
//#endregion
|
|
3301
|
-
//#region lib/gateway/routes/status.ts
|
|
3302
|
-
/** GET /status — listener registry, connected channels, and broadcaster metrics. */
|
|
3303
|
-
const statusHandler$1 = factory$1.createHandlers((c) => {
|
|
3304
|
-
const deps = c.var.deps;
|
|
3305
|
-
return c.json({
|
|
3306
|
-
ok: true,
|
|
3307
|
-
pid: deps.selfPid,
|
|
3308
|
-
uptimeMs: deps.uptimeMs(),
|
|
3309
|
-
clients: deps.broadcaster.listChannels(),
|
|
3310
|
-
listeners: deps.supervisor.list(),
|
|
3311
|
-
broadcaster: deps.broadcaster.getMetrics()
|
|
3312
|
-
});
|
|
3313
|
-
});
|
|
3314
|
-
//#endregion
|
|
3315
|
-
//#region lib/gateway/routes/index.ts
|
|
3316
|
-
function buildGatewayRoutes() {
|
|
3317
|
-
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);
|
|
3318
|
-
}
|
|
3319
|
-
const gatewayRoutes = buildGatewayRoutes();
|
|
3320
|
-
//#endregion
|
|
3321
|
-
//#region lib/gateway/gateway-server.ts
|
|
3322
|
-
const DEFAULT_HOST = "127.0.0.1";
|
|
3323
|
-
const LOOPBACK_HOSTS = new Set([
|
|
3324
|
-
"127.0.0.1",
|
|
3325
|
-
"localhost",
|
|
3326
|
-
"::1",
|
|
3327
|
-
"::ffff:127.0.0.1"
|
|
3328
|
-
]);
|
|
3329
|
-
const defaultDbPath = () => join(funnelTmpDir(), "events.db");
|
|
3330
|
-
const defaultOnError = () => {};
|
|
3331
|
-
/**
|
|
3332
|
-
* In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
|
|
3333
|
-
* listeners through `FunnelListenerSupervisor`, fans events out via
|
|
3334
|
-
* `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
|
|
3335
|
-
* System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
|
|
3336
|
-
* instead — keeping the SQLite seq space exclusive to broadcaster traffic so
|
|
3337
|
-
* the broadcaster's offset counter and `getMaxSeq()` stay aligned without
|
|
3338
|
-
* per-event coordination. Exposes `/listeners` HTTP for runtime
|
|
3339
|
-
* start/stop/restart of individual connectors.
|
|
3340
|
-
*/
|
|
3341
|
-
var FunnelGatewayServer = class {
|
|
3342
|
-
channels;
|
|
3343
|
-
settings;
|
|
3344
|
-
port;
|
|
3345
|
-
hostname;
|
|
3346
|
-
dbPath;
|
|
3347
|
-
process;
|
|
3348
|
-
logger;
|
|
3349
|
-
onError;
|
|
3350
|
-
selfPid;
|
|
3351
|
-
dir;
|
|
3352
|
-
killCompetingSlack;
|
|
3353
|
-
token;
|
|
3354
|
-
broadcaster;
|
|
3355
|
-
eventLog;
|
|
3356
|
-
supervisor;
|
|
3357
|
-
nowMs;
|
|
3358
|
-
extraRoutes;
|
|
3359
|
-
startedAt = null;
|
|
3360
|
-
server = null;
|
|
3361
|
-
constructor(deps) {
|
|
3362
|
-
this.channels = deps.channels;
|
|
3363
|
-
this.settings = deps.settings;
|
|
3364
|
-
this.port = deps.port ?? resolveFunnelPort();
|
|
3365
|
-
this.hostname = deps.hostname ?? DEFAULT_HOST;
|
|
3366
|
-
this.dbPath = deps.dbPath ?? defaultDbPath();
|
|
3367
|
-
this.process = deps.process;
|
|
3368
|
-
this.logger = deps.logger;
|
|
3369
|
-
this.onError = deps.onError ?? defaultOnError;
|
|
3370
|
-
this.selfPid = deps.selfPid ?? globalThis.process.pid;
|
|
3371
|
-
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
3372
|
-
this.killCompetingSlack = deps.killCompetingSlack ?? true;
|
|
3373
|
-
this.token = deps.token ?? "";
|
|
3374
|
-
this.extraRoutes = deps.extraRoutes ?? null;
|
|
3375
|
-
const clock = deps.clock;
|
|
3376
|
-
this.nowMs = clock ? () => clock.millis() : () => Date.now();
|
|
3377
|
-
if (deps.eventLog) this.eventLog = deps.eventLog;
|
|
3378
|
-
else {
|
|
3379
|
-
const dbDir = dirname(this.dbPath);
|
|
3380
|
-
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
3381
|
-
this.eventLog = new SqliteFunnelEventLog({
|
|
3382
|
-
path: this.dbPath,
|
|
3383
|
-
now: this.nowMs
|
|
3384
|
-
});
|
|
3385
|
-
}
|
|
3386
|
-
this.broadcaster = new FunnelBroadcaster({
|
|
3387
|
-
logger: this.logger,
|
|
3388
|
-
onError: this.onError,
|
|
3389
|
-
now: this.nowMs,
|
|
3390
|
-
persistentReplay: this.eventLog
|
|
3391
|
-
});
|
|
3392
|
-
this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
|
|
3393
|
-
this.supervisor = new FunnelListenerSupervisor({
|
|
3394
|
-
channels: this.channels,
|
|
3395
|
-
logger: this.logger,
|
|
3396
|
-
onError: this.onError,
|
|
3397
|
-
notify: async (channelName, connectorName, content, meta) => {
|
|
3398
|
-
this.emit({
|
|
3399
|
-
channel: channelName,
|
|
3400
|
-
connector: connectorName,
|
|
3401
|
-
content,
|
|
3402
|
-
meta
|
|
3403
|
-
});
|
|
3404
|
-
},
|
|
3405
|
-
now: this.nowMs
|
|
3406
|
-
});
|
|
3407
|
-
}
|
|
3408
|
-
async start() {
|
|
3409
|
-
if (this.server) return this.server;
|
|
3410
|
-
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 });
|
|
3411
|
-
const app = this.buildApp();
|
|
3412
|
-
this.startedAt = this.nowMs();
|
|
3413
|
-
this.server = Bun.serve({
|
|
3414
|
-
port: this.port,
|
|
3415
|
-
hostname: this.hostname,
|
|
3416
|
-
development: false,
|
|
3417
|
-
fetch: (request, server) => this.handleFetch(request, server, app),
|
|
3418
|
-
websocket: {
|
|
3419
|
-
open: (ws) => this.handleWsOpen(ws),
|
|
3420
|
-
close: (ws) => this.handleWsClose(ws),
|
|
3421
|
-
message() {}
|
|
3422
|
-
}
|
|
3423
|
-
});
|
|
3424
|
-
this.logServerStarted();
|
|
3425
|
-
await this.bootListeners();
|
|
3426
|
-
return this.server;
|
|
3427
|
-
}
|
|
3428
|
-
async stop() {
|
|
3429
|
-
await this.supervisor.stopAll();
|
|
3430
|
-
if (this.server) {
|
|
3431
|
-
this.server.stop();
|
|
3432
|
-
this.server = null;
|
|
3433
|
-
}
|
|
3434
|
-
}
|
|
3435
|
-
getStatus() {
|
|
3436
|
-
return {
|
|
3437
|
-
clients: this.broadcaster.getClientCount(),
|
|
3438
|
-
channels: this.broadcaster.listChannels()
|
|
3439
|
-
};
|
|
3440
|
-
}
|
|
3441
|
-
getBroadcaster() {
|
|
3442
|
-
return this.broadcaster;
|
|
3443
|
-
}
|
|
3444
|
-
getSupervisor() {
|
|
3445
|
-
return this.supervisor;
|
|
3446
|
-
}
|
|
3447
|
-
getEventLog() {
|
|
3448
|
-
return this.eventLog;
|
|
3449
|
-
}
|
|
3450
|
-
/**
|
|
3451
|
-
* Register an in-process observer for every broadcast event. Fires after
|
|
3452
|
-
* the event is fanned out to WS clients and recorded in the event log.
|
|
3453
|
-
* Returns an unsubscribe function. Only meaningful in-process (embedded
|
|
3454
|
-
* hosts / `new Funnel(...)` running their own gateway-server); a separate
|
|
3455
|
-
* daemon process cannot be observed this way — use a WS client for that.
|
|
3456
|
-
*/
|
|
3457
|
-
onEvent(handler) {
|
|
3458
|
-
return this.broadcaster.subscribe(handler);
|
|
3459
|
-
}
|
|
3460
|
-
handleFetch(request, server, app) {
|
|
3461
|
-
const url = new URL(request.url);
|
|
3462
|
-
if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
|
|
3463
|
-
if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
|
|
3464
|
-
const tapAll = url.searchParams.get("tap") === "all";
|
|
3465
|
-
const requestedChannel = tapAll ? "" : url.searchParams.get("channel") ?? "";
|
|
3466
|
-
const channel = !tapAll && requestedChannel ? this.resolveChannel(requestedChannel) : null;
|
|
3467
|
-
const channelId = tapAll ? "" : channel?.id ?? requestedChannel;
|
|
3468
|
-
const channelName = tapAll ? null : channel?.name ?? null;
|
|
3469
|
-
const connectors = channel?.connectors ?? [];
|
|
3470
|
-
const delivery = channel?.delivery ?? "fanout";
|
|
3471
|
-
const sinceRaw = url.searchParams.get("since");
|
|
3472
|
-
const sinceParsed = sinceRaw === null ? NaN : Number.parseInt(sinceRaw, 10);
|
|
3473
|
-
const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : void 0;
|
|
3474
|
-
const subscriberId = url.searchParams.get("id") ?? void 0;
|
|
3475
|
-
if (server.upgrade(request, { data: {
|
|
3476
|
-
channel: channelId,
|
|
3477
|
-
channelName,
|
|
3478
|
-
connectors,
|
|
3479
|
-
tapAll,
|
|
3480
|
-
delivery,
|
|
3481
|
-
subscriberId,
|
|
3482
|
-
since
|
|
3483
|
-
} })) return void 0;
|
|
3484
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
3485
|
-
}
|
|
3486
|
-
return app.fetch(request);
|
|
3487
|
-
}
|
|
3488
|
-
handleWsOpen(ws) {
|
|
3489
|
-
if (typeof ws.data.since === "number") {
|
|
3490
|
-
const replay = this.broadcaster.replaySince(ws.data.since, ws.data);
|
|
3491
|
-
for (const event of replay) ws.send(JSON.stringify(event));
|
|
3492
|
-
}
|
|
3493
|
-
this.broadcaster.addClient(ws, ws.data);
|
|
3494
|
-
if (ws.data.channelName) {
|
|
3495
|
-
const meta = {
|
|
3496
|
-
event_type: "system",
|
|
3497
|
-
action: "channel_connect",
|
|
3498
|
-
channel: ws.data.channelName,
|
|
3499
|
-
channelId: ws.data.channel,
|
|
3500
|
-
connectors: ws.data.connectors.join(","),
|
|
3501
|
-
total: String(this.broadcaster.getClientCount())
|
|
3502
|
-
};
|
|
3503
|
-
this.logger?.info("channel connected", meta);
|
|
3504
|
-
} else this.logger?.info("tap-all client connected", {
|
|
3505
|
-
event_type: "system",
|
|
3506
|
-
action: "tap_connect",
|
|
3507
|
-
total: String(this.broadcaster.getClientCount())
|
|
3508
|
-
});
|
|
3509
|
-
}
|
|
3510
|
-
handleWsClose(ws) {
|
|
3511
|
-
this.broadcaster.removeClient(ws);
|
|
3512
|
-
if (ws.data.channelName) this.logger?.info("channel disconnected", {
|
|
3513
|
-
event_type: "system",
|
|
3514
|
-
action: "channel_disconnect",
|
|
3515
|
-
channel: ws.data.channelName,
|
|
3516
|
-
channelId: ws.data.channel,
|
|
3517
|
-
total: String(this.broadcaster.getClientCount())
|
|
3518
|
-
});
|
|
3519
|
-
else this.logger?.info("tap-all client disconnected", {
|
|
3520
|
-
event_type: "system",
|
|
3521
|
-
action: "tap_disconnect",
|
|
3522
|
-
total: String(this.broadcaster.getClientCount())
|
|
3523
|
-
});
|
|
3524
|
-
}
|
|
3525
|
-
logServerStarted() {
|
|
3526
|
-
this.logger?.info("gateway started", {
|
|
3527
|
-
event_type: "system",
|
|
3528
|
-
action: "gateway_start",
|
|
3529
|
-
port: String(this.port),
|
|
3530
|
-
pid: String(this.selfPid)
|
|
3531
|
-
});
|
|
3532
|
-
this.logger?.info("funnel gateway listening", {
|
|
3533
|
-
url: `http://localhost:${this.port}`,
|
|
3534
|
-
websocket: `ws://localhost:${this.port}/ws`,
|
|
3535
|
-
health: `http://localhost:${this.port}/health`
|
|
3536
|
-
});
|
|
3537
|
-
}
|
|
3538
|
-
buildApp() {
|
|
3539
|
-
const base = factory$1.createApp();
|
|
3540
|
-
base.use((c, next) => {
|
|
3541
|
-
c.set("deps", {
|
|
3542
|
-
selfPid: this.selfPid,
|
|
3543
|
-
broadcaster: this.broadcaster,
|
|
3544
|
-
supervisor: this.supervisor,
|
|
3545
|
-
channels: this.channels,
|
|
3546
|
-
uptimeMs: () => this.startedAt ? this.nowMs() - this.startedAt : 0,
|
|
3547
|
-
emit: (input) => this.emit(input)
|
|
3548
|
-
});
|
|
3549
|
-
return next();
|
|
3550
|
-
});
|
|
3551
|
-
if (this.token) {
|
|
3552
|
-
base.use("/listeners/*", requireBearerToken({ expected: this.token }));
|
|
3553
|
-
base.use("/status", requireBearerToken({ expected: this.token }));
|
|
3554
|
-
base.use("/debug", requireBearerToken({ expected: this.token }));
|
|
3555
|
-
base.use("/channels/*", requireBearerToken({ expected: this.token }));
|
|
3556
|
-
}
|
|
3557
|
-
return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
|
|
3558
|
-
}
|
|
3559
|
-
/**
|
|
3560
|
-
* Reads the bearer token from the WebSocket upgrade request. Accepts:
|
|
3561
|
-
* - `Sec-WebSocket-Protocol: funnel.token.<value>` (preferred — header, never logged in URLs)
|
|
3562
|
-
* - `Authorization: Bearer <value>` (also header-based)
|
|
3563
|
-
* Returns true on a constant-time match against the daemon token.
|
|
3564
|
-
*/
|
|
3565
|
-
tokenMatchesUpgrade(request) {
|
|
3566
|
-
const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
3567
|
-
for (const proto of protocols) if (proto.startsWith("funnel.token.") && constantTimeEqual(proto.slice(13), this.token)) return true;
|
|
3568
|
-
const match = (request.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i);
|
|
3569
|
-
if (match && constantTimeEqual(match[1] ?? "", this.token)) return true;
|
|
3570
|
-
return false;
|
|
3571
|
-
}
|
|
3572
|
-
resolveChannel(requested) {
|
|
3573
|
-
const channel = this.settings.read()?.channels.find((c) => c.id === requested || c.name === requested);
|
|
3574
|
-
if (!channel) return null;
|
|
3575
|
-
return {
|
|
3576
|
-
id: channel.id,
|
|
3577
|
-
name: channel.name,
|
|
3578
|
-
connectors: channel.connectors.map((c) => c.name),
|
|
3579
|
-
delivery: channel.delivery
|
|
3580
|
-
};
|
|
3581
|
-
}
|
|
3582
|
-
async bootListeners() {
|
|
3583
|
-
const allConnectors = this.channels.listAllConnectors();
|
|
3584
|
-
if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
|
|
3585
|
-
const killed = await killCompetingSlackGateways({
|
|
3586
|
-
selfPid: this.selfPid,
|
|
3587
|
-
dir: this.dir,
|
|
3588
|
-
process: this.process,
|
|
3589
|
-
logger: this.logger
|
|
3590
|
-
});
|
|
3591
|
-
if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
|
|
3592
|
-
event_type: "system",
|
|
3593
|
-
action: "kill_competing",
|
|
3594
|
-
pids: killed.join(",")
|
|
3595
|
-
});
|
|
3596
|
-
}
|
|
3597
|
-
await this.supervisor.startAll();
|
|
3598
|
-
for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
|
|
3599
|
-
event_type: "system",
|
|
3600
|
-
action: `${entry.type}_connect`,
|
|
3601
|
-
channel: entry.channelName,
|
|
3602
|
-
connector: entry.name
|
|
3603
|
-
});
|
|
3604
|
-
this.logger?.info(`event store: ${this.dbPath}`);
|
|
3605
|
-
this.logger?.info("funnel gateway running");
|
|
3606
|
-
}
|
|
3607
|
-
/**
|
|
3608
|
-
* Broadcast `content` to subscribers of `channel`, persisting the event in
|
|
3609
|
-
* the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
|
|
3610
|
-
* when they resolve. Used by both the connector-listener path (via the
|
|
3611
|
-
* supervisor's `notify` callback) and the public `/channels/:channel/publish`
|
|
3612
|
-
* route. Returns the assigned event offset.
|
|
3613
|
-
*/
|
|
3614
|
-
emit(input) {
|
|
3615
|
-
const channelId = this.lookupChannelId(input.channel);
|
|
3616
|
-
const connectorId = channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null;
|
|
3617
|
-
const enriched = {
|
|
3618
|
-
...input.meta,
|
|
3619
|
-
channel: input.channel
|
|
3620
|
-
};
|
|
3621
|
-
if (input.connector) enriched.connector = input.connector;
|
|
3622
|
-
if (channelId) enriched.channelId = channelId;
|
|
3623
|
-
if (connectorId) enriched.connectorId = connectorId;
|
|
3624
|
-
const event = this.broadcaster.broadcast(input.content, enriched);
|
|
3625
|
-
this.eventLog.record({
|
|
3626
|
-
content: input.content,
|
|
3627
|
-
channelId: channelId ?? null,
|
|
3628
|
-
connectorId: connectorId ?? null,
|
|
3629
|
-
meta: enriched,
|
|
3630
|
-
offset: event.offset
|
|
3631
|
-
});
|
|
3632
|
-
return { offset: event.offset };
|
|
3633
|
-
}
|
|
3634
|
-
lookupChannelId(channelName) {
|
|
3635
|
-
return this.settings.read().channels.find((c) => c.name === channelName)?.id ?? null;
|
|
3636
|
-
}
|
|
3637
|
-
lookupConnectorId(channelId, connectorName) {
|
|
3638
|
-
return (this.settings.read().channels.find((c) => c.id === channelId)?.connectors.find((c) => c.name === connectorName))?.id ?? null;
|
|
3639
|
-
}
|
|
3640
|
-
};
|
|
3641
|
-
//#endregion
|
|
3642
|
-
//#region lib/gateway/gateway-token.ts
|
|
3643
|
-
const TOKEN_FILE_NAME = "gateway.token";
|
|
3644
|
-
const TOKEN_BYTES = 32;
|
|
3645
|
-
const defaultFs = new NodeFunnelFileSystem();
|
|
3646
|
-
const defaultGenerate = () => {
|
|
3647
|
-
const buf = new Uint8Array(TOKEN_BYTES);
|
|
3648
|
-
crypto.getRandomValues(buf);
|
|
3649
|
-
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3650
|
-
};
|
|
3651
|
-
/**
|
|
3652
|
-
* Reads / generates the gateway daemon token used to authenticate
|
|
3653
|
-
* `/listeners*`, `/status`, and `/ws` connections.
|
|
3654
|
-
*
|
|
3655
|
-
* Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
|
|
3656
|
-
* written with mode 0600. Clients on the same machine as the daemon read
|
|
3657
|
-
* the file directly; the token never leaves the user's home directory.
|
|
3658
|
-
*/
|
|
3659
|
-
var FunnelGatewayToken = class {
|
|
3660
|
-
fs;
|
|
3661
|
-
path;
|
|
3662
|
-
generate;
|
|
3663
|
-
constructor(deps = {}) {
|
|
3664
|
-
this.fs = deps.fs ?? defaultFs;
|
|
3665
|
-
this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME);
|
|
3666
|
-
this.generate = deps.generate ?? defaultGenerate;
|
|
3667
|
-
Object.freeze(this);
|
|
3668
|
-
}
|
|
3669
|
-
read() {
|
|
3670
|
-
if (!this.fs.existsSync(this.path)) return null;
|
|
3671
|
-
const value = this.fs.readFileSync(this.path).trim();
|
|
3672
|
-
return value.length > 0 ? value : null;
|
|
3673
|
-
}
|
|
3674
|
-
/**
|
|
3675
|
-
* Returns the existing token or, if missing, generates one and writes it with mode 0600.
|
|
3676
|
-
*
|
|
3677
|
-
* NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
|
|
3678
|
-
* itself before the PID lock is acquired) could each generate independent tokens. The
|
|
3679
|
-
* gateway PID file makes this practically a non-issue; if you need stronger guarantees,
|
|
3680
|
-
* take a file lock around this call externally.
|
|
3681
|
-
*/
|
|
3682
|
-
ensure() {
|
|
3683
|
-
const existing = this.read();
|
|
3684
|
-
if (existing) return existing;
|
|
3685
|
-
const token = this.generate();
|
|
3686
|
-
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
3687
|
-
this.fs.writeSecretFileSync(this.path, `${token}\n`);
|
|
3688
|
-
return token;
|
|
895
|
+
removePid() {
|
|
896
|
+
this.fs.unlink(this.pidFile);
|
|
3689
897
|
}
|
|
3690
|
-
|
|
3691
|
-
return this.
|
|
898
|
+
isProcessAlive(pid) {
|
|
899
|
+
return this.process.isAlive(pid);
|
|
3692
900
|
}
|
|
3693
901
|
};
|
|
3694
|
-
const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME);
|
|
3695
902
|
//#endregion
|
|
3696
903
|
//#region lib/gateway/listeners-client.ts
|
|
3697
904
|
const listenerEntrySchema = z.object({
|
|
@@ -3827,7 +1034,7 @@ const buildFunnelDebugReport = async (deps, channelFilter) => {
|
|
|
3827
1034
|
errors: listenerEntry.errors,
|
|
3828
1035
|
lastEventAt: listenerEntry.lastEventAt
|
|
3829
1036
|
} : null;
|
|
3830
|
-
const claudeClients = (gatewayData?.clients ?? []).filter((cl) =>
|
|
1037
|
+
const claudeClients = (gatewayData?.clients ?? []).filter((cl) => cl.channelName === ch.name || cl.channel === ch.name);
|
|
3831
1038
|
report.channels.push({
|
|
3832
1039
|
name: ch.name,
|
|
3833
1040
|
connectors: ch.connectors.map((conn) => conn.name),
|
|
@@ -3870,15 +1077,14 @@ const SANDBOX_DIR = "/sandbox/.funnel";
|
|
|
3870
1077
|
const SANDBOX_TMP_DIR = "/sandbox/tmp";
|
|
3871
1078
|
const noopOnError = () => {};
|
|
3872
1079
|
/**
|
|
3873
|
-
* Facade
|
|
1080
|
+
* Facade that wires every funnel facet together and exposes the public surface.
|
|
3874
1081
|
*
|
|
3875
|
-
*
|
|
3876
|
-
*
|
|
3877
|
-
* injectable via `Props` — passing memory implementations gives a fully sandboxed
|
|
1082
|
+
* All side-effecting boundaries (filesystem, process, logger, clock, id, paths)
|
|
1083
|
+
* are injected via Props — passing memory implementations gives a fully sandboxed
|
|
3878
1084
|
* Funnel that touches no real disk, processes, or wall-clock time.
|
|
3879
1085
|
*
|
|
3880
|
-
*
|
|
3881
|
-
*
|
|
1086
|
+
* Fully immutable: all fields are resolved in the constructor and frozen.
|
|
1087
|
+
* No lazy initialisation — every dependency is wired at construction time.
|
|
3882
1088
|
*
|
|
3883
1089
|
* @example
|
|
3884
1090
|
* ```ts
|
|
@@ -3889,9 +1095,104 @@ const noopOnError = () => {};
|
|
|
3889
1095
|
* ```
|
|
3890
1096
|
*/
|
|
3891
1097
|
var Funnel = class Funnel {
|
|
3892
|
-
|
|
1098
|
+
paths;
|
|
1099
|
+
channels;
|
|
1100
|
+
gateway;
|
|
1101
|
+
gatewayToken;
|
|
1102
|
+
publisher;
|
|
1103
|
+
listeners;
|
|
1104
|
+
claude;
|
|
1105
|
+
profiles;
|
|
1106
|
+
localConfig;
|
|
1107
|
+
localConfigSync;
|
|
1108
|
+
fs;
|
|
1109
|
+
process;
|
|
1110
|
+
logger;
|
|
1111
|
+
clock;
|
|
1112
|
+
onError;
|
|
3893
1113
|
constructor(props = {}) {
|
|
3894
|
-
|
|
1114
|
+
const dir = props.dir ?? resolveFunnelDir();
|
|
1115
|
+
const tmpDir = props.tmpDir ?? funnelTmpDir();
|
|
1116
|
+
const fs = props.fs ?? new NodeFunnelFileSystem();
|
|
1117
|
+
const process = props.process ?? new NodeFunnelProcessRunner();
|
|
1118
|
+
const clock = props.clock ?? new NodeFunnelClock();
|
|
1119
|
+
const idGenerator = props.idGenerator ?? new NodeFunnelIdGenerator();
|
|
1120
|
+
this.paths = {
|
|
1121
|
+
dir,
|
|
1122
|
+
tmpDir,
|
|
1123
|
+
settings: join(dir, "settings.json")
|
|
1124
|
+
};
|
|
1125
|
+
this.fs = fs;
|
|
1126
|
+
this.process = process;
|
|
1127
|
+
this.logger = props.logger;
|
|
1128
|
+
this.clock = clock;
|
|
1129
|
+
this.onError = props.onError ?? noopOnError;
|
|
1130
|
+
const store = props.store ?? new FunnelSettingsStore({
|
|
1131
|
+
path: this.paths.settings,
|
|
1132
|
+
fs,
|
|
1133
|
+
idGenerator
|
|
1134
|
+
});
|
|
1135
|
+
const factory = new FunnelConnectorFactory({
|
|
1136
|
+
fs,
|
|
1137
|
+
process,
|
|
1138
|
+
logger: this.logger,
|
|
1139
|
+
diagnosticLog: props.diagnosticLog,
|
|
1140
|
+
dir,
|
|
1141
|
+
slackListenerOptions: props.slackListenerOptions,
|
|
1142
|
+
scheduleListenerOptions: props.scheduleListenerOptions
|
|
1143
|
+
});
|
|
1144
|
+
this.channels = new FunnelChannels({
|
|
1145
|
+
store,
|
|
1146
|
+
factory,
|
|
1147
|
+
clock,
|
|
1148
|
+
idGenerator
|
|
1149
|
+
});
|
|
1150
|
+
this.gateway = new FunnelGateway({
|
|
1151
|
+
fs,
|
|
1152
|
+
process,
|
|
1153
|
+
clock,
|
|
1154
|
+
dir,
|
|
1155
|
+
tmpDir,
|
|
1156
|
+
port: props.port
|
|
1157
|
+
});
|
|
1158
|
+
this.gatewayToken = new FunnelGatewayToken({
|
|
1159
|
+
fs,
|
|
1160
|
+
dir
|
|
1161
|
+
});
|
|
1162
|
+
this.publisher = new FunnelChannelPublisher({
|
|
1163
|
+
port: this.gateway.getPort(),
|
|
1164
|
+
isDaemonRunning: () => this.gateway.isRunning(),
|
|
1165
|
+
getToken: () => this.gatewayToken.read()
|
|
1166
|
+
});
|
|
1167
|
+
this.listeners = new FunnelListenersClient({
|
|
1168
|
+
port: this.gateway.getPort(),
|
|
1169
|
+
isDaemonRunning: () => this.gateway.isRunning(),
|
|
1170
|
+
getToken: () => this.gatewayToken.read()
|
|
1171
|
+
});
|
|
1172
|
+
const mcp = new FunnelMcp({ fs });
|
|
1173
|
+
this.profiles = new FunnelProfiles({
|
|
1174
|
+
store,
|
|
1175
|
+
idGenerator,
|
|
1176
|
+
fs
|
|
1177
|
+
});
|
|
1178
|
+
this.localConfig = new FunnelLocalConfig({ fs });
|
|
1179
|
+
this.localConfigSync = new FunnelLocalConfigSync({
|
|
1180
|
+
channels: this.channels,
|
|
1181
|
+
prompter: props.tokenPrompter ?? new NodeFunnelTokenPrompter()
|
|
1182
|
+
});
|
|
1183
|
+
this.claude = new FunnelClaude({
|
|
1184
|
+
channels: this.channels,
|
|
1185
|
+
mcp,
|
|
1186
|
+
gateway: this.gateway,
|
|
1187
|
+
sessions: this.profiles,
|
|
1188
|
+
guard: new FileProcessGuard({
|
|
1189
|
+
fs,
|
|
1190
|
+
process,
|
|
1191
|
+
dir
|
|
1192
|
+
}),
|
|
1193
|
+
process,
|
|
1194
|
+
logger: this.logger
|
|
1195
|
+
});
|
|
3895
1196
|
Object.freeze(this);
|
|
3896
1197
|
}
|
|
3897
1198
|
/**
|
|
@@ -3901,6 +1202,7 @@ var Funnel = class Funnel {
|
|
|
3901
1202
|
*/
|
|
3902
1203
|
static inMemory(props = {}) {
|
|
3903
1204
|
return new Funnel({
|
|
1205
|
+
...props,
|
|
3904
1206
|
store: props.store ?? new MockFunnelSettingsReader(),
|
|
3905
1207
|
fs: props.fs ?? new MemoryFunnelFileSystem(),
|
|
3906
1208
|
process: props.process ?? new MemoryFunnelProcessRunner(),
|
|
@@ -3911,184 +1213,6 @@ var Funnel = class Funnel {
|
|
|
3911
1213
|
tmpDir: props.tmpDir ?? SANDBOX_TMP_DIR
|
|
3912
1214
|
});
|
|
3913
1215
|
}
|
|
3914
|
-
/** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
|
|
3915
|
-
get paths() {
|
|
3916
|
-
const dir = this.props.dir ?? resolveFunnelDir();
|
|
3917
|
-
return {
|
|
3918
|
-
dir,
|
|
3919
|
-
tmpDir: this.props.tmpDir ?? funnelTmpDir(),
|
|
3920
|
-
settings: join(dir, "settings.json")
|
|
3921
|
-
};
|
|
3922
|
-
}
|
|
3923
|
-
/** Filesystem boundary. Defaults to NodeFunnelFileSystem. */
|
|
3924
|
-
get fs() {
|
|
3925
|
-
if (!this.memos.fs) this.memos.fs = this.props.fs ?? new NodeFunnelFileSystem();
|
|
3926
|
-
return this.memos.fs;
|
|
3927
|
-
}
|
|
3928
|
-
/** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
|
|
3929
|
-
get process() {
|
|
3930
|
-
if (!this.memos.process) this.memos.process = this.props.process ?? new NodeFunnelProcessRunner();
|
|
3931
|
-
return this.memos.process;
|
|
3932
|
-
}
|
|
3933
|
-
/** Logger boundary. Optional — when no logger is injected, every facet's `this.logger?.x` call is a silent no-op. Production entry points (cli, daemon) inject a NodeFunnelLogger. */
|
|
3934
|
-
get logger() {
|
|
3935
|
-
return this.props.logger;
|
|
3936
|
-
}
|
|
3937
|
-
/** Clock boundary. Defaults to NodeFunnelClock. */
|
|
3938
|
-
get clock() {
|
|
3939
|
-
if (!this.memos.clock) this.memos.clock = this.props.clock ?? new NodeFunnelClock();
|
|
3940
|
-
return this.memos.clock;
|
|
3941
|
-
}
|
|
3942
|
-
/**
|
|
3943
|
-
* Error hook. Forwards Funnel-internal exceptions that would otherwise be
|
|
3944
|
-
* swallowed. Defaults to a no-op when no host hook was passed.
|
|
3945
|
-
*/
|
|
3946
|
-
get onError() {
|
|
3947
|
-
return this.props.onError ?? noopOnError;
|
|
3948
|
-
}
|
|
3949
|
-
/** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
|
|
3950
|
-
get idGenerator() {
|
|
3951
|
-
if (!this.memos.idGenerator) this.memos.idGenerator = this.props.idGenerator ?? new NodeFunnelIdGenerator();
|
|
3952
|
-
return this.memos.idGenerator;
|
|
3953
|
-
}
|
|
3954
|
-
/** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
|
|
3955
|
-
get store() {
|
|
3956
|
-
if (!this.memos.store) this.memos.store = this.props.store ?? new FunnelSettingsStore({
|
|
3957
|
-
path: this.paths.settings,
|
|
3958
|
-
fs: this.fs,
|
|
3959
|
-
idGenerator: this.idGenerator
|
|
3960
|
-
});
|
|
3961
|
-
return this.memos.store;
|
|
3962
|
-
}
|
|
3963
|
-
/** Pure factory that constructs per-type listeners and adapters from connector configs. */
|
|
3964
|
-
get factory() {
|
|
3965
|
-
if (!this.memos.factory) this.memos.factory = new FunnelConnectorFactory({
|
|
3966
|
-
fs: this.fs,
|
|
3967
|
-
process: this.process,
|
|
3968
|
-
logger: this.logger,
|
|
3969
|
-
diagnosticLog: this.props.diagnosticLog,
|
|
3970
|
-
dir: this.paths.dir,
|
|
3971
|
-
slackListenerOptions: this.props.slackListenerOptions,
|
|
3972
|
-
scheduleListenerOptions: this.props.scheduleListenerOptions
|
|
3973
|
-
});
|
|
3974
|
-
return this.memos.factory;
|
|
3975
|
-
}
|
|
3976
|
-
/** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
|
|
3977
|
-
get channels() {
|
|
3978
|
-
if (!this.memos.channels) this.memos.channels = new FunnelChannels({
|
|
3979
|
-
store: this.store,
|
|
3980
|
-
factory: this.factory,
|
|
3981
|
-
profileChecker: this.profiles,
|
|
3982
|
-
clock: this.clock,
|
|
3983
|
-
idGenerator: this.idGenerator
|
|
3984
|
-
});
|
|
3985
|
-
return this.memos.channels;
|
|
3986
|
-
}
|
|
3987
|
-
/** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
|
|
3988
|
-
get profiles() {
|
|
3989
|
-
if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({
|
|
3990
|
-
store: this.store,
|
|
3991
|
-
idGenerator: this.idGenerator
|
|
3992
|
-
});
|
|
3993
|
-
return this.memos.profiles;
|
|
3994
|
-
}
|
|
3995
|
-
/** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
|
|
3996
|
-
get localConfig() {
|
|
3997
|
-
if (!this.memos.localConfig) this.memos.localConfig = new FunnelLocalConfig({ fs: this.fs });
|
|
3998
|
-
return this.memos.localConfig;
|
|
3999
|
-
}
|
|
4000
|
-
/** Writes the stable `id` into funnel.json on first launch so state can be scoped to `~/.funnel/projects/<id>/`. */
|
|
4001
|
-
get localConfigWriter() {
|
|
4002
|
-
if (!this.memos.localConfigWriter) this.memos.localConfigWriter = new FunnelLocalConfigWriter({ fs: this.fs });
|
|
4003
|
-
return this.memos.localConfigWriter;
|
|
4004
|
-
}
|
|
4005
|
-
/** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
|
|
4006
|
-
get tokenPrompter() {
|
|
4007
|
-
if (!this.memos.tokenPrompter) this.memos.tokenPrompter = this.props.tokenPrompter ?? new NodeFunnelTokenPrompter();
|
|
4008
|
-
return this.memos.tokenPrompter;
|
|
4009
|
-
}
|
|
4010
|
-
/** Reconciles funnel.json's channel + connectors with `~/.funnel/settings.json` on launch. */
|
|
4011
|
-
get localConfigSync() {
|
|
4012
|
-
if (!this.memos.localConfigSync) this.memos.localConfigSync = new FunnelLocalConfigSync({
|
|
4013
|
-
channels: this.channels,
|
|
4014
|
-
prompter: this.tokenPrompter
|
|
4015
|
-
});
|
|
4016
|
-
return this.memos.localConfigSync;
|
|
4017
|
-
}
|
|
4018
|
-
/** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
|
|
4019
|
-
get mcp() {
|
|
4020
|
-
if (!this.memos.mcp) this.memos.mcp = new FunnelMcp({ fs: this.fs });
|
|
4021
|
-
return this.memos.mcp;
|
|
4022
|
-
}
|
|
4023
|
-
/** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
|
|
4024
|
-
get claude() {
|
|
4025
|
-
if (!this.memos.claude) this.memos.claude = new FunnelClaude({
|
|
4026
|
-
channels: this.channels,
|
|
4027
|
-
mcp: this.mcp,
|
|
4028
|
-
gateway: this.gateway,
|
|
4029
|
-
profiles: this.profiles,
|
|
4030
|
-
fs: this.fs,
|
|
4031
|
-
process: this.process,
|
|
4032
|
-
idGenerator: this.idGenerator,
|
|
4033
|
-
logger: this.logger,
|
|
4034
|
-
dir: this.paths.dir
|
|
4035
|
-
});
|
|
4036
|
-
return this.memos.claude;
|
|
4037
|
-
}
|
|
4038
|
-
/** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
|
|
4039
|
-
get gateway() {
|
|
4040
|
-
if (!this.memos.gateway) this.memos.gateway = new FunnelGateway({
|
|
4041
|
-
fs: this.fs,
|
|
4042
|
-
process: this.process,
|
|
4043
|
-
clock: this.clock,
|
|
4044
|
-
dir: this.paths.dir,
|
|
4045
|
-
tmpDir: this.paths.tmpDir,
|
|
4046
|
-
port: this.props.port
|
|
4047
|
-
});
|
|
4048
|
-
return this.memos.gateway;
|
|
4049
|
-
}
|
|
4050
|
-
/** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
|
|
4051
|
-
get gatewayToken() {
|
|
4052
|
-
if (!this.memos.gatewayToken) this.memos.gatewayToken = new FunnelGatewayToken({
|
|
4053
|
-
fs: this.fs,
|
|
4054
|
-
dir: this.paths.dir
|
|
4055
|
-
});
|
|
4056
|
-
return this.memos.gatewayToken;
|
|
4057
|
-
}
|
|
4058
|
-
/**
|
|
4059
|
-
* HTTP client for `POST /channels/:channel/publish` on the running gateway
|
|
4060
|
-
* daemon. Use it to push arbitrary content into a channel from outside any
|
|
4061
|
-
* connector. Returns `{ state: "offline" }` if the daemon isn't up.
|
|
4062
|
-
*/
|
|
4063
|
-
get publisher() {
|
|
4064
|
-
if (!this.memos.publisher) {
|
|
4065
|
-
const gateway = this.gateway;
|
|
4066
|
-
const token = this.gatewayToken;
|
|
4067
|
-
this.memos.publisher = new FunnelChannelPublisher({
|
|
4068
|
-
port: gateway.getPort(),
|
|
4069
|
-
isDaemonRunning: () => gateway.isRunning(),
|
|
4070
|
-
getToken: () => token.read()
|
|
4071
|
-
});
|
|
4072
|
-
}
|
|
4073
|
-
return this.memos.publisher;
|
|
4074
|
-
}
|
|
4075
|
-
/**
|
|
4076
|
-
* HTTP client for listener operations on the running gateway daemon.
|
|
4077
|
-
* Returns `{ state: "offline" }` when the daemon is offline so hot-reload
|
|
4078
|
-
* paths stay write-only without parsing strings.
|
|
4079
|
-
*/
|
|
4080
|
-
get listeners() {
|
|
4081
|
-
if (!this.memos.listeners) {
|
|
4082
|
-
const gateway = this.gateway;
|
|
4083
|
-
const token = this.gatewayToken;
|
|
4084
|
-
this.memos.listeners = new FunnelListenersClient({
|
|
4085
|
-
port: gateway.getPort(),
|
|
4086
|
-
isDaemonRunning: () => gateway.isRunning(),
|
|
4087
|
-
getToken: () => token.read()
|
|
4088
|
-
});
|
|
4089
|
-
}
|
|
4090
|
-
return this.memos.listeners;
|
|
4091
|
-
}
|
|
4092
1216
|
/**
|
|
4093
1217
|
* In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
|
|
4094
1218
|
* this returns a class that runs `Bun.serve` + listeners inside the current process —
|
|
@@ -4097,7 +1221,6 @@ var Funnel = class Funnel {
|
|
|
4097
1221
|
gatewayServer(options = {}) {
|
|
4098
1222
|
return new FunnelGatewayServer({
|
|
4099
1223
|
channels: this.channels,
|
|
4100
|
-
settings: this.store,
|
|
4101
1224
|
port: options.port,
|
|
4102
1225
|
hostname: options.hostname,
|
|
4103
1226
|
dbPath: options.dbPath,
|
|
@@ -4112,6 +1235,20 @@ var Funnel = class Funnel {
|
|
|
4112
1235
|
extraRoutes: options.extraRoutes
|
|
4113
1236
|
});
|
|
4114
1237
|
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Run the gateway daemon in the foreground (tied to this terminal).
|
|
1240
|
+
* For background daemon management, use `funnel.gateway.start()` instead.
|
|
1241
|
+
*/
|
|
1242
|
+
async runGatewayForeground(options = {}) {
|
|
1243
|
+
const gatewayScript = resolveDaemonScript();
|
|
1244
|
+
const command = options.caffeinate !== false && globalThis.process.platform === "darwin" ? [
|
|
1245
|
+
"caffeinate",
|
|
1246
|
+
"-is",
|
|
1247
|
+
"bun",
|
|
1248
|
+
gatewayScript
|
|
1249
|
+
] : ["bun", gatewayScript];
|
|
1250
|
+
return this.process.attach(command);
|
|
1251
|
+
}
|
|
4115
1252
|
async debug(channelName) {
|
|
4116
1253
|
return buildFunnelDebugReport({
|
|
4117
1254
|
gateway: this.gateway,
|
|
@@ -4125,331 +1262,6 @@ var Funnel = class Funnel {
|
|
|
4125
1262
|
}
|
|
4126
1263
|
};
|
|
4127
1264
|
//#endregion
|
|
4128
|
-
//#region lib/engine/mcp/channel-subscriber.ts
|
|
4129
|
-
const RECONNECT_DELAY = 1e3;
|
|
4130
|
-
const MAX_RECONNECT_DELAY = 1e4;
|
|
4131
|
-
/**
|
|
4132
|
-
* Subscribes to the gateway WebSocket for a single channel and forwards
|
|
4133
|
-
* incoming events to the MCP server as `notifications/claude/channel`.
|
|
4134
|
-
* Reconnects with exponential backoff and replays missed events via `?since=<offset>`.
|
|
4135
|
-
*/
|
|
4136
|
-
var FunnelChannelSubscriber = class {
|
|
4137
|
-
state = {
|
|
4138
|
-
reconnectDelay: RECONNECT_DELAY,
|
|
4139
|
-
lastOffset: 0
|
|
4140
|
-
};
|
|
4141
|
-
constructor(props) {
|
|
4142
|
-
this.props = props;
|
|
4143
|
-
Object.freeze(this);
|
|
4144
|
-
}
|
|
4145
|
-
start() {
|
|
4146
|
-
this.connect();
|
|
4147
|
-
}
|
|
4148
|
-
connect() {
|
|
4149
|
-
const sinceQuery = this.state.lastOffset > 0 ? `&since=${this.state.lastOffset}` : "";
|
|
4150
|
-
const wsUrl = `${this.props.baseUrl}${sinceQuery}`;
|
|
4151
|
-
const ws = new WebSocket(wsUrl, this.props.protocols);
|
|
4152
|
-
ws.addEventListener("open", () => {
|
|
4153
|
-
this.state.reconnectDelay = RECONNECT_DELAY;
|
|
4154
|
-
process.stderr.write(`funnel: connected (${wsUrl})\n`);
|
|
4155
|
-
});
|
|
4156
|
-
ws.addEventListener("message", (event) => this.handleMessage(event));
|
|
4157
|
-
ws.addEventListener("close", () => {
|
|
4158
|
-
process.stderr.write(`funnel: disconnected, reconnecting in ${this.state.reconnectDelay}ms\n`);
|
|
4159
|
-
setTimeout(() => this.connect(), this.state.reconnectDelay);
|
|
4160
|
-
this.state.reconnectDelay = Math.min(this.state.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
4161
|
-
});
|
|
4162
|
-
ws.addEventListener("error", () => {});
|
|
4163
|
-
}
|
|
4164
|
-
async handleMessage(event) {
|
|
4165
|
-
try {
|
|
4166
|
-
const payload = JSON.parse(String(event.data));
|
|
4167
|
-
const eventType = payload.meta?.event_type ?? "unknown";
|
|
4168
|
-
if (typeof payload.offset === "number" && payload.offset > this.state.lastOffset) this.state.lastOffset = payload.offset;
|
|
4169
|
-
process.stderr.write(`funnel: received event (${eventType})\n`);
|
|
4170
|
-
await this.props.server.notification({
|
|
4171
|
-
method: "notifications/claude/channel",
|
|
4172
|
-
params: {
|
|
4173
|
-
content: payload.content,
|
|
4174
|
-
meta: payload.meta
|
|
4175
|
-
}
|
|
4176
|
-
});
|
|
4177
|
-
} catch (error) {
|
|
4178
|
-
process.stderr.write(`funnel: error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
4179
|
-
}
|
|
4180
|
-
}
|
|
4181
|
-
};
|
|
4182
|
-
//#endregion
|
|
4183
|
-
//#region lib/engine/mcp/read-channel-connectors.ts
|
|
4184
|
-
const TOOL_CONNECTOR_TYPES = new Set([
|
|
4185
|
-
"slack",
|
|
4186
|
-
"gh",
|
|
4187
|
-
"discord"
|
|
4188
|
-
]);
|
|
4189
|
-
const readChannelConnectors = (dir, channelId) => {
|
|
4190
|
-
const settingsPath = join(dir, "settings.json");
|
|
4191
|
-
if (!existsSync(settingsPath)) return null;
|
|
4192
|
-
const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
4193
|
-
const parsed = settingsSchema.safeParse(raw);
|
|
4194
|
-
if (!parsed.success) return null;
|
|
4195
|
-
const channel = parsed.data.channels.find((c) => c.id === channelId);
|
|
4196
|
-
if (!channel) return null;
|
|
4197
|
-
const connectors = channel.connectors.filter((c) => TOOL_CONNECTOR_TYPES.has(c.type)).map((c) => ({
|
|
4198
|
-
name: c.name,
|
|
4199
|
-
type: c.type
|
|
4200
|
-
}));
|
|
4201
|
-
return {
|
|
4202
|
-
channelName: channel.name,
|
|
4203
|
-
connectors
|
|
4204
|
-
};
|
|
4205
|
-
};
|
|
4206
|
-
//#endregion
|
|
4207
|
-
//#region lib/engine/mcp/read-gateway-token.ts
|
|
4208
|
-
const readGatewayToken = (dir) => {
|
|
4209
|
-
const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN;
|
|
4210
|
-
if (fromEnv && fromEnv.length > 0) return fromEnv;
|
|
4211
|
-
const path = join(dir, "gateway.token");
|
|
4212
|
-
if (!existsSync(path)) return null;
|
|
4213
|
-
const value = readFileSync(path, "utf-8").trim();
|
|
4214
|
-
return value.length > 0 ? value : null;
|
|
4215
|
-
};
|
|
4216
|
-
//#endregion
|
|
4217
|
-
//#region lib/engine/mcp/usage-hint-for-type.ts
|
|
4218
|
-
const usageHintForType = (type) => {
|
|
4219
|
-
if (type === "slack") return [
|
|
4220
|
-
"Slack Web API.",
|
|
4221
|
-
"To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
|
|
4222
|
-
"To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
|
|
4223
|
-
"Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
|
|
4224
|
-
].join(" ");
|
|
4225
|
-
if (type === "discord") return [
|
|
4226
|
-
"Discord REST API.",
|
|
4227
|
-
"To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
|
|
4228
|
-
"Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
|
|
4229
|
-
].join(" ");
|
|
4230
|
-
if (type === "gh") return [
|
|
4231
|
-
"GitHub REST via gh CLI.",
|
|
4232
|
-
"To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
|
|
4233
|
-
"Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
|
|
4234
|
-
].join(" ");
|
|
4235
|
-
return "Generic adapter call.";
|
|
4236
|
-
};
|
|
4237
|
-
//#endregion
|
|
4238
|
-
//#region lib/engine/mcp/channel-server.ts
|
|
4239
|
-
const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
|
|
4240
|
-
const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
|
|
4241
|
-
const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
|
|
4242
|
-
const readAllChannels = (dir) => {
|
|
4243
|
-
const settingsPath = join(dir, "settings.json");
|
|
4244
|
-
if (!existsSync(settingsPath)) return [];
|
|
4245
|
-
try {
|
|
4246
|
-
const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
4247
|
-
const parsed = settingsSchema.safeParse(raw);
|
|
4248
|
-
if (!parsed.success) return [];
|
|
4249
|
-
return parsed.data.channels.map((c) => ({
|
|
4250
|
-
id: c.id,
|
|
4251
|
-
name: c.name
|
|
4252
|
-
}));
|
|
4253
|
-
} catch {
|
|
4254
|
-
return [];
|
|
4255
|
-
}
|
|
4256
|
-
};
|
|
4257
|
-
const startChannelServer = async (options = {}) => {
|
|
4258
|
-
const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
|
|
4259
|
-
const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
|
|
4260
|
-
const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`;
|
|
4261
|
-
const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
|
|
4262
|
-
const channel = channelId ? readChannelConnectors(dir, channelId) : null;
|
|
4263
|
-
const token = options.token ?? readGatewayToken(dir);
|
|
4264
|
-
const allChannels = readAllChannels(dir);
|
|
4265
|
-
const currentChannelName = channel?.channelName ?? null;
|
|
4266
|
-
const channelContext = allChannels.length > 0 ? [
|
|
4267
|
-
"",
|
|
4268
|
-
"Configured channels (use as the `channel` argument to fnl_debug):",
|
|
4269
|
-
...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
|
|
4270
|
-
].join("\n") : "";
|
|
4271
|
-
const server = new Server({
|
|
4272
|
-
name: FUNNEL_MCP_NAME,
|
|
4273
|
-
version: "1.0.0"
|
|
4274
|
-
}, {
|
|
4275
|
-
capabilities: {
|
|
4276
|
-
experimental: { "claude/channel": {} },
|
|
4277
|
-
tools: {}
|
|
4278
|
-
},
|
|
4279
|
-
instructions: [
|
|
4280
|
-
`Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
|
|
4281
|
-
` content — the event payload as a JSON string (parse it to read the message)`,
|
|
4282
|
-
` meta — key/value strings describing the event`,
|
|
4283
|
-
"",
|
|
4284
|
-
"meta fields by event_type:",
|
|
4285
|
-
" slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
|
|
4286
|
-
" gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
|
|
4287
|
-
" discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
|
|
4288
|
-
" schedule: event_type=schedule entry_id=…",
|
|
4289
|
-
"",
|
|
4290
|
-
"To reply to a Slack message in the same thread, call the connector tool with:",
|
|
4291
|
-
` method: POST`,
|
|
4292
|
-
` path: chat.postMessage`,
|
|
4293
|
-
` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
|
|
4294
|
-
"",
|
|
4295
|
-
"To comment on a GitHub issue/PR (extract from subject_url in meta):",
|
|
4296
|
-
` method: POST`,
|
|
4297
|
-
` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
|
|
4298
|
-
` body: { body: "your reply" }`,
|
|
4299
|
-
"",
|
|
4300
|
-
"Built-in diagnostic tools — call proactively when events seem missing or delayed:",
|
|
4301
|
-
" fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
|
|
4302
|
-
" fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
|
|
4303
|
-
" omit channel arg to diagnose all channels; check summary.suggestedActions first",
|
|
4304
|
-
channelContext
|
|
4305
|
-
].join("\n")
|
|
4306
|
-
});
|
|
4307
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
4308
|
-
const connectorTools = (channel?.connectors ?? []).map((c) => ({
|
|
4309
|
-
name: c.name,
|
|
4310
|
-
description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
|
|
4311
|
-
inputSchema: {
|
|
4312
|
-
type: "object",
|
|
4313
|
-
properties: {
|
|
4314
|
-
method: {
|
|
4315
|
-
type: "string",
|
|
4316
|
-
description: "HTTP verb or API method (e.g. POST, chat.postMessage)"
|
|
4317
|
-
},
|
|
4318
|
-
path: {
|
|
4319
|
-
type: "string",
|
|
4320
|
-
description: "API path or method name (adapter-specific)"
|
|
4321
|
-
},
|
|
4322
|
-
body: {
|
|
4323
|
-
type: "object",
|
|
4324
|
-
description: "Request body / params (adapter-specific)"
|
|
4325
|
-
}
|
|
4326
|
-
},
|
|
4327
|
-
required: ["method", "path"]
|
|
4328
|
-
}
|
|
4329
|
-
}));
|
|
4330
|
-
const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
|
|
4331
|
-
const builtinTools = [{
|
|
4332
|
-
name: "fnl_status",
|
|
4333
|
-
description: "Return the current funnel gateway status as JSON — gateway running state, listener alive/dead per channel, and connected Claude WS clients. Call this when you need to check whether the gateway is up or why events stopped arriving.",
|
|
4334
|
-
inputSchema: {
|
|
4335
|
-
type: "object",
|
|
4336
|
-
properties: {}
|
|
4337
|
-
}
|
|
4338
|
-
}, {
|
|
4339
|
-
name: "fnl_debug",
|
|
4340
|
-
description: "Return a full channel diagnosis as JSON — gateway health, listener state, Claude WS connection, last 10 inbound events with outcome, connectionErrors (when listener is dead), and diagnosis.rootCause. Call this first when debugging missing events. Omit `channel` to diagnose all channels at once.",
|
|
4341
|
-
inputSchema: {
|
|
4342
|
-
type: "object",
|
|
4343
|
-
properties: { channel: channelEnum ? {
|
|
4344
|
-
type: "string",
|
|
4345
|
-
description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
|
|
4346
|
-
enum: channelEnum
|
|
4347
|
-
} : {
|
|
4348
|
-
type: "string",
|
|
4349
|
-
description: "Channel name to inspect. Omit to get all channels."
|
|
4350
|
-
} }
|
|
4351
|
-
}
|
|
4352
|
-
}];
|
|
4353
|
-
return { tools: [...connectorTools, ...builtinTools] };
|
|
4354
|
-
});
|
|
4355
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4356
|
-
const toolName = request.params.name;
|
|
4357
|
-
if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
|
|
4358
|
-
if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
|
|
4359
|
-
const args = request.params.arguments ?? {};
|
|
4360
|
-
const method = typeof args.method === "string" ? args.method : "";
|
|
4361
|
-
const path = typeof args.path === "string" ? args.path : "";
|
|
4362
|
-
const body = args.body ?? {};
|
|
4363
|
-
if (!method || !path) throw new Error("`method` and `path` are required");
|
|
4364
|
-
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
|
|
4365
|
-
const headers = { "content-type": "application/json" };
|
|
4366
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
4367
|
-
const res = await fetch(url, {
|
|
4368
|
-
method: "POST",
|
|
4369
|
-
headers,
|
|
4370
|
-
body: JSON.stringify({
|
|
4371
|
-
method,
|
|
4372
|
-
path,
|
|
4373
|
-
body
|
|
4374
|
-
})
|
|
4375
|
-
});
|
|
4376
|
-
const text = await res.text();
|
|
4377
|
-
if (!res.ok) throw new Error(`gateway call failed (${res.status}): ${text}`);
|
|
4378
|
-
return { content: [{
|
|
4379
|
-
type: "text",
|
|
4380
|
-
text
|
|
4381
|
-
}] };
|
|
4382
|
-
});
|
|
4383
|
-
const transport = new StdioServerTransport();
|
|
4384
|
-
await server.connect(transport);
|
|
4385
|
-
if (!channelId) return;
|
|
4386
|
-
new FunnelChannelSubscriber({
|
|
4387
|
-
server,
|
|
4388
|
-
baseUrl: `${gatewayWsUrl}?channel=${encodeURIComponent(channelId)}`,
|
|
4389
|
-
protocols: token ? [`funnel.token.${token}`] : void 0
|
|
4390
|
-
}).start();
|
|
4391
|
-
};
|
|
4392
|
-
const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
|
|
4393
|
-
const headers = {};
|
|
4394
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
4395
|
-
if (name === "fnl_status") {
|
|
4396
|
-
const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
|
|
4397
|
-
if (!res) return { content: [{
|
|
4398
|
-
type: "text",
|
|
4399
|
-
text: JSON.stringify({
|
|
4400
|
-
running: false,
|
|
4401
|
-
error: "gateway unreachable",
|
|
4402
|
-
hint: "run: fnl gateway start",
|
|
4403
|
-
knownChannels: allChannels.map((ch) => ch.name)
|
|
4404
|
-
})
|
|
4405
|
-
}] };
|
|
4406
|
-
const body = await res.json();
|
|
4407
|
-
return { content: [{
|
|
4408
|
-
type: "text",
|
|
4409
|
-
text: JSON.stringify(body)
|
|
4410
|
-
}] };
|
|
4411
|
-
}
|
|
4412
|
-
const channelArg = typeof args?.channel === "string" ? args.channel : null;
|
|
4413
|
-
const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
|
|
4414
|
-
const res = await fetch(url, { headers }).catch(() => null);
|
|
4415
|
-
if (!res) return { content: [{
|
|
4416
|
-
type: "text",
|
|
4417
|
-
text: JSON.stringify({
|
|
4418
|
-
gateway: { running: false },
|
|
4419
|
-
channels: allChannels.map((ch) => ({
|
|
4420
|
-
id: ch.id,
|
|
4421
|
-
name: ch.name,
|
|
4422
|
-
diagnosis: {
|
|
4423
|
-
status: "error",
|
|
4424
|
-
message: "gateway is not running",
|
|
4425
|
-
nextAction: "fnl gateway start",
|
|
4426
|
-
rootCause: null
|
|
4427
|
-
}
|
|
4428
|
-
}))
|
|
4429
|
-
})
|
|
4430
|
-
}] };
|
|
4431
|
-
const body = await res.json();
|
|
4432
|
-
return { content: [{
|
|
4433
|
-
type: "text",
|
|
4434
|
-
text: JSON.stringify(body)
|
|
4435
|
-
}] };
|
|
4436
|
-
};
|
|
4437
|
-
//#endregion
|
|
4438
|
-
//#region lib/engine/local-config/local-config-json-schema.ts
|
|
4439
|
-
/**
|
|
4440
|
-
* Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
|
|
4441
|
-
* `$schema` references in committed `funnel.json` files so editors can give
|
|
4442
|
-
* autocomplete and validation for channels[] (transport) and profiles[]
|
|
4443
|
-
* (launch recipe) without anyone hand-maintaining a separate schema.
|
|
4444
|
-
*/
|
|
4445
|
-
const funnelJsonSchema = () => {
|
|
4446
|
-
return {
|
|
4447
|
-
...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
|
|
4448
|
-
title: "Funnel per-repo launch config",
|
|
4449
|
-
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."
|
|
4450
|
-
};
|
|
4451
|
-
};
|
|
4452
|
-
//#endregion
|
|
4453
1265
|
//#region lib/engine/logger/node-logger.ts
|
|
4454
1266
|
const defaultLogFile = () => join(funnelTmpDir(), "funnel.log");
|
|
4455
1267
|
var NodeFunnelLogger = class extends FunnelLogger {
|
|
@@ -4490,493 +1302,6 @@ var NoopFunnelLogger = class extends FunnelLogger {
|
|
|
4490
1302
|
error() {}
|
|
4491
1303
|
};
|
|
4492
1304
|
//#endregion
|
|
4493
|
-
//#region lib/engine/token-prompter/memory-token-prompter.ts
|
|
4494
|
-
/**
|
|
4495
|
-
* Pre-seeded answers keyed by prompt label. Tests configure the map up front;
|
|
4496
|
-
* unmapped labels throw so the test surfaces unexpected prompts loudly.
|
|
4497
|
-
*/
|
|
4498
|
-
var MemoryFunnelTokenPrompter = class extends FunnelTokenPrompter {
|
|
4499
|
-
answers;
|
|
4500
|
-
asked = [];
|
|
4501
|
-
constructor(props = {}) {
|
|
4502
|
-
super();
|
|
4503
|
-
this.answers = new Map(Object.entries(props.answers ?? {}));
|
|
4504
|
-
}
|
|
4505
|
-
async promptSecret(label) {
|
|
4506
|
-
this.asked.push(label);
|
|
4507
|
-
const answer = this.answers.get(label);
|
|
4508
|
-
if (answer === void 0) throw new Error(`no answer seeded for prompt "${label}"`);
|
|
4509
|
-
return answer;
|
|
4510
|
-
}
|
|
4511
|
-
};
|
|
4512
|
-
//#endregion
|
|
4513
|
-
//#region lib/gateway/memory-funnel-event-log.ts
|
|
4514
|
-
/**
|
|
4515
|
-
* In-process `FunnelEventLog` backed by a plain array. Used by tests and by
|
|
4516
|
-
* embedders that do not need durability — replay works within the process
|
|
4517
|
-
* lifetime but is lost when the process exits. Unlike the SQLite log it does
|
|
4518
|
-
* not truncate content or prune, so it is not meant for unbounded production
|
|
4519
|
-
* traffic.
|
|
4520
|
-
*/
|
|
4521
|
-
var MemoryFunnelEventLog = class extends FunnelEventLog {
|
|
4522
|
-
events = [];
|
|
4523
|
-
constructor() {
|
|
4524
|
-
super();
|
|
4525
|
-
Object.freeze(this);
|
|
4526
|
-
}
|
|
4527
|
-
record(record) {
|
|
4528
|
-
this.events.push({
|
|
4529
|
-
offset: record.offset,
|
|
4530
|
-
content: record.content,
|
|
4531
|
-
meta: record.meta ?? void 0,
|
|
4532
|
-
channelId: record.channelId,
|
|
4533
|
-
connectorId: record.connectorId
|
|
4534
|
-
});
|
|
4535
|
-
}
|
|
4536
|
-
loadSince(since) {
|
|
4537
|
-
const out = [];
|
|
4538
|
-
for (const event of this.events) if (event.offset > since) out.push({
|
|
4539
|
-
content: event.content,
|
|
4540
|
-
meta: event.meta,
|
|
4541
|
-
offset: event.offset
|
|
4542
|
-
});
|
|
4543
|
-
return out;
|
|
4544
|
-
}
|
|
4545
|
-
findMaxOffset() {
|
|
4546
|
-
let max = 0;
|
|
4547
|
-
for (const event of this.events) if (event.offset > max) max = event.offset;
|
|
4548
|
-
return max;
|
|
4549
|
-
}
|
|
4550
|
-
clear() {
|
|
4551
|
-
this.events.length = 0;
|
|
4552
|
-
}
|
|
4553
|
-
close() {}
|
|
4554
|
-
};
|
|
4555
|
-
//#endregion
|
|
4556
|
-
//#region lib/gateway/connector-diagnostic-log.ts
|
|
4557
|
-
/**
|
|
4558
|
-
* Points in the listener's connection lifecycle. The single source of truth
|
|
4559
|
-
* for the value set: the `status` column schema, the `ConnectorConnectionStatus`
|
|
4560
|
-
* union, and the runtime Set used to narrow on read-back all derive from this
|
|
4561
|
-
* array, so adding a status is a one-line change that cannot drift out of sync.
|
|
4562
|
-
*
|
|
4563
|
-
* started start() was called
|
|
4564
|
-
* connected the socket opened and events can flow
|
|
4565
|
-
* disconnected the socket was closed by a stop() call (a clean teardown)
|
|
4566
|
-
* auth-failed the token was rejected before the socket opened
|
|
4567
|
-
* stopped the listener was fully torn down (always follows a stop(),
|
|
4568
|
-
* paired with the disconnected/error that preceded it)
|
|
4569
|
-
* error start/stop threw, or Bolt surfaced an error frame — this is
|
|
4570
|
-
* also where an unsolicited socket drop shows up when Bolt
|
|
4571
|
-
* reports it (an `error` with no following `stopped` means the
|
|
4572
|
-
* supervisor recycled the listener, not a clean stop)
|
|
4573
|
-
*
|
|
4574
|
-
* A connection row is independent of any single inbound event, so it carries
|
|
4575
|
-
* no `eventId`. This is how "no notification arrived because the listener
|
|
4576
|
-
* never connected (or dropped, or failed auth)" becomes visible: the
|
|
4577
|
-
* raw/processed tables only hold events that *did* arrive.
|
|
4578
|
-
*/
|
|
4579
|
-
const CONNECTOR_CONNECTION_STATUSES = [
|
|
4580
|
-
"started",
|
|
4581
|
-
"connected",
|
|
4582
|
-
"disconnected",
|
|
4583
|
-
"auth-failed",
|
|
4584
|
-
"stopped",
|
|
4585
|
-
"error"
|
|
4586
|
-
];
|
|
4587
|
-
/**
|
|
4588
|
-
* Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
|
|
4589
|
-
* carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
|
|
4590
|
-
* connectors land in the same tables without a schema change. `event_id` is
|
|
4591
|
-
* the correlation key the listener mints once per inbound event and stamps
|
|
4592
|
-
* onto both the raw and processed rows, so the two are joinable even though
|
|
4593
|
-
* they live in separate tables with independent `seq` counters.
|
|
4594
|
-
*
|
|
4595
|
-
* These schemas mirror the stored shape (snake_case columns) the way
|
|
4596
|
-
* `FunnelEvent` does for the replay log; they exist for `z.infer` and to
|
|
4597
|
-
* document the column set, not as a parse boundary.
|
|
4598
|
-
*/
|
|
4599
|
-
const connectorRawEventSchema = z.object({
|
|
4600
|
-
event_id: z.string(),
|
|
4601
|
-
type: z.string(),
|
|
4602
|
-
connector_id: z.string().nullable(),
|
|
4603
|
-
channel_id: z.string().nullable(),
|
|
4604
|
-
payload: z.string()
|
|
4605
|
-
});
|
|
4606
|
-
const connectorProcessedEventSchema = z.object({
|
|
4607
|
-
event_id: z.string(),
|
|
4608
|
-
type: z.string(),
|
|
4609
|
-
connector_id: z.string().nullable(),
|
|
4610
|
-
channel_id: z.string().nullable(),
|
|
4611
|
-
outcome: z.string(),
|
|
4612
|
-
payload: z.string()
|
|
4613
|
-
});
|
|
4614
|
-
const connectorConnectionEventSchema = z.object({
|
|
4615
|
-
type: z.string(),
|
|
4616
|
-
connector_id: z.string().nullable(),
|
|
4617
|
-
channel_id: z.string().nullable(),
|
|
4618
|
-
status: z.enum(CONNECTOR_CONNECTION_STATUSES),
|
|
4619
|
-
detail: z.string()
|
|
4620
|
-
});
|
|
4621
|
-
/**
|
|
4622
|
-
* Three-table diagnostic log of everything a connector listener does, so
|
|
4623
|
-
* "why was there no notification?" is answerable whichever way it failed:
|
|
4624
|
-
* - `raw` — every inbound event, before any filtering, with the listener's
|
|
4625
|
-
* untouched payload (the Slack Bolt event, the GH webhook, …)
|
|
4626
|
-
* - `processed` — the verdict for that event: `outcome` (emitted, or the
|
|
4627
|
-
* reason it was dropped) and, when emitted, the body that was delivered.
|
|
4628
|
-
* Shares an `eventId` with its raw row, so the two join into one story.
|
|
4629
|
-
* - `connection` — the listener's lifecycle (started, connected, dropped,
|
|
4630
|
-
* auth-failed, stopped, errored). This is the half the event tables can't
|
|
4631
|
-
* show: an event that never arrived leaves no raw row, but a listener that
|
|
4632
|
-
* never connected leaves a `connection` trail that says so.
|
|
4633
|
-
*
|
|
4634
|
-
* The three are physically separate (independent retention and payload-size
|
|
4635
|
-
* policy) so a query never crosses them by accident and a huge raw payload
|
|
4636
|
-
* never bloats the verdict or lifecycle trails. None flow to WS clients or the
|
|
4637
|
-
* MCP channel — this is a separate store from `FunnelEventLog` (replay) and
|
|
4638
|
-
* exists solely for debugging.
|
|
4639
|
-
*
|
|
4640
|
-
* Implementations:
|
|
4641
|
-
* - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
|
|
4642
|
-
* bounded by per-table row/age caps.
|
|
4643
|
-
* - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
|
|
4644
|
-
*/
|
|
4645
|
-
var ConnectorDiagnosticLog = class {};
|
|
4646
|
-
//#endregion
|
|
4647
|
-
//#region lib/gateway/sqlite-connector-diagnostic-log.ts
|
|
4648
|
-
/**
|
|
4649
|
-
* Cap on a raw payload kept verbatim. The point of the raw table is to see
|
|
4650
|
-
* what Slack/Discord actually sent, and a typical event is a few KB — so 256
|
|
4651
|
-
* KiB keeps essentially everything intact while bounding the rare giant
|
|
4652
|
-
* payload (a huge Block Kit message, a file dump) that would otherwise let a
|
|
4653
|
-
* single row bloat the debug database without limit.
|
|
4654
|
-
*/
|
|
4655
|
-
const RAW_PAYLOAD_CAP = 256 * 1024;
|
|
4656
|
-
/**
|
|
4657
|
-
* Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
|
|
4658
|
-
* per table (raw / processed / connection), in separate files. Each sink
|
|
4659
|
-
* indexes the columns its queries filter on — `event_id` / `connector_id` /
|
|
4660
|
-
* `channel_id` for raw, plus `outcome` for processed and `status` for
|
|
4661
|
-
* connection — so those lookups are indexed scans (`type` is a fixed column
|
|
4662
|
-
* the sink extracts separately, not an index, so filtering by it is a scan).
|
|
4663
|
-
*
|
|
4664
|
-
* The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
|
|
4665
|
-
* truncating mid-string (which yields unparseable JSON), it replaces the
|
|
4666
|
-
* body with a small JSON object that keeps the diagnostic essentials and
|
|
4667
|
-
* records the dropped size under `_funnel_oversized`. Every stored payload
|
|
4668
|
-
* therefore stays valid JSON.
|
|
4669
|
-
*/
|
|
4670
|
-
var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
|
|
4671
|
-
raw;
|
|
4672
|
-
processed;
|
|
4673
|
-
connection;
|
|
4674
|
-
now;
|
|
4675
|
-
logger;
|
|
4676
|
-
constructor(props) {
|
|
4677
|
-
super();
|
|
4678
|
-
this.now = props.now ?? (() => Date.now());
|
|
4679
|
-
this.logger = props.logger;
|
|
4680
|
-
const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
|
|
4681
|
-
const verdictCap = {
|
|
4682
|
-
now: this.now,
|
|
4683
|
-
...ageCap,
|
|
4684
|
-
...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
|
|
4685
|
-
};
|
|
4686
|
-
const rawMax = props.rawMaxRows ?? props.maxRows;
|
|
4687
|
-
const rawCap = {
|
|
4688
|
-
now: this.now,
|
|
4689
|
-
...ageCap,
|
|
4690
|
-
...rawMax !== void 0 ? { maxRows: rawMax } : {}
|
|
4691
|
-
};
|
|
4692
|
-
this.raw = new LeucoLoggerSqliteSink({
|
|
4693
|
-
path: props.rawPath,
|
|
4694
|
-
indexes: [
|
|
4695
|
-
"event_id",
|
|
4696
|
-
"connector_id",
|
|
4697
|
-
"channel_id"
|
|
4698
|
-
],
|
|
4699
|
-
extractIndexes: (event) => ({
|
|
4700
|
-
event_id: event.event_id,
|
|
4701
|
-
connector_id: event.connector_id,
|
|
4702
|
-
channel_id: event.channel_id
|
|
4703
|
-
}),
|
|
4704
|
-
...rawCap
|
|
4705
|
-
});
|
|
4706
|
-
this.processed = new LeucoLoggerSqliteSink({
|
|
4707
|
-
path: props.processedPath,
|
|
4708
|
-
indexes: [
|
|
4709
|
-
"event_id",
|
|
4710
|
-
"connector_id",
|
|
4711
|
-
"channel_id",
|
|
4712
|
-
"outcome"
|
|
4713
|
-
],
|
|
4714
|
-
extractIndexes: (event) => ({
|
|
4715
|
-
event_id: event.event_id,
|
|
4716
|
-
connector_id: event.connector_id,
|
|
4717
|
-
channel_id: event.channel_id,
|
|
4718
|
-
outcome: event.outcome
|
|
4719
|
-
}),
|
|
4720
|
-
...verdictCap
|
|
4721
|
-
});
|
|
4722
|
-
this.connection = new LeucoLoggerSqliteSink({
|
|
4723
|
-
path: props.connectionPath,
|
|
4724
|
-
indexes: [
|
|
4725
|
-
"connector_id",
|
|
4726
|
-
"channel_id",
|
|
4727
|
-
"status"
|
|
4728
|
-
],
|
|
4729
|
-
extractIndexes: (event) => ({
|
|
4730
|
-
connector_id: event.connector_id,
|
|
4731
|
-
channel_id: event.channel_id,
|
|
4732
|
-
status: event.status
|
|
4733
|
-
}),
|
|
4734
|
-
...verdictCap
|
|
4735
|
-
});
|
|
4736
|
-
restrictPermissions(props.rawPath);
|
|
4737
|
-
restrictPermissions(props.processedPath);
|
|
4738
|
-
restrictPermissions(props.connectionPath);
|
|
4739
|
-
Object.freeze(this);
|
|
4740
|
-
}
|
|
4741
|
-
recordRaw(record) {
|
|
4742
|
-
const event = {
|
|
4743
|
-
event_id: record.eventId,
|
|
4744
|
-
type: record.type,
|
|
4745
|
-
connector_id: record.connectorId,
|
|
4746
|
-
channel_id: record.channelId,
|
|
4747
|
-
payload: capPayload(record.payload, record.type)
|
|
4748
|
-
};
|
|
4749
|
-
this.report("raw", this.raw.insert({
|
|
4750
|
-
ts: this.now(),
|
|
4751
|
-
event
|
|
4752
|
-
}));
|
|
4753
|
-
}
|
|
4754
|
-
recordProcessed(record) {
|
|
4755
|
-
const event = {
|
|
4756
|
-
event_id: record.eventId,
|
|
4757
|
-
type: record.type,
|
|
4758
|
-
connector_id: record.connectorId,
|
|
4759
|
-
channel_id: record.channelId,
|
|
4760
|
-
outcome: record.outcome,
|
|
4761
|
-
payload: record.payload
|
|
4762
|
-
};
|
|
4763
|
-
this.report("processed", this.processed.insert({
|
|
4764
|
-
ts: this.now(),
|
|
4765
|
-
event
|
|
4766
|
-
}));
|
|
4767
|
-
}
|
|
4768
|
-
recordConnection(record) {
|
|
4769
|
-
const event = {
|
|
4770
|
-
type: record.type,
|
|
4771
|
-
connector_id: record.connectorId,
|
|
4772
|
-
channel_id: record.channelId,
|
|
4773
|
-
status: record.status,
|
|
4774
|
-
detail: record.detail
|
|
4775
|
-
};
|
|
4776
|
-
this.report("connection", this.connection.insert({
|
|
4777
|
-
ts: this.now(),
|
|
4778
|
-
event
|
|
4779
|
-
}));
|
|
4780
|
-
}
|
|
4781
|
-
report(table, result) {
|
|
4782
|
-
if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
|
|
4783
|
-
table,
|
|
4784
|
-
error: result.message
|
|
4785
|
-
});
|
|
4786
|
-
}
|
|
4787
|
-
queryRaw(query) {
|
|
4788
|
-
return this.raw.getRecords({
|
|
4789
|
-
...query.type !== void 0 ? { type: query.type } : {},
|
|
4790
|
-
...query.limit !== void 0 ? { limit: query.limit } : {},
|
|
4791
|
-
where: buildWhere(query),
|
|
4792
|
-
order: "desc"
|
|
4793
|
-
}).map((record) => ({
|
|
4794
|
-
seq: record.seq,
|
|
4795
|
-
ts: record.ts,
|
|
4796
|
-
eventId: record.event.event_id,
|
|
4797
|
-
type: record.event.type,
|
|
4798
|
-
connectorId: record.event.connector_id,
|
|
4799
|
-
channelId: record.event.channel_id,
|
|
4800
|
-
payload: record.event.payload
|
|
4801
|
-
}));
|
|
4802
|
-
}
|
|
4803
|
-
queryProcessed(query) {
|
|
4804
|
-
const where = buildWhere(query);
|
|
4805
|
-
if (query.outcome !== void 0) where.outcome = query.outcome;
|
|
4806
|
-
return this.processed.getRecords({
|
|
4807
|
-
...query.type !== void 0 ? { type: query.type } : {},
|
|
4808
|
-
...query.limit !== void 0 ? { limit: query.limit } : {},
|
|
4809
|
-
where,
|
|
4810
|
-
order: "desc"
|
|
4811
|
-
}).map((record) => ({
|
|
4812
|
-
seq: record.seq,
|
|
4813
|
-
ts: record.ts,
|
|
4814
|
-
eventId: record.event.event_id,
|
|
4815
|
-
type: record.event.type,
|
|
4816
|
-
connectorId: record.event.connector_id,
|
|
4817
|
-
channelId: record.event.channel_id,
|
|
4818
|
-
outcome: record.event.outcome,
|
|
4819
|
-
payload: record.event.payload
|
|
4820
|
-
}));
|
|
4821
|
-
}
|
|
4822
|
-
queryConnection(query) {
|
|
4823
|
-
const where = buildWhere(query);
|
|
4824
|
-
if (query.status !== void 0) where.status = query.status;
|
|
4825
|
-
return this.connection.getRecords({
|
|
4826
|
-
...query.type !== void 0 ? { type: query.type } : {},
|
|
4827
|
-
...query.limit !== void 0 ? { limit: query.limit } : {},
|
|
4828
|
-
where,
|
|
4829
|
-
order: "desc"
|
|
4830
|
-
}).map((record) => ({
|
|
4831
|
-
seq: record.seq,
|
|
4832
|
-
ts: record.ts,
|
|
4833
|
-
type: record.event.type,
|
|
4834
|
-
connectorId: record.event.connector_id,
|
|
4835
|
-
channelId: record.event.channel_id,
|
|
4836
|
-
status: statusOf(record.event.status),
|
|
4837
|
-
detail: record.event.detail
|
|
4838
|
-
}));
|
|
4839
|
-
}
|
|
4840
|
-
clear() {
|
|
4841
|
-
this.raw.clear();
|
|
4842
|
-
this.processed.clear();
|
|
4843
|
-
this.connection.clear();
|
|
4844
|
-
}
|
|
4845
|
-
close() {
|
|
4846
|
-
this.raw.close();
|
|
4847
|
-
this.processed.close();
|
|
4848
|
-
this.connection.close();
|
|
4849
|
-
}
|
|
4850
|
-
};
|
|
4851
|
-
const restrictPermissions = (path) => {
|
|
4852
|
-
if (path === ":memory:") return;
|
|
4853
|
-
for (const suffix of [
|
|
4854
|
-
"",
|
|
4855
|
-
"-wal",
|
|
4856
|
-
"-shm"
|
|
4857
|
-
]) try {
|
|
4858
|
-
chmodSync(`${path}${suffix}`, 384);
|
|
4859
|
-
} catch {}
|
|
4860
|
-
};
|
|
4861
|
-
const buildWhere = (query) => {
|
|
4862
|
-
const where = {};
|
|
4863
|
-
if (query.connectorId !== void 0) where.connector_id = query.connectorId;
|
|
4864
|
-
if (query.channelId !== void 0) where.channel_id = query.channelId;
|
|
4865
|
-
return where;
|
|
4866
|
-
};
|
|
4867
|
-
const statusField = connectorConnectionEventSchema.shape.status;
|
|
4868
|
-
const statusOf = (value) => {
|
|
4869
|
-
const parsed = statusField.safeParse(value);
|
|
4870
|
-
return parsed.success ? parsed.data : "error";
|
|
4871
|
-
};
|
|
4872
|
-
const capPayload = (payload, type) => {
|
|
4873
|
-
const size = Buffer.byteLength(payload, "utf8");
|
|
4874
|
-
if (size <= RAW_PAYLOAD_CAP) return payload;
|
|
4875
|
-
return JSON.stringify({
|
|
4876
|
-
...headFields(payload),
|
|
4877
|
-
_funnel_oversized: size,
|
|
4878
|
-
_funnel_type: type
|
|
4879
|
-
});
|
|
4880
|
-
};
|
|
4881
|
-
const HEAD_KEYS = [
|
|
4882
|
-
"type",
|
|
4883
|
-
"subtype",
|
|
4884
|
-
"ts",
|
|
4885
|
-
"channel",
|
|
4886
|
-
"channel_type",
|
|
4887
|
-
"user",
|
|
4888
|
-
"bot_id"
|
|
4889
|
-
];
|
|
4890
|
-
const headFields = (payload) => {
|
|
4891
|
-
try {
|
|
4892
|
-
const parsed = JSON.parse(payload);
|
|
4893
|
-
if (typeof parsed !== "object" || parsed === null) return {};
|
|
4894
|
-
const source = parsed;
|
|
4895
|
-
const head = {};
|
|
4896
|
-
for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
|
|
4897
|
-
return head;
|
|
4898
|
-
} catch {
|
|
4899
|
-
return {};
|
|
4900
|
-
}
|
|
4901
|
-
};
|
|
4902
|
-
//#endregion
|
|
4903
|
-
//#region lib/gateway/memory-connector-diagnostic-log.ts
|
|
4904
|
-
/**
|
|
4905
|
-
* In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
|
|
4906
|
-
* and embedders that do not need durability. Like the SQLite log it keeps
|
|
4907
|
-
* `seq` per-table (each array's 1-based position) and returns the most recent
|
|
4908
|
-
* `limit` rows oldest-first; unlike it, it never prunes and never offloads
|
|
4909
|
-
* oversized payloads — it keeps whatever the caller hands it, which is fine
|
|
4910
|
-
* for the bounded volumes a test produces. Payload-validity is therefore a
|
|
4911
|
-
* SQLite-only guarantee; do not write a test that leans on this double
|
|
4912
|
-
* rejecting a malformed payload.
|
|
4913
|
-
*/
|
|
4914
|
-
var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
|
|
4915
|
-
raws = [];
|
|
4916
|
-
processeds = [];
|
|
4917
|
-
connections = [];
|
|
4918
|
-
constructor(now = () => Date.now()) {
|
|
4919
|
-
super();
|
|
4920
|
-
this.now = now;
|
|
4921
|
-
Object.freeze(this);
|
|
4922
|
-
}
|
|
4923
|
-
recordRaw(record) {
|
|
4924
|
-
this.raws.push({
|
|
4925
|
-
...record,
|
|
4926
|
-
seq: this.raws.length + 1,
|
|
4927
|
-
ts: this.now()
|
|
4928
|
-
});
|
|
4929
|
-
}
|
|
4930
|
-
recordProcessed(record) {
|
|
4931
|
-
this.processeds.push({
|
|
4932
|
-
...record,
|
|
4933
|
-
seq: this.processeds.length + 1,
|
|
4934
|
-
ts: this.now()
|
|
4935
|
-
});
|
|
4936
|
-
}
|
|
4937
|
-
recordConnection(record) {
|
|
4938
|
-
this.connections.push({
|
|
4939
|
-
...record,
|
|
4940
|
-
seq: this.connections.length + 1,
|
|
4941
|
-
ts: this.now()
|
|
4942
|
-
});
|
|
4943
|
-
}
|
|
4944
|
-
queryRaw(query) {
|
|
4945
|
-
return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
|
|
4946
|
-
}
|
|
4947
|
-
queryProcessed(query) {
|
|
4948
|
-
return takeRecent(this.processeds.filter((event) => {
|
|
4949
|
-
if (!matches(event, query)) return false;
|
|
4950
|
-
if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
|
|
4951
|
-
return true;
|
|
4952
|
-
}), query.limit);
|
|
4953
|
-
}
|
|
4954
|
-
queryConnection(query) {
|
|
4955
|
-
return takeRecent(this.connections.filter((event) => {
|
|
4956
|
-
if (!matches(event, query)) return false;
|
|
4957
|
-
if (query.status !== void 0 && event.status !== query.status) return false;
|
|
4958
|
-
return true;
|
|
4959
|
-
}), query.limit);
|
|
4960
|
-
}
|
|
4961
|
-
clear() {
|
|
4962
|
-
this.raws.length = 0;
|
|
4963
|
-
this.processeds.length = 0;
|
|
4964
|
-
this.connections.length = 0;
|
|
4965
|
-
}
|
|
4966
|
-
close() {}
|
|
4967
|
-
};
|
|
4968
|
-
const matches = (event, query) => {
|
|
4969
|
-
if (query.type !== void 0 && event.type !== query.type) return false;
|
|
4970
|
-
if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
|
|
4971
|
-
if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
|
|
4972
|
-
return true;
|
|
4973
|
-
};
|
|
4974
|
-
const takeRecent = (events, limit) => {
|
|
4975
|
-
if (limit === void 0) return events;
|
|
4976
|
-
if (limit <= 0) return [];
|
|
4977
|
-
return events.slice(-limit);
|
|
4978
|
-
};
|
|
4979
|
-
//#endregion
|
|
4980
1305
|
//#region lib/cli/factory.ts
|
|
4981
1306
|
const factory = createFactory();
|
|
4982
1307
|
//#endregion
|
|
@@ -5465,7 +1790,6 @@ modes:
|
|
|
5465
1790
|
fanout every connected WS client receives every event (default)
|
|
5466
1791
|
exclusive each event is delivered to exactly one connected client (round-robin)
|
|
5467
1792
|
|
|
5468
|
-
tap=all clients (TUI dashboard, debugging) always receive regardless of mode.
|
|
5469
1793
|
`), (c) => {
|
|
5470
1794
|
const param = c.req.valid("param");
|
|
5471
1795
|
c.env.funnel.channels.setDelivery(param.channel, param.mode);
|
|
@@ -5662,19 +1986,19 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
5662
1986
|
channel: z.string().optional()
|
|
5663
1987
|
}).passthrough(), claudeHelp), async (c) => {
|
|
5664
1988
|
const query = c.req.valid("query");
|
|
5665
|
-
const funnel = c.env
|
|
1989
|
+
const { funnel, claude, profiles, localConfig, localConfigSync } = c.env;
|
|
5666
1990
|
const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
|
|
5667
1991
|
if (query.channel && !query.profile) {
|
|
5668
|
-
const exitCode = await
|
|
1992
|
+
const exitCode = await claude.launch({
|
|
5669
1993
|
channel: query.channel,
|
|
5670
1994
|
userArgs
|
|
5671
1995
|
});
|
|
5672
1996
|
process.exit(exitCode);
|
|
5673
1997
|
}
|
|
5674
1998
|
if (query.profile) {
|
|
5675
|
-
const profile =
|
|
1999
|
+
const profile = profiles.get(query.profile);
|
|
5676
2000
|
if (!profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
|
|
5677
|
-
const exitCode = await
|
|
2001
|
+
const exitCode = await claude.launch({
|
|
5678
2002
|
channel: profile.channelId,
|
|
5679
2003
|
cwd: profile.path,
|
|
5680
2004
|
userArgs,
|
|
@@ -5686,24 +2010,24 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
5686
2010
|
process.exit(exitCode);
|
|
5687
2011
|
}
|
|
5688
2012
|
const cwd = process.cwd();
|
|
5689
|
-
const local =
|
|
2013
|
+
const local = localConfig.read(cwd);
|
|
5690
2014
|
if (local) {
|
|
5691
2015
|
const picked = query.channel !== void 0 ? local.channels.find((c) => c.name === query.channel) : local.channels[0];
|
|
5692
2016
|
if (!picked) throw new HTTPException(404, { message: query.channel ? `channel "${query.channel}" is not declared in funnel.json` : `funnel.json declares no channels` });
|
|
5693
|
-
const synced = await
|
|
2017
|
+
const synced = await localConfigSync.ensure(picked);
|
|
5694
2018
|
for (const outcome of synced.touched) if (outcome.changed) await funnel.listeners.restart(picked.name, outcome.name);
|
|
5695
2019
|
else await funnel.listeners.start(picked.name, outcome.name);
|
|
5696
2020
|
for (const name of synced.removed) await funnel.listeners.stop(picked.name, name);
|
|
5697
|
-
const exitCode = await
|
|
2021
|
+
const exitCode = await claude.launch({
|
|
5698
2022
|
channel: picked.name,
|
|
5699
2023
|
cwd,
|
|
5700
2024
|
userArgs
|
|
5701
2025
|
});
|
|
5702
2026
|
process.exit(exitCode);
|
|
5703
2027
|
}
|
|
5704
|
-
const defaultProfile =
|
|
2028
|
+
const defaultProfile = profiles.getDefault();
|
|
5705
2029
|
if (!defaultProfile) return c.text(claudeHelp);
|
|
5706
|
-
const exitCode = await
|
|
2030
|
+
const exitCode = await claude.launch({
|
|
5707
2031
|
channel: defaultProfile.channelId,
|
|
5708
2032
|
cwd: defaultProfile.path,
|
|
5709
2033
|
userArgs,
|
|
@@ -6196,7 +2520,7 @@ const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNul
|
|
|
6196
2520
|
errors: l.errors,
|
|
6197
2521
|
lastEventAt: l.lastEventAt
|
|
6198
2522
|
}));
|
|
6199
|
-
baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) =>
|
|
2523
|
+
baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => cl.channelName === targetChannelName).length;
|
|
6200
2524
|
}
|
|
6201
2525
|
if (store) {
|
|
6202
2526
|
const evRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [targetChannel.id, limit]);
|
|
@@ -6706,15 +3030,7 @@ examples:
|
|
|
6706
3030
|
funnel gateway run
|
|
6707
3031
|
funnel gateway run --no-caffeine`), async (c) => {
|
|
6708
3032
|
const query = c.req.valid("query");
|
|
6709
|
-
const
|
|
6710
|
-
const gatewayScript = resolveDaemonScript();
|
|
6711
|
-
const command = query["no-caffeine"] !== "true" && process.platform === "darwin" ? [
|
|
6712
|
-
"caffeinate",
|
|
6713
|
-
"-is",
|
|
6714
|
-
"bun",
|
|
6715
|
-
gatewayScript
|
|
6716
|
-
] : ["bun", gatewayScript];
|
|
6717
|
-
const exitCode = await funnel.process.attach(command);
|
|
3033
|
+
const exitCode = await c.env.funnel.runGatewayForeground({ caffeinate: query["no-caffeine"] !== "true" });
|
|
6718
3034
|
process.exit(exitCode);
|
|
6719
3035
|
});
|
|
6720
3036
|
//#endregion
|
|
@@ -6858,10 +3174,11 @@ const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object
|
|
|
6858
3174
|
const param = c.req.valid("param");
|
|
6859
3175
|
const query = c.req.valid("query");
|
|
6860
3176
|
const funnel = c.env.funnel;
|
|
3177
|
+
const { profiles, claude } = c.env;
|
|
6861
3178
|
const channel = funnel.channels.get(query.channel);
|
|
6862
3179
|
if (!channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
|
|
6863
3180
|
const recipe = parseProfileRecipe(query);
|
|
6864
|
-
|
|
3181
|
+
profiles.add({
|
|
6865
3182
|
name: param.profile,
|
|
6866
3183
|
path: query.path,
|
|
6867
3184
|
channelId: channel.id,
|
|
@@ -6877,7 +3194,9 @@ usage: funnel profiles <name> as-default
|
|
|
6877
3194
|
|
|
6878
3195
|
the first profile in the list is treated as the default for fnl claude.`), (c) => {
|
|
6879
3196
|
const param = c.req.valid("param");
|
|
6880
|
-
c.env.funnel
|
|
3197
|
+
c.env.funnel;
|
|
3198
|
+
const { profiles, claude } = c.env;
|
|
3199
|
+
profiles.asDefault(param.profile);
|
|
6881
3200
|
return c.text(`profile "${param.profile}" is now the default`);
|
|
6882
3201
|
});
|
|
6883
3202
|
//#endregion
|
|
@@ -6903,7 +3222,9 @@ const profilesRenameHandler = factory.createHandlers(zValidator$1("param", z.obj
|
|
|
6903
3222
|
newName: z.string()
|
|
6904
3223
|
})), zValidator$1("query", z.object({})), (c) => {
|
|
6905
3224
|
const param = c.req.valid("param");
|
|
6906
|
-
c.env.funnel
|
|
3225
|
+
c.env.funnel;
|
|
3226
|
+
const { profiles, claude } = c.env;
|
|
3227
|
+
profiles.rename(param.profile, param.newName);
|
|
6907
3228
|
return c.text(`renamed profile "${param.profile}" to "${param.newName}"`);
|
|
6908
3229
|
});
|
|
6909
3230
|
//#endregion
|
|
@@ -6915,10 +3236,11 @@ usage: funnel profiles <name> run [additional claude args...]
|
|
|
6915
3236
|
const RESERVED_KEYS = [];
|
|
6916
3237
|
const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({}).passthrough(), launchHelp), async (c) => {
|
|
6917
3238
|
const param = c.req.valid("param");
|
|
6918
|
-
|
|
6919
|
-
const
|
|
3239
|
+
c.env.funnel;
|
|
3240
|
+
const { profiles, claude } = c.env;
|
|
3241
|
+
const profile = profiles.get(param.profile);
|
|
6920
3242
|
if (!profile) throw new HTTPException(404, { message: `profile "${param.profile}" not found` });
|
|
6921
|
-
const exitCode = await
|
|
3243
|
+
const exitCode = await claude.launch({
|
|
6922
3244
|
channel: profile.channelId,
|
|
6923
3245
|
cwd: profile.path,
|
|
6924
3246
|
userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
|
|
@@ -6939,7 +3261,9 @@ const profilesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$1));
|
|
|
6939
3261
|
//#region lib/cli/routes/profiles.remove.$profile.ts
|
|
6940
3262
|
const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({})), (c) => {
|
|
6941
3263
|
const param = c.req.valid("param");
|
|
6942
|
-
c.env.funnel
|
|
3264
|
+
c.env.funnel;
|
|
3265
|
+
const { profiles, claude } = c.env;
|
|
3266
|
+
profiles.remove(param.profile);
|
|
6943
3267
|
return c.text(`removed profile "${param.profile}"`);
|
|
6944
3268
|
});
|
|
6945
3269
|
//#endregion
|
|
@@ -6973,10 +3297,11 @@ const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object
|
|
|
6973
3297
|
const param = c.req.valid("param");
|
|
6974
3298
|
const query = c.req.valid("query");
|
|
6975
3299
|
const funnel = c.env.funnel;
|
|
3300
|
+
const { profiles, claude } = c.env;
|
|
6976
3301
|
const channel = query.channel !== void 0 ? funnel.channels.get(query.channel) : null;
|
|
6977
3302
|
if (query.channel !== void 0 && !channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
|
|
6978
3303
|
const recipe = parseProfileRecipe(query);
|
|
6979
|
-
|
|
3304
|
+
profiles.update(param.profile, {
|
|
6980
3305
|
path: query.path,
|
|
6981
3306
|
channelId: channel?.id,
|
|
6982
3307
|
options: recipe.options,
|
|
@@ -7007,9 +3332,11 @@ examples:
|
|
|
7007
3332
|
funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
|
|
7008
3333
|
funnel profiles cto as-default
|
|
7009
3334
|
funnel profiles cto run`), (c) => {
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
const
|
|
3335
|
+
c.env.funnel;
|
|
3336
|
+
const { profiles } = c.env;
|
|
3337
|
+
const profileList = profiles.list();
|
|
3338
|
+
if (profileList.length === 0) return c.text("no profiles");
|
|
3339
|
+
const lines = profileList.map((profile, index) => {
|
|
7013
3340
|
const tag = index === 0 ? " (default)" : "";
|
|
7014
3341
|
const recipe = profile.options.length > 0 ? `, options=${profile.options.join(" ")}` : "";
|
|
7015
3342
|
const session = profile.resume ? "" : ", resume=false";
|
|
@@ -7061,9 +3388,9 @@ const isGatewayStatus = (value) => {
|
|
|
7061
3388
|
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
7062
3389
|
return true;
|
|
7063
3390
|
};
|
|
7064
|
-
const buildStatusLines = async (funnel) => {
|
|
3391
|
+
const buildStatusLines = async (funnel, profiles) => {
|
|
7065
3392
|
const channels = funnel.channels.list();
|
|
7066
|
-
const
|
|
3393
|
+
const profileList = profiles.list();
|
|
7067
3394
|
const gatewayStatus = funnel.gateway.getStatus();
|
|
7068
3395
|
const lines = [];
|
|
7069
3396
|
lines.push("= funnel status =");
|
|
@@ -7089,7 +3416,6 @@ const buildStatusLines = async (funnel) => {
|
|
|
7089
3416
|
const listenerAliveByChannel = /* @__PURE__ */ new Map();
|
|
7090
3417
|
if (gatewayData) {
|
|
7091
3418
|
for (const client of gatewayData.clients) {
|
|
7092
|
-
if (client.tapAll) continue;
|
|
7093
3419
|
const key = client.channelName ?? client.channel;
|
|
7094
3420
|
clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
|
|
7095
3421
|
}
|
|
@@ -7110,8 +3436,8 @@ const buildStatusLines = async (funnel) => {
|
|
|
7110
3436
|
lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
|
|
7111
3437
|
}
|
|
7112
3438
|
lines.push("");
|
|
7113
|
-
lines.push(`profiles: ${
|
|
7114
|
-
for (const [index, profile] of
|
|
3439
|
+
lines.push(`profiles: ${profileList.length}`);
|
|
3440
|
+
for (const [index, profile] of profileList.entries()) {
|
|
7115
3441
|
const tag = index === 0 ? " (default)" : "";
|
|
7116
3442
|
const channel = funnel.channels.getById(profile.channelId);
|
|
7117
3443
|
const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
|
|
@@ -7132,11 +3458,11 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
7132
3458
|
const isWatch = query.watch === "true" || query.watch === "";
|
|
7133
3459
|
const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
|
|
7134
3460
|
if (!isWatch) {
|
|
7135
|
-
const lines = await buildStatusLines(funnel);
|
|
3461
|
+
const lines = await buildStatusLines(funnel, c.env.profiles);
|
|
7136
3462
|
return c.text(lines.join("\n"));
|
|
7137
3463
|
}
|
|
7138
3464
|
const render = async () => {
|
|
7139
|
-
const lines = await buildStatusLines(funnel);
|
|
3465
|
+
const lines = await buildStatusLines(funnel, c.env.profiles);
|
|
7140
3466
|
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
7141
3467
|
process.stdout.write("\x1B[2J\x1B[H");
|
|
7142
3468
|
process.stdout.write(lines.join("\n"));
|
|
@@ -7178,4 +3504,4 @@ const routes = factory.createApp().onError((error, c) => {
|
|
|
7178
3504
|
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
7179
3505
|
}).get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...channelsAddHelpHandler).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...channelsRemoveHelpHandler).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...channelsRenameHelpHandler).post("/channels/:channel/rename", ...channelsChannelRenameHelpHandler).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...channelsPublishHelpHandler).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel/validate", ...channelsValidateHandler).get("/channels/validate", ...channelsValidateHelpHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...channelsConnectorsAddHelpHandler).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...channelsConnectorsRemoveHelpHandler).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...channelsConnectorsSetHelpHandler).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...channelsConnectorsRenameHelpHandler).post("/channels/:channel/connectors/:connector/rename", ...channelsConnectorRenameHelpHandler).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...channelsConnectorSchedulesAddHelpHandler).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...channelsConnectorSchedulesRemoveHelpHandler).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...profilesAddHelpHandler).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...profilesSetHelpHandler).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...profilesRemoveHelpHandler).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...profilesRenameHelpHandler).post("/profiles/:profile/rename", ...profilesProfileRenameHelpHandler).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/sql", ...gatewaySqlHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/debug", ...debugHandler).get("/debug/events", ...debugEventsHandler).get("/debug/dropped", ...debugDroppedHandler).get("/debug/errors", ...debugErrorsHandler).get("/debug/replay", ...debugReplayHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
|
|
7180
3506
|
//#endregion
|
|
7181
|
-
export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR,
|
|
3507
|
+
export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLogger, FunnelProcessRunner, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, ghConnectorSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, toRequest };
|