@interactive-inc/claude-funnel 0.35.0 → 0.37.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 +243 -319
- package/dist/bin.js +543 -508
- package/dist/gateway/daemon.js +275 -237
- package/dist/index.d.ts +1453 -292
- package/dist/index.js +1606 -205
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -17,7 +17,6 @@ import { zValidator } from "@hono/zod-validator";
|
|
|
17
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
19
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
-
import { stringify } from "yaml";
|
|
21
20
|
//#region lib/engine/id/id-generator.ts
|
|
22
21
|
/**
|
|
23
22
|
* ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
|
|
@@ -787,14 +786,15 @@ var FunnelClaude = class {
|
|
|
787
786
|
/**
|
|
788
787
|
* Mirrors claude's session storage path
|
|
789
788
|
* (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
|
|
790
|
-
* whether a recorded session still exists. Reads the same
|
|
791
|
-
* the child will run under so the check matches reality; a
|
|
792
|
-
* only ever produce a false negative (start fresh), never a
|
|
789
|
+
* whether a recorded session still exists AND is non-empty. Reads the same
|
|
790
|
+
* `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
|
|
791
|
+
* wrong guess can only ever produce a false negative (start fresh), never a
|
|
792
|
+
* bad resume.
|
|
793
793
|
*/
|
|
794
794
|
sessionFileExists(cwd, sessionId, recipeEnv) {
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
return this.fs.
|
|
795
|
+
const path = join(recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
|
|
796
|
+
if (!this.fs.existsSync(path)) return false;
|
|
797
|
+
return this.fs.readFileSync(path).trim().length > 0;
|
|
798
798
|
}
|
|
799
799
|
buildEnv(channelId, recipeEnv) {
|
|
800
800
|
const env = {};
|
|
@@ -1316,7 +1316,8 @@ var MemoryFunnelLogger = class extends FunnelLogger {
|
|
|
1316
1316
|
};
|
|
1317
1317
|
//#endregion
|
|
1318
1318
|
//#region lib/engine/mcp/mcp.ts
|
|
1319
|
-
const FUNNEL_MCP_COMMAND = "
|
|
1319
|
+
const FUNNEL_MCP_COMMAND = "bun";
|
|
1320
|
+
const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
|
|
1320
1321
|
const FUNNEL_MCP_NAME = "funnel";
|
|
1321
1322
|
const mcpEntrySchema = z.object({
|
|
1322
1323
|
command: z.string().optional(),
|
|
@@ -1341,8 +1342,8 @@ var FunnelMcp = class {
|
|
|
1341
1342
|
const servers = config.mcpServers ?? {};
|
|
1342
1343
|
const targetName = this.findServerName(servers) ?? "funnel";
|
|
1343
1344
|
servers[targetName] = {
|
|
1344
|
-
command:
|
|
1345
|
-
args:
|
|
1345
|
+
command: "bun",
|
|
1346
|
+
args: FUNNEL_MCP_ARGS
|
|
1346
1347
|
};
|
|
1347
1348
|
this.writeConfig(repoPath, {
|
|
1348
1349
|
...config,
|
|
@@ -1369,10 +1370,17 @@ var FunnelMcp = class {
|
|
|
1369
1370
|
findServerName(servers) {
|
|
1370
1371
|
for (const entry of Object.entries(servers)) {
|
|
1371
1372
|
const name = entry[0];
|
|
1372
|
-
|
|
1373
|
+
const value = entry[1];
|
|
1374
|
+
if (this.isFunnelEntry(value)) return name;
|
|
1373
1375
|
}
|
|
1374
1376
|
return null;
|
|
1375
1377
|
}
|
|
1378
|
+
isFunnelEntry(value) {
|
|
1379
|
+
if (!value) return false;
|
|
1380
|
+
if (value.command === "bun" && value.args?.[0] === "funnel") return true;
|
|
1381
|
+
if (value.command === "funnel") return true;
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1376
1384
|
readConfig(repoPath) {
|
|
1377
1385
|
const mcpPath = join(repoPath, ".mcp.json");
|
|
1378
1386
|
if (!this.fs.existsSync(mcpPath)) return {};
|
|
@@ -1758,7 +1766,7 @@ var FunnelChannelPublisher = class {
|
|
|
1758
1766
|
async publish(channelName, request) {
|
|
1759
1767
|
if (!this.isDaemonRunning()) return OFFLINE$1;
|
|
1760
1768
|
try {
|
|
1761
|
-
const url = `http://
|
|
1769
|
+
const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
|
|
1762
1770
|
const res = await fetch(url, {
|
|
1763
1771
|
method: "POST",
|
|
1764
1772
|
headers: {
|
|
@@ -2573,7 +2581,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
2573
2581
|
record(record) {
|
|
2574
2582
|
const event = {
|
|
2575
2583
|
type: record.meta?.event_type ?? "unknown",
|
|
2576
|
-
content: truncate(record.content),
|
|
2584
|
+
content: truncate$1(record.content),
|
|
2577
2585
|
channel_id: record.channelId,
|
|
2578
2586
|
connector_id: record.connectorId,
|
|
2579
2587
|
meta: record.meta
|
|
@@ -2630,7 +2638,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
2630
2638
|
this.sink.close();
|
|
2631
2639
|
}
|
|
2632
2640
|
};
|
|
2633
|
-
function truncate(content) {
|
|
2641
|
+
function truncate$1(content) {
|
|
2634
2642
|
if (content.length <= MAX_CONTENT_CHARS) return content;
|
|
2635
2643
|
return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
|
|
2636
2644
|
}
|
|
@@ -2986,6 +2994,240 @@ const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ chan
|
|
|
2986
2994
|
return c.json(response);
|
|
2987
2995
|
});
|
|
2988
2996
|
//#endregion
|
|
2997
|
+
//#region lib/gateway/connector-diagnostic-sql-reader.ts
|
|
2998
|
+
/**
|
|
2999
|
+
* Read-only SQL surface over the three diagnostic tables, for Claude to query
|
|
3000
|
+
* the log with arbitrary `SELECT`s. It opens all files read-only and exposes
|
|
3001
|
+
* three views — `raw`, `processed`, `connection` — that hide the storage
|
|
3002
|
+
* details (the physical table is `leuco_log` and each row's columns live
|
|
3003
|
+
* inside a JSON `event` blob): the views surface the columns as plain fields,
|
|
3004
|
+
* with `payload` already pulled out of the nested JSON.
|
|
3005
|
+
*
|
|
3006
|
+
* The tables are separate files. `raw` and `processed` share an `event_id`,
|
|
3007
|
+
* so a `JOIN` answers "the event arrived, but what verdict did it get?";
|
|
3008
|
+
* `connection` answers the other half — "did the listener ever connect at
|
|
3009
|
+
* all?". Writes are impossible: the connection is read-only and `query`
|
|
3010
|
+
* rejects anything but a single `SELECT`.
|
|
3011
|
+
*/
|
|
3012
|
+
var ConnectorDiagnosticSqlReader = class {
|
|
3013
|
+
db;
|
|
3014
|
+
constructor(props) {
|
|
3015
|
+
const db = new Database(props.rawPath, { readonly: true });
|
|
3016
|
+
try {
|
|
3017
|
+
db.run("PRAGMA busy_timeout = 500");
|
|
3018
|
+
db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
|
|
3019
|
+
db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
|
|
3020
|
+
db.run(rawViewSql);
|
|
3021
|
+
db.run(processedViewSql);
|
|
3022
|
+
db.run(connectionViewSql);
|
|
3023
|
+
} catch (error) {
|
|
3024
|
+
db.close();
|
|
3025
|
+
throw error;
|
|
3026
|
+
}
|
|
3027
|
+
this.db = db;
|
|
3028
|
+
Object.freeze(this);
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
|
|
3032
|
+
* than throwing) for a non-SELECT statement or a SQL error, so the caller
|
|
3033
|
+
* can surface the message without a stack trace.
|
|
3034
|
+
*/
|
|
3035
|
+
query(sql, params = []) {
|
|
3036
|
+
const trimmed = sql.trim().replace(/;$/, "").trim();
|
|
3037
|
+
if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
|
|
3038
|
+
if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
|
|
3039
|
+
try {
|
|
3040
|
+
return this.db.prepare(trimmed).all(...params);
|
|
3041
|
+
} catch (error) {
|
|
3042
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
close() {
|
|
3046
|
+
this.db.close();
|
|
3047
|
+
}
|
|
3048
|
+
};
|
|
3049
|
+
const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
|
|
3050
|
+
seq,
|
|
3051
|
+
ts,
|
|
3052
|
+
json_extract(event, '$.event_id') AS event_id,
|
|
3053
|
+
json_extract(event, '$.type') AS type,
|
|
3054
|
+
json_extract(event, '$.connector_id') AS connector_id,
|
|
3055
|
+
json_extract(event, '$.channel_id') AS channel_id,
|
|
3056
|
+
json_extract(event, '$.payload') AS payload
|
|
3057
|
+
FROM main.leuco_log`;
|
|
3058
|
+
const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
|
|
3059
|
+
seq,
|
|
3060
|
+
ts,
|
|
3061
|
+
json_extract(event, '$.event_id') AS event_id,
|
|
3062
|
+
json_extract(event, '$.type') AS type,
|
|
3063
|
+
json_extract(event, '$.connector_id') AS connector_id,
|
|
3064
|
+
json_extract(event, '$.channel_id') AS channel_id,
|
|
3065
|
+
json_extract(event, '$.outcome') AS outcome,
|
|
3066
|
+
json_extract(event, '$.payload') AS payload
|
|
3067
|
+
FROM processeddb.leuco_log`;
|
|
3068
|
+
const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
|
|
3069
|
+
seq,
|
|
3070
|
+
ts,
|
|
3071
|
+
json_extract(event, '$.type') AS type,
|
|
3072
|
+
json_extract(event, '$.connector_id') AS connector_id,
|
|
3073
|
+
json_extract(event, '$.channel_id') AS channel_id,
|
|
3074
|
+
json_extract(event, '$.status') AS status,
|
|
3075
|
+
json_extract(event, '$.detail') AS detail
|
|
3076
|
+
FROM connectiondb.leuco_log`;
|
|
3077
|
+
//#endregion
|
|
3078
|
+
//#region lib/gateway/routes/debug.ts
|
|
3079
|
+
const extractPreview$1 = (payload) => {
|
|
3080
|
+
if (typeof payload !== "string" || payload.length === 0) return null;
|
|
3081
|
+
try {
|
|
3082
|
+
const parsed = JSON.parse(payload);
|
|
3083
|
+
if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
|
|
3084
|
+
const text = String(parsed.text);
|
|
3085
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
3086
|
+
}
|
|
3087
|
+
} catch {
|
|
3088
|
+
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3089
|
+
}
|
|
3090
|
+
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3091
|
+
};
|
|
3092
|
+
const buildChannelDiagnosis = (channel) => {
|
|
3093
|
+
const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
|
|
3094
|
+
if (channel.connectors.length === 0) return {
|
|
3095
|
+
status: "warn",
|
|
3096
|
+
message: "no connectors configured on this channel",
|
|
3097
|
+
nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
|
|
3098
|
+
rootCause: null
|
|
3099
|
+
};
|
|
3100
|
+
if (!channel.listener) return {
|
|
3101
|
+
status: "error",
|
|
3102
|
+
message: "no listener running for this channel",
|
|
3103
|
+
nextActions: ["fnl gateway restart"],
|
|
3104
|
+
rootCause
|
|
3105
|
+
};
|
|
3106
|
+
if (!channel.listener.alive) return {
|
|
3107
|
+
status: "error",
|
|
3108
|
+
message: "listener is dead",
|
|
3109
|
+
nextActions: ["fnl gateway logs", "fnl gateway restart"],
|
|
3110
|
+
rootCause
|
|
3111
|
+
};
|
|
3112
|
+
if (channel.claudeClients === 0) return {
|
|
3113
|
+
status: "warn",
|
|
3114
|
+
message: "no Claude connected to this channel",
|
|
3115
|
+
nextActions: [`fnl claude --channel ${channel.name}`],
|
|
3116
|
+
rootCause: null
|
|
3117
|
+
};
|
|
3118
|
+
if (channel.listener.errors > 0) return {
|
|
3119
|
+
status: "warn",
|
|
3120
|
+
message: "listener has errors",
|
|
3121
|
+
nextActions: ["fnl gateway logs"],
|
|
3122
|
+
rootCause
|
|
3123
|
+
};
|
|
3124
|
+
return {
|
|
3125
|
+
status: "ok",
|
|
3126
|
+
message: "healthy",
|
|
3127
|
+
nextActions: [],
|
|
3128
|
+
rootCause: null
|
|
3129
|
+
};
|
|
3130
|
+
};
|
|
3131
|
+
/** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
|
|
3132
|
+
const debugHandler$1 = factory$1.createHandlers(async (c) => {
|
|
3133
|
+
const deps = c.var.deps;
|
|
3134
|
+
const channelFilter = c.req.query("channel") ?? null;
|
|
3135
|
+
const allChannels = deps.channels.list();
|
|
3136
|
+
const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
|
|
3137
|
+
const gatewayListeners = deps.supervisor.list();
|
|
3138
|
+
const gatewayClients = deps.broadcaster.listChannels();
|
|
3139
|
+
const metrics = deps.broadcaster.getMetrics();
|
|
3140
|
+
const tmpDir = funnelTmpDir();
|
|
3141
|
+
const rawPath = join(tmpDir, "connector-raw.db");
|
|
3142
|
+
const processedPath = join(tmpDir, "connector-processed.db");
|
|
3143
|
+
const connectionPath = join(tmpDir, "connector-connection.db");
|
|
3144
|
+
const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
|
|
3145
|
+
const channels = targetChannels.map((ch) => {
|
|
3146
|
+
const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
|
|
3147
|
+
const listener = listenerEntry ? {
|
|
3148
|
+
alive: listenerEntry.alive,
|
|
3149
|
+
events: listenerEntry.events,
|
|
3150
|
+
errors: listenerEntry.errors,
|
|
3151
|
+
lastEventAt: listenerEntry.lastEventAt
|
|
3152
|
+
} : null;
|
|
3153
|
+
const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
|
|
3154
|
+
const recentEvents = [];
|
|
3155
|
+
const connectionErrors = [];
|
|
3156
|
+
if (hasStore) {
|
|
3157
|
+
const reader = new ConnectorDiagnosticSqlReader({
|
|
3158
|
+
rawPath,
|
|
3159
|
+
processedPath,
|
|
3160
|
+
connectionPath
|
|
3161
|
+
});
|
|
3162
|
+
const rows = (() => {
|
|
3163
|
+
try {
|
|
3164
|
+
return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
|
|
3165
|
+
} finally {
|
|
3166
|
+
reader.close();
|
|
3167
|
+
}
|
|
3168
|
+
})();
|
|
3169
|
+
if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
|
|
3170
|
+
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
3171
|
+
let payloadParsed = null;
|
|
3172
|
+
if (rawPayload) try {
|
|
3173
|
+
const parsed = JSON.parse(rawPayload);
|
|
3174
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
3175
|
+
} catch {
|
|
3176
|
+
payloadParsed = null;
|
|
3177
|
+
}
|
|
3178
|
+
recentEvents.push({
|
|
3179
|
+
seq: typeof row.seq === "number" ? row.seq : null,
|
|
3180
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3181
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
3182
|
+
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
3183
|
+
payload: rawPayload,
|
|
3184
|
+
payloadParsed,
|
|
3185
|
+
preview: extractPreview$1(row.payload)
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
if (listener && (!listener.alive || listener.errors > 0) || !listener) {
|
|
3189
|
+
const errReader = new ConnectorDiagnosticSqlReader({
|
|
3190
|
+
rawPath,
|
|
3191
|
+
processedPath,
|
|
3192
|
+
connectionPath
|
|
3193
|
+
});
|
|
3194
|
+
const errRows = (() => {
|
|
3195
|
+
try {
|
|
3196
|
+
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]);
|
|
3197
|
+
} finally {
|
|
3198
|
+
errReader.close();
|
|
3199
|
+
}
|
|
3200
|
+
})();
|
|
3201
|
+
if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
|
|
3202
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3203
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
3204
|
+
status: typeof row.status === "string" ? row.status : "?",
|
|
3205
|
+
detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
const base = {
|
|
3210
|
+
id: ch.id,
|
|
3211
|
+
name: ch.name,
|
|
3212
|
+
connectors: ch.connectors.map((conn) => conn.name),
|
|
3213
|
+
listener,
|
|
3214
|
+
claudeClients,
|
|
3215
|
+
recentEvents,
|
|
3216
|
+
connectionErrors
|
|
3217
|
+
};
|
|
3218
|
+
return {
|
|
3219
|
+
...base,
|
|
3220
|
+
diagnosis: buildChannelDiagnosis(base)
|
|
3221
|
+
};
|
|
3222
|
+
});
|
|
3223
|
+
return c.json({
|
|
3224
|
+
pid: deps.selfPid,
|
|
3225
|
+
uptimeMs: deps.uptimeMs(),
|
|
3226
|
+
eventsBroadcast: metrics.eventsBroadcast,
|
|
3227
|
+
channels
|
|
3228
|
+
});
|
|
3229
|
+
});
|
|
3230
|
+
//#endregion
|
|
2989
3231
|
//#region lib/gateway/routes/health.ts
|
|
2990
3232
|
/** GET /health — liveness + listener registry snapshot. */
|
|
2991
3233
|
const healthHandler = factory$1.createHandlers((c) => {
|
|
@@ -3058,7 +3300,7 @@ const statusHandler$1 = factory$1.createHandlers((c) => {
|
|
|
3058
3300
|
* from the `deps` variable set by `FunnelGatewayServer`'s middleware — same
|
|
3059
3301
|
* shape as CLI's `c.var.funnel`.
|
|
3060
3302
|
*/
|
|
3061
|
-
const gatewayRoutes = factory$1.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler$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);
|
|
3303
|
+
const gatewayRoutes = 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);
|
|
3062
3304
|
//#endregion
|
|
3063
3305
|
//#region lib/gateway/gateway-server.ts
|
|
3064
3306
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -3291,6 +3533,7 @@ var FunnelGatewayServer = class {
|
|
|
3291
3533
|
if (this.token) {
|
|
3292
3534
|
base.use("/listeners/*", requireBearerToken({ expected: this.token }));
|
|
3293
3535
|
base.use("/status", requireBearerToken({ expected: this.token }));
|
|
3536
|
+
base.use("/debug", requireBearerToken({ expected: this.token }));
|
|
3294
3537
|
base.use("/channels/*", requireBearerToken({ expected: this.token }));
|
|
3295
3538
|
}
|
|
3296
3539
|
return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
|
|
@@ -3463,7 +3706,7 @@ var FunnelListenersClient = class {
|
|
|
3463
3706
|
async list() {
|
|
3464
3707
|
if (!this.isDaemonRunning()) return { state: "offline" };
|
|
3465
3708
|
try {
|
|
3466
|
-
const res = await fetch(`http://
|
|
3709
|
+
const res = await fetch(`http://127.0.0.1:${this.port}/listeners`, { headers: this.authHeaders() });
|
|
3467
3710
|
if (!res.ok) return {
|
|
3468
3711
|
state: "error",
|
|
3469
3712
|
reason: `HTTP ${res.status}`
|
|
@@ -3505,7 +3748,7 @@ var FunnelListenersClient = class {
|
|
|
3505
3748
|
}
|
|
3506
3749
|
async call(method, path) {
|
|
3507
3750
|
try {
|
|
3508
|
-
const res = await fetch(`http://
|
|
3751
|
+
const res = await fetch(`http://127.0.0.1:${this.port}${path}`, {
|
|
3509
3752
|
method,
|
|
3510
3753
|
headers: this.authHeaders()
|
|
3511
3754
|
});
|
|
@@ -3526,6 +3769,84 @@ var FunnelListenersClient = class {
|
|
|
3526
3769
|
}
|
|
3527
3770
|
};
|
|
3528
3771
|
//#endregion
|
|
3772
|
+
//#region lib/gateway/funnel-debug.ts
|
|
3773
|
+
const isGatewayStatusResponse$1 = (value) => {
|
|
3774
|
+
if (value === null || typeof value !== "object") return false;
|
|
3775
|
+
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
3776
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
3777
|
+
return true;
|
|
3778
|
+
};
|
|
3779
|
+
const buildFunnelDebugReport = async (deps, channelFilter) => {
|
|
3780
|
+
const gatewayStatus = deps.gateway.getStatus();
|
|
3781
|
+
const report = {
|
|
3782
|
+
gateway: {
|
|
3783
|
+
running: gatewayStatus.running,
|
|
3784
|
+
pid: gatewayStatus.pid,
|
|
3785
|
+
port: gatewayStatus.running ? gatewayStatus.port : null,
|
|
3786
|
+
uptimeMs: null
|
|
3787
|
+
},
|
|
3788
|
+
channels: [],
|
|
3789
|
+
recentEvents: null
|
|
3790
|
+
};
|
|
3791
|
+
const allChannels = deps.channels.list();
|
|
3792
|
+
const filteredChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter) : allChannels;
|
|
3793
|
+
let gatewayData = null;
|
|
3794
|
+
if (gatewayStatus.running) {
|
|
3795
|
+
const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
|
|
3796
|
+
if (res && res.ok) {
|
|
3797
|
+
const body = await res.json();
|
|
3798
|
+
if (isGatewayStatusResponse$1(body)) {
|
|
3799
|
+
gatewayData = body;
|
|
3800
|
+
report.gateway.uptimeMs = body.uptimeMs;
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
for (const ch of filteredChannels) {
|
|
3805
|
+
const listenerEntry = gatewayData?.listeners.find((l) => l.channelName === ch.name) ?? null;
|
|
3806
|
+
const listener = listenerEntry ? {
|
|
3807
|
+
alive: listenerEntry.alive,
|
|
3808
|
+
events: listenerEntry.events,
|
|
3809
|
+
errors: listenerEntry.errors,
|
|
3810
|
+
lastEventAt: listenerEntry.lastEventAt
|
|
3811
|
+
} : null;
|
|
3812
|
+
const claudeClients = (gatewayData?.clients ?? []).filter((cl) => !cl.tapAll && (cl.channelName === ch.name || cl.channel === ch.name));
|
|
3813
|
+
report.channels.push({
|
|
3814
|
+
name: ch.name,
|
|
3815
|
+
connectors: ch.connectors.map((conn) => conn.name),
|
|
3816
|
+
listener,
|
|
3817
|
+
claudeConnected: claudeClients.length > 0,
|
|
3818
|
+
claudeClientCount: claudeClients.length
|
|
3819
|
+
});
|
|
3820
|
+
}
|
|
3821
|
+
const rawPath = join(deps.tmpDir, "connector-raw.db");
|
|
3822
|
+
const processedPath = join(deps.tmpDir, "connector-processed.db");
|
|
3823
|
+
const connectionPath = join(deps.tmpDir, "connector-connection.db");
|
|
3824
|
+
if (existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath)) {
|
|
3825
|
+
const reader = new ConnectorDiagnosticSqlReader({
|
|
3826
|
+
rawPath,
|
|
3827
|
+
processedPath,
|
|
3828
|
+
connectionPath
|
|
3829
|
+
});
|
|
3830
|
+
const filteredChannelId = channelFilter ? allChannels.find((ch) => ch.name === channelFilter)?.id ?? null : null;
|
|
3831
|
+
const sql = filteredChannelId ? "SELECT ts, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 20" : "SELECT ts, outcome, payload FROM processed ORDER BY seq DESC LIMIT 20";
|
|
3832
|
+
const params = filteredChannelId ? [filteredChannelId] : [];
|
|
3833
|
+
const rows = reader.query(sql, params);
|
|
3834
|
+
reader.close();
|
|
3835
|
+
if (!(rows instanceof Error)) report.recentEvents = rows.map((row) => {
|
|
3836
|
+
const ts = typeof row.ts === "number" ? row.ts : 0;
|
|
3837
|
+
const outcome = typeof row.outcome === "string" ? row.outcome : "";
|
|
3838
|
+
const payload = typeof row.payload === "string" ? row.payload : null;
|
|
3839
|
+
return {
|
|
3840
|
+
ts,
|
|
3841
|
+
outcome,
|
|
3842
|
+
payload,
|
|
3843
|
+
preview: payload ? payload.slice(0, 120) : null
|
|
3844
|
+
};
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
return report;
|
|
3848
|
+
};
|
|
3849
|
+
//#endregion
|
|
3529
3850
|
//#region lib/funnel.ts
|
|
3530
3851
|
const SANDBOX_DIR = "/sandbox/.funnel";
|
|
3531
3852
|
const SANDBOX_TMP_DIR = "/sandbox/tmp";
|
|
@@ -3772,6 +4093,13 @@ var Funnel = class Funnel {
|
|
|
3772
4093
|
extraRoutes: options.extraRoutes
|
|
3773
4094
|
});
|
|
3774
4095
|
}
|
|
4096
|
+
async debug(channelName) {
|
|
4097
|
+
return buildFunnelDebugReport({
|
|
4098
|
+
gateway: this.gateway,
|
|
4099
|
+
channels: this.channels,
|
|
4100
|
+
tmpDir: this.paths.tmpDir
|
|
4101
|
+
}, channelName ?? null);
|
|
4102
|
+
}
|
|
3775
4103
|
};
|
|
3776
4104
|
//#endregion
|
|
3777
4105
|
//#region lib/engine/mcp/channel-subscriber.ts
|
|
@@ -3865,21 +4193,58 @@ const readGatewayToken = (dir) => {
|
|
|
3865
4193
|
//#endregion
|
|
3866
4194
|
//#region lib/engine/mcp/usage-hint-for-type.ts
|
|
3867
4195
|
const usageHintForType = (type) => {
|
|
3868
|
-
if (type === "slack") return
|
|
3869
|
-
|
|
3870
|
-
|
|
4196
|
+
if (type === "slack") return [
|
|
4197
|
+
"Slack Web API.",
|
|
4198
|
+
"To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
|
|
4199
|
+
"To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
|
|
4200
|
+
"Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
|
|
4201
|
+
].join(" ");
|
|
4202
|
+
if (type === "discord") return [
|
|
4203
|
+
"Discord REST API.",
|
|
4204
|
+
"To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
|
|
4205
|
+
"Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
|
|
4206
|
+
].join(" ");
|
|
4207
|
+
if (type === "gh") return [
|
|
4208
|
+
"GitHub REST via gh CLI.",
|
|
4209
|
+
"To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
|
|
4210
|
+
"Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
|
|
4211
|
+
].join(" ");
|
|
3871
4212
|
return "Generic adapter call.";
|
|
3872
4213
|
};
|
|
3873
4214
|
//#endregion
|
|
3874
4215
|
//#region lib/engine/mcp/channel-server.ts
|
|
3875
4216
|
const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
|
|
4217
|
+
const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
|
|
4218
|
+
const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
|
|
4219
|
+
const readAllChannels = (dir) => {
|
|
4220
|
+
const settingsPath = join(dir, "settings.json");
|
|
4221
|
+
if (!existsSync(settingsPath)) return [];
|
|
4222
|
+
try {
|
|
4223
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
4224
|
+
const parsed = settingsSchema.safeParse(raw);
|
|
4225
|
+
if (!parsed.success) return [];
|
|
4226
|
+
return parsed.data.channels.map((c) => ({
|
|
4227
|
+
id: c.id,
|
|
4228
|
+
name: c.name
|
|
4229
|
+
}));
|
|
4230
|
+
} catch {
|
|
4231
|
+
return [];
|
|
4232
|
+
}
|
|
4233
|
+
};
|
|
3876
4234
|
const startChannelServer = async (options = {}) => {
|
|
3877
4235
|
const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
|
|
3878
|
-
const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://
|
|
4236
|
+
const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
|
|
3879
4237
|
const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`;
|
|
3880
4238
|
const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
|
|
3881
4239
|
const channel = channelId ? readChannelConnectors(dir, channelId) : null;
|
|
3882
4240
|
const token = options.token ?? readGatewayToken(dir);
|
|
4241
|
+
const allChannels = readAllChannels(dir);
|
|
4242
|
+
const currentChannelName = channel?.channelName ?? null;
|
|
4243
|
+
const channelContext = allChannels.length > 0 ? [
|
|
4244
|
+
"",
|
|
4245
|
+
"Configured channels (use as the `channel` argument to fnl_debug):",
|
|
4246
|
+
...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
|
|
4247
|
+
].join("\n") : "";
|
|
3883
4248
|
const server = new Server({
|
|
3884
4249
|
name: FUNNEL_MCP_NAME,
|
|
3885
4250
|
version: "1.0.0"
|
|
@@ -3889,13 +4254,35 @@ const startChannelServer = async (options = {}) => {
|
|
|
3889
4254
|
tools: {}
|
|
3890
4255
|
},
|
|
3891
4256
|
instructions: [
|
|
3892
|
-
`Events arrive
|
|
4257
|
+
`Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
|
|
4258
|
+
` content — the event payload as a JSON string (parse it to read the message)`,
|
|
4259
|
+
` meta — key/value strings describing the event`,
|
|
4260
|
+
"",
|
|
4261
|
+
"meta fields by event_type:",
|
|
4262
|
+
" slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
|
|
4263
|
+
" gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
|
|
4264
|
+
" discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
|
|
4265
|
+
" schedule: event_type=schedule entry_id=…",
|
|
4266
|
+
"",
|
|
4267
|
+
"To reply to a Slack message in the same thread, call the connector tool with:",
|
|
4268
|
+
` method: POST`,
|
|
4269
|
+
` path: chat.postMessage`,
|
|
4270
|
+
` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
|
|
3893
4271
|
"",
|
|
3894
|
-
"To
|
|
4272
|
+
"To comment on a GitHub issue/PR (extract from subject_url in meta):",
|
|
4273
|
+
` method: POST`,
|
|
4274
|
+
` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
|
|
4275
|
+
` body: { body: "your reply" }`,
|
|
4276
|
+
"",
|
|
4277
|
+
"Built-in diagnostic tools — call proactively when events seem missing or delayed:",
|
|
4278
|
+
" fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
|
|
4279
|
+
" fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
|
|
4280
|
+
" omit channel arg to diagnose all channels; check summary.suggestedActions first",
|
|
4281
|
+
channelContext
|
|
3895
4282
|
].join("\n")
|
|
3896
4283
|
});
|
|
3897
4284
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3898
|
-
|
|
4285
|
+
const connectorTools = (channel?.connectors ?? []).map((c) => ({
|
|
3899
4286
|
name: c.name,
|
|
3900
4287
|
description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
|
|
3901
4288
|
inputSchema: {
|
|
@@ -3916,17 +4303,42 @@ const startChannelServer = async (options = {}) => {
|
|
|
3916
4303
|
},
|
|
3917
4304
|
required: ["method", "path"]
|
|
3918
4305
|
}
|
|
3919
|
-
}))
|
|
4306
|
+
}));
|
|
4307
|
+
const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
|
|
4308
|
+
const builtinTools = [{
|
|
4309
|
+
name: "fnl_status",
|
|
4310
|
+
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.",
|
|
4311
|
+
inputSchema: {
|
|
4312
|
+
type: "object",
|
|
4313
|
+
properties: {}
|
|
4314
|
+
}
|
|
4315
|
+
}, {
|
|
4316
|
+
name: "fnl_debug",
|
|
4317
|
+
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.",
|
|
4318
|
+
inputSchema: {
|
|
4319
|
+
type: "object",
|
|
4320
|
+
properties: { channel: channelEnum ? {
|
|
4321
|
+
type: "string",
|
|
4322
|
+
description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
|
|
4323
|
+
enum: channelEnum
|
|
4324
|
+
} : {
|
|
4325
|
+
type: "string",
|
|
4326
|
+
description: "Channel name to inspect. Omit to get all channels."
|
|
4327
|
+
} }
|
|
4328
|
+
}
|
|
4329
|
+
}];
|
|
4330
|
+
return { tools: [...connectorTools, ...builtinTools] };
|
|
3920
4331
|
});
|
|
3921
4332
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4333
|
+
const toolName = request.params.name;
|
|
4334
|
+
if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
|
|
3922
4335
|
if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
|
|
3923
|
-
const connectorName = request.params.name;
|
|
3924
4336
|
const args = request.params.arguments ?? {};
|
|
3925
4337
|
const method = typeof args.method === "string" ? args.method : "";
|
|
3926
4338
|
const path = typeof args.path === "string" ? args.path : "";
|
|
3927
4339
|
const body = args.body ?? {};
|
|
3928
4340
|
if (!method || !path) throw new Error("`method` and `path` are required");
|
|
3929
|
-
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(
|
|
4341
|
+
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
|
|
3930
4342
|
const headers = { "content-type": "application/json" };
|
|
3931
4343
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
3932
4344
|
const res = await fetch(url, {
|
|
@@ -3954,6 +4366,51 @@ const startChannelServer = async (options = {}) => {
|
|
|
3954
4366
|
protocols: token ? [`funnel.token.${token}`] : void 0
|
|
3955
4367
|
}).start();
|
|
3956
4368
|
};
|
|
4369
|
+
const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
|
|
4370
|
+
const headers = {};
|
|
4371
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
4372
|
+
if (name === "fnl_status") {
|
|
4373
|
+
const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
|
|
4374
|
+
if (!res) return { content: [{
|
|
4375
|
+
type: "text",
|
|
4376
|
+
text: JSON.stringify({
|
|
4377
|
+
running: false,
|
|
4378
|
+
error: "gateway unreachable",
|
|
4379
|
+
hint: "run: fnl gateway start",
|
|
4380
|
+
knownChannels: allChannels.map((ch) => ch.name)
|
|
4381
|
+
})
|
|
4382
|
+
}] };
|
|
4383
|
+
const body = await res.json();
|
|
4384
|
+
return { content: [{
|
|
4385
|
+
type: "text",
|
|
4386
|
+
text: JSON.stringify(body)
|
|
4387
|
+
}] };
|
|
4388
|
+
}
|
|
4389
|
+
const channelArg = typeof args?.channel === "string" ? args.channel : null;
|
|
4390
|
+
const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
|
|
4391
|
+
const res = await fetch(url, { headers }).catch(() => null);
|
|
4392
|
+
if (!res) return { content: [{
|
|
4393
|
+
type: "text",
|
|
4394
|
+
text: JSON.stringify({
|
|
4395
|
+
gateway: { running: false },
|
|
4396
|
+
channels: allChannels.map((ch) => ({
|
|
4397
|
+
id: ch.id,
|
|
4398
|
+
name: ch.name,
|
|
4399
|
+
diagnosis: {
|
|
4400
|
+
status: "error",
|
|
4401
|
+
message: "gateway is not running",
|
|
4402
|
+
nextAction: "fnl gateway start",
|
|
4403
|
+
rootCause: null
|
|
4404
|
+
}
|
|
4405
|
+
}))
|
|
4406
|
+
})
|
|
4407
|
+
}] };
|
|
4408
|
+
const body = await res.json();
|
|
4409
|
+
return { content: [{
|
|
4410
|
+
type: "text",
|
|
4411
|
+
text: JSON.stringify(body)
|
|
4412
|
+
}] };
|
|
4413
|
+
};
|
|
3957
4414
|
//#endregion
|
|
3958
4415
|
//#region lib/engine/local-config/local-config-json-schema.ts
|
|
3959
4416
|
/**
|
|
@@ -4497,87 +4954,6 @@ const takeRecent = (events, limit) => {
|
|
|
4497
4954
|
return events.slice(-limit);
|
|
4498
4955
|
};
|
|
4499
4956
|
//#endregion
|
|
4500
|
-
//#region lib/gateway/connector-diagnostic-sql-reader.ts
|
|
4501
|
-
/**
|
|
4502
|
-
* Read-only SQL surface over the three diagnostic tables, for Claude to query
|
|
4503
|
-
* the log with arbitrary `SELECT`s. It opens all files read-only and exposes
|
|
4504
|
-
* three views — `raw`, `processed`, `connection` — that hide the storage
|
|
4505
|
-
* details (the physical table is `leuco_log` and each row's columns live
|
|
4506
|
-
* inside a JSON `event` blob): the views surface the columns as plain fields,
|
|
4507
|
-
* with `payload` already pulled out of the nested JSON.
|
|
4508
|
-
*
|
|
4509
|
-
* The tables are separate files. `raw` and `processed` share an `event_id`,
|
|
4510
|
-
* so a `JOIN` answers "the event arrived, but what verdict did it get?";
|
|
4511
|
-
* `connection` answers the other half — "did the listener ever connect at
|
|
4512
|
-
* all?". Writes are impossible: the connection is read-only and `query`
|
|
4513
|
-
* rejects anything but a single `SELECT`.
|
|
4514
|
-
*/
|
|
4515
|
-
var ConnectorDiagnosticSqlReader = class {
|
|
4516
|
-
db;
|
|
4517
|
-
constructor(props) {
|
|
4518
|
-
const db = new Database(props.rawPath, { readonly: true });
|
|
4519
|
-
try {
|
|
4520
|
-
db.run("PRAGMA busy_timeout = 500");
|
|
4521
|
-
db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
|
|
4522
|
-
db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
|
|
4523
|
-
db.run(rawViewSql);
|
|
4524
|
-
db.run(processedViewSql);
|
|
4525
|
-
db.run(connectionViewSql);
|
|
4526
|
-
} catch (error) {
|
|
4527
|
-
db.close();
|
|
4528
|
-
throw error;
|
|
4529
|
-
}
|
|
4530
|
-
this.db = db;
|
|
4531
|
-
Object.freeze(this);
|
|
4532
|
-
}
|
|
4533
|
-
/**
|
|
4534
|
-
* Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
|
|
4535
|
-
* than throwing) for a non-SELECT statement or a SQL error, so the caller
|
|
4536
|
-
* can surface the message without a stack trace.
|
|
4537
|
-
*/
|
|
4538
|
-
query(sql) {
|
|
4539
|
-
const trimmed = sql.trim().replace(/;$/, "").trim();
|
|
4540
|
-
if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
|
|
4541
|
-
if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
|
|
4542
|
-
try {
|
|
4543
|
-
return this.db.prepare(trimmed).all();
|
|
4544
|
-
} catch (error) {
|
|
4545
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
4546
|
-
}
|
|
4547
|
-
}
|
|
4548
|
-
close() {
|
|
4549
|
-
this.db.close();
|
|
4550
|
-
}
|
|
4551
|
-
};
|
|
4552
|
-
const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
|
|
4553
|
-
seq,
|
|
4554
|
-
ts,
|
|
4555
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
4556
|
-
json_extract(event, '$.type') AS type,
|
|
4557
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
4558
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
4559
|
-
json_extract(event, '$.payload') AS payload
|
|
4560
|
-
FROM main.leuco_log`;
|
|
4561
|
-
const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
|
|
4562
|
-
seq,
|
|
4563
|
-
ts,
|
|
4564
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
4565
|
-
json_extract(event, '$.type') AS type,
|
|
4566
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
4567
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
4568
|
-
json_extract(event, '$.outcome') AS outcome,
|
|
4569
|
-
json_extract(event, '$.payload') AS payload
|
|
4570
|
-
FROM processeddb.leuco_log`;
|
|
4571
|
-
const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
|
|
4572
|
-
seq,
|
|
4573
|
-
ts,
|
|
4574
|
-
json_extract(event, '$.type') AS type,
|
|
4575
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
4576
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
4577
|
-
json_extract(event, '$.status') AS status,
|
|
4578
|
-
json_extract(event, '$.detail') AS detail
|
|
4579
|
-
FROM connectiondb.leuco_log`;
|
|
4580
|
-
//#endregion
|
|
4581
4957
|
//#region lib/cli/factory.ts
|
|
4582
4958
|
const factory = createFactory();
|
|
4583
4959
|
//#endregion
|
|
@@ -5042,9 +5418,16 @@ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.objec
|
|
|
5042
5418
|
];
|
|
5043
5419
|
return c.text(lines.join("\n"));
|
|
5044
5420
|
});
|
|
5045
|
-
const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
5421
|
+
const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
|
|
5422
|
+
"true",
|
|
5423
|
+
"false",
|
|
5424
|
+
""
|
|
5425
|
+
]).optional() }), `funnel channels — manage subscription boxes
|
|
5046
5426
|
|
|
5047
|
-
usage: funnel channels [
|
|
5427
|
+
usage: funnel channels [--json]
|
|
5428
|
+
|
|
5429
|
+
options:
|
|
5430
|
+
--json output as JSON array (machine-readable, useful for Claude)
|
|
5048
5431
|
|
|
5049
5432
|
subcommands:
|
|
5050
5433
|
(none) list
|
|
@@ -5055,13 +5438,26 @@ subcommands:
|
|
|
5055
5438
|
<name> connectors add <c> --type=... add a connector
|
|
5056
5439
|
|
|
5057
5440
|
examples:
|
|
5441
|
+
funnel channels
|
|
5442
|
+
funnel channels --json
|
|
5058
5443
|
funnel channels add prod-inbox
|
|
5059
5444
|
funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
|
|
5060
5445
|
funnel channels prod-inbox`), (c) => {
|
|
5446
|
+
const query = c.req.valid("query");
|
|
5061
5447
|
const channels = c.var.funnel.channels.list();
|
|
5448
|
+
if (query.json === "true" || query.json === "") return c.json(channels.map((ch) => ({
|
|
5449
|
+
id: ch.id,
|
|
5450
|
+
name: ch.name,
|
|
5451
|
+
delivery: ch.delivery,
|
|
5452
|
+
connectors: ch.connectors.map((conn) => ({
|
|
5453
|
+
id: conn.id,
|
|
5454
|
+
name: conn.name,
|
|
5455
|
+
type: conn.type
|
|
5456
|
+
}))
|
|
5457
|
+
})));
|
|
5062
5458
|
if (channels.length === 0) return c.text("no channels");
|
|
5063
5459
|
const lines = channels.map((ch) => {
|
|
5064
|
-
const names = ch.connectors.map((
|
|
5460
|
+
const names = ch.connectors.map((conn) => conn.name);
|
|
5065
5461
|
const connectors = names.length > 0 ? names.join(", ") : "(none)";
|
|
5066
5462
|
return `${ch.name} [${connectors}]`;
|
|
5067
5463
|
});
|
|
@@ -5152,12 +5548,848 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
5152
5548
|
process.exit(exitCode);
|
|
5153
5549
|
});
|
|
5154
5550
|
//#endregion
|
|
5155
|
-
//#region lib/cli/routes/
|
|
5551
|
+
//#region lib/cli/routes/debug.ts
|
|
5552
|
+
const debugHelp = `funnel debug — diagnose why Claude is not receiving events
|
|
5553
|
+
|
|
5554
|
+
usage: funnel debug [subcommand] [--channel <name>] [--all] [--json]
|
|
5555
|
+
|
|
5556
|
+
subcommands:
|
|
5557
|
+
(none) full diagnosis (gateway + listener + Claude + last 5 events)
|
|
5558
|
+
events last N processed events with outcome and preview
|
|
5559
|
+
dropped events filtered out (skip:*) with payload detail
|
|
5560
|
+
errors listener connection errors (auth-failed, error)
|
|
5561
|
+
|
|
5562
|
+
options:
|
|
5563
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5564
|
+
--all diagnose all channels at once (JSON output)
|
|
5565
|
+
--limit <N> number of recent events to include (default: 5; events/dropped/errors default: 20)
|
|
5566
|
+
--json output as JSON (machine-readable, useful for Claude)
|
|
5567
|
+
|
|
5568
|
+
when a listener is dead the diagnosis includes rootCause — the most recent
|
|
5569
|
+
connection error detail pulled from the connection log automatically.
|
|
5570
|
+
|
|
5571
|
+
use --json when asking Claude to analyse the output — it returns structured
|
|
5572
|
+
data that Claude can parse without guessing at text formatting.
|
|
5573
|
+
|
|
5574
|
+
examples:
|
|
5575
|
+
funnel debug
|
|
5576
|
+
funnel debug --all --json
|
|
5577
|
+
funnel debug --channel open-karte
|
|
5578
|
+
funnel debug --channel open-karte --json
|
|
5579
|
+
funnel debug events --channel open-karte --limit 50
|
|
5580
|
+
funnel debug dropped --channel open-karte --json
|
|
5581
|
+
funnel debug errors`;
|
|
5582
|
+
const debugEventsHelp = `funnel debug events — last N processed events with outcome and preview
|
|
5583
|
+
|
|
5584
|
+
usage: funnel debug events [--channel <name>] [--limit <N>] [--json]
|
|
5585
|
+
|
|
5586
|
+
options:
|
|
5587
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5588
|
+
--limit <N> number of rows (default: 20)
|
|
5589
|
+
--json output as JSON
|
|
5590
|
+
|
|
5591
|
+
examples:
|
|
5592
|
+
funnel debug events
|
|
5593
|
+
funnel debug events --channel open-karte --limit 50
|
|
5594
|
+
funnel debug events --json`;
|
|
5595
|
+
const debugDroppedHelp = `funnel debug dropped — events filtered out (skip:*)
|
|
5596
|
+
|
|
5597
|
+
usage: funnel debug dropped [--channel <name>] [--limit <N>] [--json]
|
|
5598
|
+
|
|
5599
|
+
options:
|
|
5600
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5601
|
+
--limit <N> number of rows (default: 20)
|
|
5602
|
+
--json output as JSON
|
|
5603
|
+
|
|
5604
|
+
shows why events were skipped: skip:type, skip:subtype, skip:dedup,
|
|
5605
|
+
skip:self-user, skip:self-bot, skip:preprocess
|
|
5606
|
+
|
|
5607
|
+
examples:
|
|
5608
|
+
funnel debug dropped
|
|
5609
|
+
funnel debug dropped --channel open-karte --json`;
|
|
5610
|
+
const debugErrorsHelp = `funnel debug errors — listener connection errors
|
|
5611
|
+
|
|
5612
|
+
usage: funnel debug errors [--channel <name>] [--limit <N>] [--json]
|
|
5613
|
+
|
|
5614
|
+
options:
|
|
5615
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5616
|
+
--limit <N> number of rows (default: 20)
|
|
5617
|
+
--json output as JSON
|
|
5618
|
+
|
|
5619
|
+
shows auth-failed and error events from the connection lifecycle log.
|
|
5620
|
+
use this when a listener never connects or keeps disconnecting.
|
|
5621
|
+
|
|
5622
|
+
examples:
|
|
5623
|
+
funnel debug errors
|
|
5624
|
+
funnel debug errors --channel open-karte`;
|
|
5625
|
+
const isGatewayStatusResponse = (value) => {
|
|
5626
|
+
if (value === null || typeof value !== "object") return false;
|
|
5627
|
+
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
5628
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
5629
|
+
return true;
|
|
5630
|
+
};
|
|
5631
|
+
const formatUptime = (ms) => {
|
|
5632
|
+
const sec = Math.floor(ms / 1e3);
|
|
5633
|
+
const min = Math.floor(sec / 60);
|
|
5634
|
+
if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`;
|
|
5635
|
+
if (sec >= 60) return `${min}m ${sec % 60}s`;
|
|
5636
|
+
return `${sec}s`;
|
|
5637
|
+
};
|
|
5638
|
+
const formatTs = (epochMs) => {
|
|
5639
|
+
if (typeof epochMs !== "number") return "?";
|
|
5640
|
+
return new Date(epochMs).toISOString().slice(11, 19);
|
|
5641
|
+
};
|
|
5642
|
+
const truncate = (text, max) => text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
5643
|
+
const extractPreview = (payload) => {
|
|
5644
|
+
if (typeof payload !== "string" || payload.length === 0) return null;
|
|
5645
|
+
try {
|
|
5646
|
+
const parsed = JSON.parse(payload);
|
|
5647
|
+
if (parsed !== null && typeof parsed === "object" && "text" in parsed) return truncate(String(parsed.text), 60);
|
|
5648
|
+
} catch {
|
|
5649
|
+
return truncate(payload, 60);
|
|
5650
|
+
}
|
|
5651
|
+
return truncate(payload, 60);
|
|
5652
|
+
};
|
|
5653
|
+
const buildDiagnosis = (report) => {
|
|
5654
|
+
const rootCause = (report.connectionErrors[report.connectionErrors.length - 1] ?? null)?.detail ?? null;
|
|
5655
|
+
if (!report.gateway.running) return {
|
|
5656
|
+
status: "error",
|
|
5657
|
+
message: "gateway is not running",
|
|
5658
|
+
nextActions: ["fnl gateway start"],
|
|
5659
|
+
rootCause: null
|
|
5660
|
+
};
|
|
5661
|
+
const channel = report.channel;
|
|
5662
|
+
if (!(report.listeners.length > 0)) return {
|
|
5663
|
+
status: "warn",
|
|
5664
|
+
message: "no connectors configured on this channel",
|
|
5665
|
+
nextActions: [`fnl channels ${channel} connectors add <name> --type=slack ...`],
|
|
5666
|
+
rootCause: null
|
|
5667
|
+
};
|
|
5668
|
+
const allDead = report.listeners.every((l) => !l.alive);
|
|
5669
|
+
const someDead = report.listeners.some((l) => !l.alive);
|
|
5670
|
+
if (allDead) return {
|
|
5671
|
+
status: "error",
|
|
5672
|
+
message: "all listeners are dead",
|
|
5673
|
+
nextActions: ["fnl gateway logs", "fnl gateway restart"],
|
|
5674
|
+
rootCause
|
|
5675
|
+
};
|
|
5676
|
+
if (someDead) return {
|
|
5677
|
+
status: "warn",
|
|
5678
|
+
message: "some listeners are dead",
|
|
5679
|
+
nextActions: ["fnl gateway logs"],
|
|
5680
|
+
rootCause
|
|
5681
|
+
};
|
|
5682
|
+
const hasErrors = report.listeners.some((l) => l.errors > 0);
|
|
5683
|
+
if (report.claudeClients === 0) return {
|
|
5684
|
+
status: "warn",
|
|
5685
|
+
message: "no Claude connected to this channel",
|
|
5686
|
+
nextActions: [`fnl claude --channel ${channel}`],
|
|
5687
|
+
rootCause: null
|
|
5688
|
+
};
|
|
5689
|
+
if (hasErrors) return {
|
|
5690
|
+
status: "warn",
|
|
5691
|
+
message: "listeners have errors",
|
|
5692
|
+
nextActions: ["fnl gateway logs"],
|
|
5693
|
+
rootCause
|
|
5694
|
+
};
|
|
5695
|
+
return {
|
|
5696
|
+
status: "ok",
|
|
5697
|
+
message: "everything looks healthy",
|
|
5698
|
+
nextActions: [],
|
|
5699
|
+
rootCause: null
|
|
5700
|
+
};
|
|
5701
|
+
};
|
|
5702
|
+
const renderText = (report) => {
|
|
5703
|
+
const lines = [];
|
|
5704
|
+
lines.push(`= funnel debug: ${report.channel} =`);
|
|
5705
|
+
lines.push("");
|
|
5706
|
+
const gw = report.gateway;
|
|
5707
|
+
if (!gw.running) lines.push("[gateway] ○ not running");
|
|
5708
|
+
else {
|
|
5709
|
+
const uptime = gw.uptimeMs !== null ? ` · up ${formatUptime(gw.uptimeMs)}` : "";
|
|
5710
|
+
lines.push(`[gateway] ● running pid ${gw.pid} · port ${gw.port}${uptime}`);
|
|
5711
|
+
}
|
|
5712
|
+
if (report.listeners.length === 0) lines.push("[listener] - no listener");
|
|
5713
|
+
else for (const listener of report.listeners) {
|
|
5714
|
+
const indicator = listener.alive ? "●" : "○";
|
|
5715
|
+
const state = listener.alive ? "alive " : "dead ";
|
|
5716
|
+
const eventsStr = `${listener.events} events`;
|
|
5717
|
+
const lastStr = listener.lastEventAt ? ` · last ${listener.lastEventAt.slice(11, 19)}` : "";
|
|
5718
|
+
const errStr = listener.errors > 0 ? ` · ⚠ ${listener.errors} errors` : "";
|
|
5719
|
+
lines.push(`[listener] ${indicator} ${state} ${eventsStr}${lastStr}${errStr}`);
|
|
5720
|
+
}
|
|
5721
|
+
const claudeCount = report.claudeClients;
|
|
5722
|
+
if (claudeCount === 0) lines.push("[claude] ○ not connected");
|
|
5723
|
+
else lines.push(`[claude] ● connected (${claudeCount} WS client${claudeCount > 1 ? "s" : ""})`);
|
|
5724
|
+
if (report.recentEvents.length === 0) lines.push("[events] no events recorded");
|
|
5725
|
+
else {
|
|
5726
|
+
lines.push(`[events] last ${report.recentEvents.length} event${report.recentEvents.length > 1 ? "s" : ""}:`);
|
|
5727
|
+
for (const event of report.recentEvents) {
|
|
5728
|
+
const time = formatTs(event.ts);
|
|
5729
|
+
const type = event.type.padEnd(8);
|
|
5730
|
+
const outcome = event.outcome.padEnd(20);
|
|
5731
|
+
const preview = event.preview ? ` "${event.preview}"` : "";
|
|
5732
|
+
const seq = event.seq !== null ? ` (seq=${event.seq})` : "";
|
|
5733
|
+
lines.push(` ${time} ${type} ${outcome}${preview}${seq}`);
|
|
5734
|
+
}
|
|
5735
|
+
}
|
|
5736
|
+
if (report.connectionErrors.length > 0) {
|
|
5737
|
+
lines.push("[conn errs] recent connection errors:");
|
|
5738
|
+
for (const err of report.connectionErrors) {
|
|
5739
|
+
const time = formatTs(err.ts);
|
|
5740
|
+
const status = err.status.padEnd(14);
|
|
5741
|
+
const detail = err.detail ? ` "${err.detail}"` : "";
|
|
5742
|
+
lines.push(` ${time} ${err.type.padEnd(8)} ${status}${detail}`);
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
5745
|
+
lines.push("");
|
|
5746
|
+
const diag = report.diagnosis;
|
|
5747
|
+
const icon = diag.status === "ok" ? "✓" : diag.status === "warn" ? "⚠" : "✗";
|
|
5748
|
+
lines.push(`diagnosis: ${icon} ${diag.message}`);
|
|
5749
|
+
if (diag.rootCause) lines.push(` root cause: ${diag.rootCause}`);
|
|
5750
|
+
for (const action of diag.nextActions) lines.push(` → ${action}`);
|
|
5751
|
+
return lines.join("\n");
|
|
5752
|
+
};
|
|
5753
|
+
const resolveStoreOrNull = () => {
|
|
5754
|
+
const tmpDir = funnelTmpDir();
|
|
5755
|
+
const rawPath = join(tmpDir, "connector-raw.db");
|
|
5756
|
+
const processedPath = join(tmpDir, "connector-processed.db");
|
|
5757
|
+
const connectionPath = join(tmpDir, "connector-connection.db");
|
|
5758
|
+
if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return null;
|
|
5759
|
+
return {
|
|
5760
|
+
rawPath,
|
|
5761
|
+
processedPath,
|
|
5762
|
+
connectionPath
|
|
5763
|
+
};
|
|
5764
|
+
};
|
|
5765
|
+
const resolveChannelId = (channels, channelName) => {
|
|
5766
|
+
if (channelName) {
|
|
5767
|
+
const match = channels.find((ch) => ch.name === channelName);
|
|
5768
|
+
if (match) return {
|
|
5769
|
+
found: true,
|
|
5770
|
+
channel: match
|
|
5771
|
+
};
|
|
5772
|
+
return {
|
|
5773
|
+
found: false,
|
|
5774
|
+
reason: "not-found",
|
|
5775
|
+
name: channelName
|
|
5776
|
+
};
|
|
5777
|
+
}
|
|
5778
|
+
if (channels.length === 1 && channels[0]) return {
|
|
5779
|
+
found: true,
|
|
5780
|
+
channel: channels[0]
|
|
5781
|
+
};
|
|
5782
|
+
if (channels.length === 0) return {
|
|
5783
|
+
found: false,
|
|
5784
|
+
reason: "none"
|
|
5785
|
+
};
|
|
5786
|
+
return {
|
|
5787
|
+
found: false,
|
|
5788
|
+
reason: "ambiguous",
|
|
5789
|
+
names: channels.map((ch) => ch.name)
|
|
5790
|
+
};
|
|
5791
|
+
};
|
|
5792
|
+
const debugEventsHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
5793
|
+
channel: z.string().optional(),
|
|
5794
|
+
limit: z.string().optional(),
|
|
5795
|
+
json: z.enum([
|
|
5796
|
+
"true",
|
|
5797
|
+
"false",
|
|
5798
|
+
""
|
|
5799
|
+
]).optional()
|
|
5800
|
+
}), debugEventsHelp), async (c) => {
|
|
5801
|
+
const query = c.req.valid("query");
|
|
5802
|
+
const channels = c.var.funnel.channels.list();
|
|
5803
|
+
const isJson = query.json === "true" || query.json === "";
|
|
5804
|
+
const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
|
|
5805
|
+
const store = resolveStoreOrNull();
|
|
5806
|
+
if (!store) {
|
|
5807
|
+
if (isJson) return c.json([]);
|
|
5808
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
5809
|
+
}
|
|
5810
|
+
const resolved = resolveChannelId(channels, query.channel);
|
|
5811
|
+
if (!resolved.found) {
|
|
5812
|
+
if (resolved.reason === "not-found") {
|
|
5813
|
+
if (isJson) return c.json({
|
|
5814
|
+
error: `channel not found: ${resolved.name}`,
|
|
5815
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
5816
|
+
});
|
|
5817
|
+
return c.text(`channel not found: ${resolved.name}`);
|
|
5818
|
+
}
|
|
5819
|
+
if (resolved.reason === "ambiguous") {
|
|
5820
|
+
if (isJson) return c.json({
|
|
5821
|
+
error: "multiple channels — specify one with --channel",
|
|
5822
|
+
channels: resolved.names
|
|
5823
|
+
});
|
|
5824
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
5825
|
+
}
|
|
5826
|
+
if (isJson) return c.json([]);
|
|
5827
|
+
return c.text("no channels configured");
|
|
5828
|
+
}
|
|
5829
|
+
const channel = resolved.channel;
|
|
5830
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
5831
|
+
const rows = (() => {
|
|
5832
|
+
try {
|
|
5833
|
+
if (channel) return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [channel.id, limit]);
|
|
5834
|
+
return reader.query("SELECT seq, ts, type, outcome, payload FROM processed ORDER BY seq DESC LIMIT ?", [limit]);
|
|
5835
|
+
} finally {
|
|
5836
|
+
reader.close();
|
|
5837
|
+
}
|
|
5838
|
+
})();
|
|
5839
|
+
if (rows instanceof Error) return c.text(`error: ${rows.message}`);
|
|
5840
|
+
const events = [...rows].reverse().map((row) => {
|
|
5841
|
+
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
5842
|
+
let payloadParsed = null;
|
|
5843
|
+
if (rawPayload) try {
|
|
5844
|
+
const parsed = JSON.parse(rawPayload);
|
|
5845
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
5846
|
+
} catch {
|
|
5847
|
+
payloadParsed = null;
|
|
5848
|
+
}
|
|
5849
|
+
return {
|
|
5850
|
+
seq: typeof row.seq === "number" ? row.seq : null,
|
|
5851
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
5852
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
5853
|
+
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
5854
|
+
payload: rawPayload,
|
|
5855
|
+
payloadParsed,
|
|
5856
|
+
preview: extractPreview(row.payload)
|
|
5857
|
+
};
|
|
5858
|
+
});
|
|
5859
|
+
if (isJson) return c.json(events);
|
|
5860
|
+
if (events.length === 0) return c.text("no events recorded");
|
|
5861
|
+
const lines = events.map((ev) => {
|
|
5862
|
+
const time = formatTs(ev.ts);
|
|
5863
|
+
const type = ev.type.padEnd(8);
|
|
5864
|
+
const outcome = ev.outcome.padEnd(20);
|
|
5865
|
+
const preview = ev.preview ? ` "${ev.preview}"` : "";
|
|
5866
|
+
return `${time} ${type} ${outcome}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${preview}`;
|
|
5867
|
+
});
|
|
5868
|
+
return c.text(lines.join("\n"));
|
|
5869
|
+
});
|
|
5870
|
+
const debugDroppedHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
5871
|
+
channel: z.string().optional(),
|
|
5872
|
+
limit: z.string().optional(),
|
|
5873
|
+
json: z.enum([
|
|
5874
|
+
"true",
|
|
5875
|
+
"false",
|
|
5876
|
+
""
|
|
5877
|
+
]).optional()
|
|
5878
|
+
}), debugDroppedHelp), async (c) => {
|
|
5879
|
+
const query = c.req.valid("query");
|
|
5880
|
+
const channels = c.var.funnel.channels.list();
|
|
5881
|
+
const isJson = query.json === "true" || query.json === "";
|
|
5882
|
+
const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
|
|
5883
|
+
const store = resolveStoreOrNull();
|
|
5884
|
+
if (!store) {
|
|
5885
|
+
if (isJson) return c.json([]);
|
|
5886
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
5887
|
+
}
|
|
5888
|
+
const resolvedDropped = resolveChannelId(channels, query.channel);
|
|
5889
|
+
if (!resolvedDropped.found) {
|
|
5890
|
+
if (resolvedDropped.reason === "not-found") {
|
|
5891
|
+
if (isJson) return c.json({
|
|
5892
|
+
error: `channel not found: ${resolvedDropped.name}`,
|
|
5893
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
5894
|
+
});
|
|
5895
|
+
return c.text(`channel not found: ${resolvedDropped.name}`);
|
|
5896
|
+
}
|
|
5897
|
+
if (resolvedDropped.reason === "ambiguous") {
|
|
5898
|
+
if (isJson) return c.json({
|
|
5899
|
+
error: "multiple channels — specify one with --channel",
|
|
5900
|
+
channels: resolvedDropped.names
|
|
5901
|
+
});
|
|
5902
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolvedDropped.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
5903
|
+
}
|
|
5904
|
+
if (isJson) return c.json([]);
|
|
5905
|
+
return c.text("no channels configured");
|
|
5906
|
+
}
|
|
5907
|
+
const channel = resolvedDropped.channel;
|
|
5908
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
5909
|
+
const rows = (() => {
|
|
5910
|
+
try {
|
|
5911
|
+
if (channel) return reader.query("SELECT p.seq, p.ts, p.type, p.outcome, p.payload, p.event_id FROM processed p WHERE p.channel_id = ? AND p.outcome LIKE 'skip:%' ORDER BY p.seq DESC LIMIT ?", [channel.id, limit]);
|
|
5912
|
+
return reader.query("SELECT seq, ts, type, outcome, payload, event_id FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT ?", [limit]);
|
|
5913
|
+
} finally {
|
|
5914
|
+
reader.close();
|
|
5915
|
+
}
|
|
5916
|
+
})();
|
|
5917
|
+
if (rows instanceof Error) return c.text(`error: ${rows.message}`);
|
|
5918
|
+
const events = [...rows].reverse().map((row) => {
|
|
5919
|
+
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
5920
|
+
let payloadParsed = null;
|
|
5921
|
+
if (rawPayload) try {
|
|
5922
|
+
const parsed = JSON.parse(rawPayload);
|
|
5923
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
5924
|
+
} catch {
|
|
5925
|
+
payloadParsed = null;
|
|
5926
|
+
}
|
|
5927
|
+
return {
|
|
5928
|
+
seq: typeof row.seq === "number" ? row.seq : null,
|
|
5929
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
5930
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
5931
|
+
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
5932
|
+
event_id: typeof row.event_id === "string" ? row.event_id : null,
|
|
5933
|
+
payload: rawPayload,
|
|
5934
|
+
payloadParsed,
|
|
5935
|
+
preview: extractPreview(row.payload)
|
|
5936
|
+
};
|
|
5937
|
+
});
|
|
5938
|
+
if (isJson) return c.json(events);
|
|
5939
|
+
if (events.length === 0) return c.text("no dropped events recorded");
|
|
5940
|
+
const lines = events.map((ev) => {
|
|
5941
|
+
return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.outcome.padEnd(20)}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${ev.event_id ? ` event_id=${ev.event_id.slice(0, 8)}` : ""}${ev.preview ? ` "${ev.preview}"` : ""}`;
|
|
5942
|
+
});
|
|
5943
|
+
return c.text(lines.join("\n"));
|
|
5944
|
+
});
|
|
5945
|
+
const debugErrorsHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
5946
|
+
channel: z.string().optional(),
|
|
5947
|
+
limit: z.string().optional(),
|
|
5948
|
+
json: z.enum([
|
|
5949
|
+
"true",
|
|
5950
|
+
"false",
|
|
5951
|
+
""
|
|
5952
|
+
]).optional()
|
|
5953
|
+
}), debugErrorsHelp), async (c) => {
|
|
5954
|
+
const query = c.req.valid("query");
|
|
5955
|
+
const channels = c.var.funnel.channels.list();
|
|
5956
|
+
const isJson = query.json === "true" || query.json === "";
|
|
5957
|
+
const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
|
|
5958
|
+
const store = resolveStoreOrNull();
|
|
5959
|
+
if (!store) {
|
|
5960
|
+
if (isJson) return c.json([]);
|
|
5961
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
5962
|
+
}
|
|
5963
|
+
const resolvedErrors = resolveChannelId(channels, query.channel);
|
|
5964
|
+
if (!resolvedErrors.found) {
|
|
5965
|
+
if (resolvedErrors.reason === "not-found") {
|
|
5966
|
+
if (isJson) return c.json({
|
|
5967
|
+
error: `channel not found: ${resolvedErrors.name}`,
|
|
5968
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
5969
|
+
});
|
|
5970
|
+
return c.text(`channel not found: ${resolvedErrors.name}`);
|
|
5971
|
+
}
|
|
5972
|
+
if (resolvedErrors.reason === "ambiguous") {
|
|
5973
|
+
if (isJson) return c.json({
|
|
5974
|
+
error: "multiple channels — specify one with --channel",
|
|
5975
|
+
channels: resolvedErrors.names
|
|
5976
|
+
});
|
|
5977
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolvedErrors.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
5978
|
+
}
|
|
5979
|
+
if (isJson) return c.json([]);
|
|
5980
|
+
return c.text("no channels configured");
|
|
5981
|
+
}
|
|
5982
|
+
const channel = resolvedErrors.channel;
|
|
5983
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
5984
|
+
const rows = (() => {
|
|
5985
|
+
try {
|
|
5986
|
+
if (channel) return reader.query("SELECT seq, ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [channel.id, limit]);
|
|
5987
|
+
return reader.query("SELECT seq, ts, type, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [limit]);
|
|
5988
|
+
} finally {
|
|
5989
|
+
reader.close();
|
|
5990
|
+
}
|
|
5991
|
+
})();
|
|
5992
|
+
if (rows instanceof Error) return c.text(`error: ${rows.message}`);
|
|
5993
|
+
const errors = [...rows].reverse().map((row) => ({
|
|
5994
|
+
seq: typeof row.seq === "number" ? row.seq : null,
|
|
5995
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
5996
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
5997
|
+
status: typeof row.status === "string" ? row.status : "?",
|
|
5998
|
+
detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
|
|
5999
|
+
}));
|
|
6000
|
+
if (isJson) return c.json(errors);
|
|
6001
|
+
if (errors.length === 0) return c.text("no connection errors recorded");
|
|
6002
|
+
const lines = errors.map((ev) => {
|
|
6003
|
+
return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.status.padEnd(16)}${ev.detail ? ` "${ev.detail}"` : ""}`;
|
|
6004
|
+
});
|
|
6005
|
+
return c.text(lines.join("\n"));
|
|
6006
|
+
});
|
|
6007
|
+
const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNull, store, limit = 5) => {
|
|
6008
|
+
const targetChannelName = targetChannel.name;
|
|
6009
|
+
const baseReport = {
|
|
6010
|
+
channel: targetChannelName,
|
|
6011
|
+
channelId: targetChannel.id,
|
|
6012
|
+
gateway: {
|
|
6013
|
+
running: gatewayStatus.running,
|
|
6014
|
+
pid: gatewayStatus.pid,
|
|
6015
|
+
port: gatewayStatus.running ? gatewayStatus.port : null,
|
|
6016
|
+
uptimeMs: gatewayBodyOrNull?.uptimeMs ?? null
|
|
6017
|
+
},
|
|
6018
|
+
listeners: [],
|
|
6019
|
+
claudeClients: 0,
|
|
6020
|
+
recentEvents: [],
|
|
6021
|
+
connectionErrors: []
|
|
6022
|
+
};
|
|
6023
|
+
if (gatewayBodyOrNull) {
|
|
6024
|
+
baseReport.listeners = gatewayBodyOrNull.listeners.filter((l) => l.channelName === targetChannelName).map((l) => ({
|
|
6025
|
+
name: l.name,
|
|
6026
|
+
type: l.type,
|
|
6027
|
+
alive: l.alive,
|
|
6028
|
+
events: l.events,
|
|
6029
|
+
errors: l.errors,
|
|
6030
|
+
lastEventAt: l.lastEventAt
|
|
6031
|
+
}));
|
|
6032
|
+
baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => !cl.tapAll && cl.channelName === targetChannelName).length;
|
|
6033
|
+
}
|
|
6034
|
+
if (store) {
|
|
6035
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
6036
|
+
const evRows = (() => {
|
|
6037
|
+
try {
|
|
6038
|
+
return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [targetChannel.id, limit]);
|
|
6039
|
+
} finally {
|
|
6040
|
+
reader.close();
|
|
6041
|
+
}
|
|
6042
|
+
})();
|
|
6043
|
+
if (!(evRows instanceof Error)) baseReport.recentEvents = evRows.reverse().map((row) => {
|
|
6044
|
+
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
6045
|
+
let payloadParsed = null;
|
|
6046
|
+
if (rawPayload) try {
|
|
6047
|
+
const parsed = JSON.parse(rawPayload);
|
|
6048
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
6049
|
+
} catch {
|
|
6050
|
+
payloadParsed = null;
|
|
6051
|
+
}
|
|
6052
|
+
return {
|
|
6053
|
+
seq: typeof row.seq === "number" ? row.seq : null,
|
|
6054
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
6055
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
6056
|
+
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
6057
|
+
payload: rawPayload,
|
|
6058
|
+
payloadParsed,
|
|
6059
|
+
preview: extractPreview(row.payload)
|
|
6060
|
+
};
|
|
6061
|
+
});
|
|
6062
|
+
const hasDeadListeners = baseReport.listeners.some((l) => !l.alive);
|
|
6063
|
+
const hasListenerErrors = baseReport.listeners.some((l) => l.errors > 0);
|
|
6064
|
+
if (hasDeadListeners || hasListenerErrors) {
|
|
6065
|
+
const errReader = new ConnectorDiagnosticSqlReader(store);
|
|
6066
|
+
const errRows = (() => {
|
|
6067
|
+
try {
|
|
6068
|
+
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", [targetChannel.id]);
|
|
6069
|
+
} finally {
|
|
6070
|
+
errReader.close();
|
|
6071
|
+
}
|
|
6072
|
+
})();
|
|
6073
|
+
if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map((row) => ({
|
|
6074
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
6075
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
6076
|
+
status: typeof row.status === "string" ? row.status : "?",
|
|
6077
|
+
detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
|
|
6078
|
+
}));
|
|
6079
|
+
}
|
|
6080
|
+
}
|
|
6081
|
+
return {
|
|
6082
|
+
...baseReport,
|
|
6083
|
+
diagnosis: buildDiagnosis(baseReport)
|
|
6084
|
+
};
|
|
6085
|
+
};
|
|
6086
|
+
const debugHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6087
|
+
channel: z.string().optional(),
|
|
6088
|
+
all: z.enum([
|
|
6089
|
+
"true",
|
|
6090
|
+
"false",
|
|
6091
|
+
""
|
|
6092
|
+
]).optional(),
|
|
6093
|
+
json: z.enum([
|
|
6094
|
+
"true",
|
|
6095
|
+
"false",
|
|
6096
|
+
""
|
|
6097
|
+
]).optional(),
|
|
6098
|
+
limit: z.string().optional()
|
|
6099
|
+
}), debugHelp), async (c) => {
|
|
6100
|
+
const query = c.req.valid("query");
|
|
6101
|
+
const funnel = c.var.funnel;
|
|
6102
|
+
const channels = funnel.channels.list();
|
|
6103
|
+
const gatewayStatus = funnel.gateway.getStatus();
|
|
6104
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6105
|
+
const isAll = query.all === "true" || query.all === "";
|
|
6106
|
+
const eventLimit = query.limit ? Math.max(1, Number(query.limit)) : 5;
|
|
6107
|
+
if (channels.length === 0) {
|
|
6108
|
+
if (isJson) return c.json({
|
|
6109
|
+
error: "no channels configured",
|
|
6110
|
+
nextAction: "fnl channels add <name>"
|
|
6111
|
+
});
|
|
6112
|
+
return c.text("no channels configured — run: fnl channels add <name>");
|
|
6113
|
+
}
|
|
6114
|
+
const token = funnel.gatewayToken.read();
|
|
6115
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
6116
|
+
let gatewayBodyOrNull = null;
|
|
6117
|
+
if (gatewayStatus.running) {
|
|
6118
|
+
const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`, { headers }).catch(() => null);
|
|
6119
|
+
if (res && res.ok) {
|
|
6120
|
+
const body = await res.json();
|
|
6121
|
+
if (isGatewayStatusResponse(body)) gatewayBodyOrNull = body;
|
|
6122
|
+
}
|
|
6123
|
+
}
|
|
6124
|
+
const store = resolveStoreOrNull();
|
|
6125
|
+
if (isAll) {
|
|
6126
|
+
const reports = await Promise.all(channels.map((ch) => buildChannelReport(ch, gatewayStatus, gatewayBodyOrNull, store, eventLimit)));
|
|
6127
|
+
const errorChannels = reports.filter((r) => r.diagnosis.status === "error").map((r) => r.channel);
|
|
6128
|
+
const warnChannels = reports.filter((r) => r.diagnosis.status === "warn").map((r) => r.channel);
|
|
6129
|
+
const okChannels = reports.filter((r) => r.diagnosis.status === "ok").map((r) => r.channel);
|
|
6130
|
+
const uniqueActions = [...new Set(reports.flatMap((r) => r.diagnosis.nextActions))];
|
|
6131
|
+
return c.json({
|
|
6132
|
+
summary: {
|
|
6133
|
+
total: reports.length,
|
|
6134
|
+
ok: okChannels.length,
|
|
6135
|
+
warn: warnChannels.length,
|
|
6136
|
+
error: errorChannels.length,
|
|
6137
|
+
criticalChannels: errorChannels,
|
|
6138
|
+
warnChannels,
|
|
6139
|
+
suggestedActions: uniqueActions
|
|
6140
|
+
},
|
|
6141
|
+
channels: reports
|
|
6142
|
+
});
|
|
6143
|
+
}
|
|
6144
|
+
let targetChannel = null;
|
|
6145
|
+
if (query.channel) {
|
|
6146
|
+
targetChannel = channels.find((ch) => ch.name === query.channel) ?? null;
|
|
6147
|
+
if (!targetChannel) {
|
|
6148
|
+
if (isJson) return c.json({
|
|
6149
|
+
error: `channel not found: ${query.channel}`,
|
|
6150
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6151
|
+
});
|
|
6152
|
+
return c.text(`channel not found: ${query.channel}`);
|
|
6153
|
+
}
|
|
6154
|
+
} else if (channels.length === 1 && channels[0]) targetChannel = channels[0];
|
|
6155
|
+
else {
|
|
6156
|
+
const names = channels.map((ch) => ch.name);
|
|
6157
|
+
if (isJson) return c.json({
|
|
6158
|
+
error: "multiple channels — specify one with --channel or use --all",
|
|
6159
|
+
channels: names,
|
|
6160
|
+
hint: "use --all for all channels at once"
|
|
6161
|
+
});
|
|
6162
|
+
return c.text(`multiple channels — specify one with --channel or use --all:\n${names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6163
|
+
}
|
|
6164
|
+
const report = await buildChannelReport(targetChannel, gatewayStatus, gatewayBodyOrNull, store, eventLimit);
|
|
6165
|
+
if (isJson) return c.json(report);
|
|
6166
|
+
return c.text(renderText(report));
|
|
6167
|
+
});
|
|
6168
|
+
const debugReplayHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6169
|
+
channel: z.string().optional(),
|
|
6170
|
+
seq: z.string().optional(),
|
|
6171
|
+
json: z.enum([
|
|
6172
|
+
"true",
|
|
6173
|
+
"false",
|
|
6174
|
+
""
|
|
6175
|
+
]).optional()
|
|
6176
|
+
}), `funnel debug replay — re-publish a past event into a channel
|
|
6177
|
+
|
|
6178
|
+
usage: funnel debug replay --channel <name> [--seq <N>] [--json]
|
|
6179
|
+
|
|
6180
|
+
options:
|
|
6181
|
+
--channel <name> channel to replay into (required when multiple channels exist)
|
|
6182
|
+
--seq <N> replay the event at this processed-table seq (default: most recent emitted)
|
|
6183
|
+
--json output result as JSON
|
|
6184
|
+
|
|
6185
|
+
Re-sends a past event from the diagnostic store through the publisher path,
|
|
6186
|
+
so subscribers receive it again. Useful to verify that Claude handles an event
|
|
6187
|
+
correctly without waiting for a real external trigger.
|
|
6188
|
+
|
|
6189
|
+
Gateway must be running. The event is injected via POST /channels/<name>/publish.
|
|
6190
|
+
|
|
6191
|
+
examples:
|
|
6192
|
+
fnl debug replay --channel open-karte
|
|
6193
|
+
fnl debug replay --channel open-karte --seq 412
|
|
6194
|
+
fnl debug replay --channel open-karte --json`), async (c) => {
|
|
6195
|
+
const query = c.req.valid("query");
|
|
6196
|
+
const funnel = c.var.funnel;
|
|
6197
|
+
const channels = funnel.channels.list();
|
|
6198
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6199
|
+
const resolved = resolveChannelId(channels, query.channel);
|
|
6200
|
+
if (!resolved.found) {
|
|
6201
|
+
if (resolved.reason === "not-found") {
|
|
6202
|
+
if (isJson) return c.json({
|
|
6203
|
+
error: `channel not found: ${resolved.name}`,
|
|
6204
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6205
|
+
});
|
|
6206
|
+
return c.text(`channel not found: ${resolved.name}`);
|
|
6207
|
+
}
|
|
6208
|
+
if (resolved.reason === "ambiguous") {
|
|
6209
|
+
if (isJson) return c.json({
|
|
6210
|
+
error: "multiple channels — specify one with --channel",
|
|
6211
|
+
channels: resolved.names
|
|
6212
|
+
});
|
|
6213
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6214
|
+
}
|
|
6215
|
+
if (isJson) return c.json({ error: "no channels configured" });
|
|
6216
|
+
return c.text("no channels configured");
|
|
6217
|
+
}
|
|
6218
|
+
const targetChannel = resolved.channel;
|
|
6219
|
+
const store = resolveStoreOrNull();
|
|
6220
|
+
if (!store) {
|
|
6221
|
+
if (isJson) return c.json({ error: "no diagnostic store yet (start the gateway first)" });
|
|
6222
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
6223
|
+
}
|
|
6224
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
6225
|
+
const rows = (() => {
|
|
6226
|
+
try {
|
|
6227
|
+
if (query.seq) return reader.query("SELECT seq, event_id, type, payload, connector_id, channel_id FROM processed WHERE channel_id = ? AND seq = ? LIMIT 1", [targetChannel.id, Number(query.seq)]);
|
|
6228
|
+
return reader.query("SELECT seq, event_id, type, payload, connector_id, channel_id FROM processed WHERE channel_id = ? AND outcome LIKE 'emitted%' ORDER BY seq DESC LIMIT 1", [targetChannel.id]);
|
|
6229
|
+
} finally {
|
|
6230
|
+
reader.close();
|
|
6231
|
+
}
|
|
6232
|
+
})();
|
|
6233
|
+
if (rows instanceof Error) {
|
|
6234
|
+
if (isJson) return c.json({ error: rows.message });
|
|
6235
|
+
return c.text(`error: ${rows.message}`);
|
|
6236
|
+
}
|
|
6237
|
+
const firstRow = rows[0];
|
|
6238
|
+
if (!firstRow) {
|
|
6239
|
+
if (isJson) return c.json({ error: "no matching event found" });
|
|
6240
|
+
return c.text("no matching event found");
|
|
6241
|
+
}
|
|
6242
|
+
const seq = typeof firstRow.seq === "number" ? firstRow.seq : null;
|
|
6243
|
+
const eventId = typeof firstRow.event_id === "string" ? firstRow.event_id : null;
|
|
6244
|
+
const connectorId = typeof firstRow.connector_id === "string" ? firstRow.connector_id : null;
|
|
6245
|
+
let content = typeof firstRow.payload === "string" ? firstRow.payload : null;
|
|
6246
|
+
if ((!content || content.length === 0) && eventId) {
|
|
6247
|
+
const rawReader = new ConnectorDiagnosticSqlReader(store);
|
|
6248
|
+
const rawRows = (() => {
|
|
6249
|
+
try {
|
|
6250
|
+
return rawReader.query("SELECT payload FROM raw WHERE event_id = ? LIMIT 1", [eventId]);
|
|
6251
|
+
} finally {
|
|
6252
|
+
rawReader.close();
|
|
6253
|
+
}
|
|
6254
|
+
})();
|
|
6255
|
+
if (!(rawRows instanceof Error) && rawRows[0]) {
|
|
6256
|
+
const rawRow = rawRows[0];
|
|
6257
|
+
content = typeof rawRow.payload === "string" ? rawRow.payload : null;
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
if (!content) {
|
|
6261
|
+
if (isJson) return c.json({ error: "event has no payload to replay" });
|
|
6262
|
+
return c.text("event has no payload to replay");
|
|
6263
|
+
}
|
|
6264
|
+
const connectorName = connectorId ? targetChannel.connectors?.find((c) => c.id === connectorId)?.name : void 0;
|
|
6265
|
+
const result = await funnel.publisher.publish(targetChannel.name, {
|
|
6266
|
+
content,
|
|
6267
|
+
connector: connectorName
|
|
6268
|
+
});
|
|
6269
|
+
if (result.state === "offline") {
|
|
6270
|
+
if (isJson) return c.json({
|
|
6271
|
+
error: "gateway daemon is not running",
|
|
6272
|
+
nextAction: "fnl gateway start"
|
|
6273
|
+
});
|
|
6274
|
+
return c.text("error: gateway daemon is not running — run: fnl gateway start");
|
|
6275
|
+
}
|
|
6276
|
+
if (result.state === "error") {
|
|
6277
|
+
if (isJson) return c.json({ error: result.reason });
|
|
6278
|
+
return c.text(`error: ${result.reason}`);
|
|
6279
|
+
}
|
|
6280
|
+
const preview = extractPreview(content);
|
|
6281
|
+
if (isJson) return c.json({
|
|
6282
|
+
replayed: true,
|
|
6283
|
+
seq,
|
|
6284
|
+
offset: result.offset,
|
|
6285
|
+
preview
|
|
6286
|
+
});
|
|
6287
|
+
return c.text(`replayed seq=${seq ?? "?"} → offset=${result.offset}${preview ? ` "${preview}"` : ""}`);
|
|
6288
|
+
});
|
|
6289
|
+
//#endregion
|
|
6290
|
+
//#region lib/cli/routes/channels.$channel.validate.ts
|
|
6291
|
+
const validateHelp = `funnel channels <channel> validate — check connector configuration
|
|
6292
|
+
|
|
6293
|
+
usage: funnel channels <channel> validate [--json]
|
|
6294
|
+
|
|
6295
|
+
options:
|
|
6296
|
+
--json output as JSON
|
|
6297
|
+
|
|
6298
|
+
Checks that each connector has the required tokens and fields set.
|
|
6299
|
+
Does not make any network calls — static config check only.
|
|
6300
|
+
|
|
6301
|
+
examples:
|
|
6302
|
+
funnel channels open-karte validate
|
|
6303
|
+
funnel channels open-karte validate --json`;
|
|
6304
|
+
const validateConnector = (connector) => {
|
|
6305
|
+
const issues = [];
|
|
6306
|
+
if (connector.type === "slack") {
|
|
6307
|
+
if (!(connector.botToken || connector.botTokenEnv)) issues.push({
|
|
6308
|
+
connector: connector.name,
|
|
6309
|
+
field: "botToken",
|
|
6310
|
+
message: "missing botToken (xoxb-...) or botTokenEnv"
|
|
6311
|
+
});
|
|
6312
|
+
if (!(connector.appToken || connector.appTokenEnv)) issues.push({
|
|
6313
|
+
connector: connector.name,
|
|
6314
|
+
field: "appToken",
|
|
6315
|
+
message: "missing appToken (xapp-...) or appTokenEnv"
|
|
6316
|
+
});
|
|
6317
|
+
if (connector.botToken && typeof connector.botToken === "string" && !connector.botToken.startsWith("xoxb-")) issues.push({
|
|
6318
|
+
connector: connector.name,
|
|
6319
|
+
field: "botToken",
|
|
6320
|
+
message: `botToken must start with xoxb- (got: ${connector.botToken.slice(0, 8)}...)`
|
|
6321
|
+
});
|
|
6322
|
+
if (connector.appToken && typeof connector.appToken === "string" && !connector.appToken.startsWith("xapp-")) issues.push({
|
|
6323
|
+
connector: connector.name,
|
|
6324
|
+
field: "appToken",
|
|
6325
|
+
message: `appToken must start with xapp- (got: ${connector.appToken.slice(0, 8)}...)`
|
|
6326
|
+
});
|
|
6327
|
+
}
|
|
6328
|
+
if (connector.type === "gh") {
|
|
6329
|
+
if (!(connector.token || connector.tokenEnv)) issues.push({
|
|
6330
|
+
connector: connector.name,
|
|
6331
|
+
field: "token",
|
|
6332
|
+
message: "missing token or tokenEnv for GitHub connector"
|
|
6333
|
+
});
|
|
6334
|
+
if (!connector.repo) issues.push({
|
|
6335
|
+
connector: connector.name,
|
|
6336
|
+
field: "repo",
|
|
6337
|
+
message: "missing repo (expected owner/repo format)"
|
|
6338
|
+
});
|
|
6339
|
+
}
|
|
6340
|
+
if (connector.type === "discord") {
|
|
6341
|
+
if (!(connector.botToken || connector.botTokenEnv)) issues.push({
|
|
6342
|
+
connector: connector.name,
|
|
6343
|
+
field: "botToken",
|
|
6344
|
+
message: "missing botToken or botTokenEnv for Discord connector"
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
return issues;
|
|
6348
|
+
};
|
|
6349
|
+
const channelsValidateHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ json: z.enum([
|
|
6350
|
+
"true",
|
|
6351
|
+
"false",
|
|
6352
|
+
""
|
|
6353
|
+
]).optional() }), validateHelp), (c) => {
|
|
6354
|
+
const param = c.req.valid("param");
|
|
6355
|
+
const query = c.req.valid("query");
|
|
6356
|
+
const funnel = c.var.funnel;
|
|
6357
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6358
|
+
const channel = funnel.channels.get(param.channel);
|
|
6359
|
+
if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
|
|
6360
|
+
if (channel.connectors.length === 0) {
|
|
6361
|
+
if (isJson) return c.json({
|
|
6362
|
+
channel: channel.name,
|
|
6363
|
+
valid: false,
|
|
6364
|
+
issues: [{
|
|
6365
|
+
connector: "(none)",
|
|
6366
|
+
field: "connectors",
|
|
6367
|
+
message: "no connectors configured"
|
|
6368
|
+
}]
|
|
6369
|
+
});
|
|
6370
|
+
return c.text(`⚠ ${channel.name}: no connectors configured`);
|
|
6371
|
+
}
|
|
6372
|
+
const allIssues = [];
|
|
6373
|
+
for (const connector of channel.connectors) {
|
|
6374
|
+
const issues = validateConnector(connector);
|
|
6375
|
+
allIssues.push(...issues);
|
|
6376
|
+
}
|
|
6377
|
+
if (isJson) return c.json({
|
|
6378
|
+
channel: channel.name,
|
|
6379
|
+
valid: allIssues.length === 0,
|
|
6380
|
+
issues: allIssues
|
|
6381
|
+
});
|
|
6382
|
+
if (allIssues.length === 0) return c.text(`✓ ${channel.name}: all connectors valid`);
|
|
6383
|
+
const lines = allIssues.map((issue) => `✗ ${channel.name}/${issue.connector}: ${issue.message}`);
|
|
6384
|
+
return c.text(lines.join("\n"));
|
|
6385
|
+
});
|
|
6386
|
+
//#endregion
|
|
6387
|
+
//#region lib/cli/routes/gateway.ts
|
|
5156
6388
|
const groupHelp$1 = `funnel gateway — manage the funnel daemon
|
|
5157
6389
|
|
|
5158
|
-
The gateway daemon hosts the WebSocket /ws (used by Claude MCP)
|
|
5159
|
-
|
|
5160
|
-
|
|
6390
|
+
The gateway daemon hosts the WebSocket /ws (used by Claude MCP) and the
|
|
6391
|
+
listener supervisor that runs every connector. One daemon, one port (9743
|
|
6392
|
+
for the CLI, 9742 for programmatic use), one PID file.
|
|
5161
6393
|
|
|
5162
6394
|
usage: funnel gateway [subcommand]
|
|
5163
6395
|
|
|
@@ -5168,20 +6400,49 @@ subcommands:
|
|
|
5168
6400
|
restart stop then start
|
|
5169
6401
|
run start in foreground (for developers)
|
|
5170
6402
|
logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
|
|
5171
|
-
sql
|
|
6403
|
+
sql query inbound connector traffic (raw + processed verdict)
|
|
5172
6404
|
listeners list running connector listeners (alive / dead)
|
|
5173
6405
|
|
|
5174
6406
|
examples:
|
|
5175
|
-
funnel gateway
|
|
5176
|
-
funnel gateway restart
|
|
6407
|
+
funnel gateway check status
|
|
6408
|
+
funnel gateway restart restart after config changes
|
|
6409
|
+
funnel gateway logs stream the daemon log
|
|
6410
|
+
funnel gateway sql --preset recent inspect last 20 inbound events
|
|
6411
|
+
|
|
6412
|
+
see also: fnl debug --channel <name> (higher-level diagnosis with next-action hints)`;
|
|
5177
6413
|
const renderGatewayStatus = async (c) => {
|
|
5178
6414
|
const status = c.var.funnel.gateway.getStatus();
|
|
5179
6415
|
if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
|
|
5180
|
-
const res = await fetch(`http://
|
|
6416
|
+
const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
|
|
5181
6417
|
if (!res) return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`);
|
|
5182
|
-
const
|
|
5183
|
-
const
|
|
5184
|
-
|
|
6418
|
+
const data = await res.json();
|
|
6419
|
+
const lines = [];
|
|
6420
|
+
lines.push(`funnel gateway: running (pid ${data.pid})`);
|
|
6421
|
+
lines.push(` port: ${status.port}`);
|
|
6422
|
+
const uptimeSec = Math.floor(data.uptimeMs / 1e3);
|
|
6423
|
+
const uptimeMin = Math.floor(uptimeSec / 60);
|
|
6424
|
+
const uptimeStr = uptimeMin >= 60 ? `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? `${uptimeMin}m ${uptimeSec % 60}s` : `${uptimeSec}s`;
|
|
6425
|
+
lines.push(` uptime: ${uptimeStr}`);
|
|
6426
|
+
lines.push(` events: ${data.broadcaster.eventsBroadcast} broadcast`);
|
|
6427
|
+
if (data.listeners.length === 0) lines.push(` listeners: none`);
|
|
6428
|
+
else {
|
|
6429
|
+
lines.push(` listeners:`);
|
|
6430
|
+
for (const l of data.listeners) {
|
|
6431
|
+
const indicator = l.alive ? "●" : "○";
|
|
6432
|
+
const eventsStr = l.events > 0 ? ` (${l.events} events)` : "";
|
|
6433
|
+
const errStr = l.errors > 0 ? ` ⚠ ${l.errors} errors` : "";
|
|
6434
|
+
lines.push(` ${indicator} ${l.channelName}/${l.name} [${l.type}]${eventsStr}${errStr}`);
|
|
6435
|
+
}
|
|
6436
|
+
}
|
|
6437
|
+
if (data.clients.length === 0) lines.push(` clients: none`);
|
|
6438
|
+
else {
|
|
6439
|
+
lines.push(` clients: ${data.clients.length}`);
|
|
6440
|
+
for (const cl of data.clients) {
|
|
6441
|
+
const connectors = cl.connectors.length > 0 ? ` → ${cl.connectors.join(", ")}` : "";
|
|
6442
|
+
lines.push(` · ${cl.channel}${connectors}`);
|
|
6443
|
+
}
|
|
6444
|
+
}
|
|
6445
|
+
return c.text(lines.join("\n"));
|
|
5185
6446
|
};
|
|
5186
6447
|
const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
|
|
5187
6448
|
const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners — show running connector listeners
|
|
@@ -5203,24 +6464,31 @@ examples:
|
|
|
5203
6464
|
});
|
|
5204
6465
|
//#endregion
|
|
5205
6466
|
//#region lib/cli/routes/gateway.logs.ts
|
|
5206
|
-
const logsHelp = `funnel gateway logs — tail diagnostic
|
|
6467
|
+
const logsHelp = `funnel gateway logs — tail the daemon diagnostic log
|
|
5207
6468
|
|
|
5208
|
-
usage: funnel gateway logs [-n <N>]
|
|
6469
|
+
usage: funnel gateway logs [-n <N>] [--format <plain|json>]
|
|
5209
6470
|
|
|
5210
6471
|
options:
|
|
5211
6472
|
-n <N> number of trailing lines to show (default: 20)
|
|
6473
|
+
--format <plain|json> output format (default: plain)
|
|
6474
|
+
|
|
6475
|
+
Streams ${join(funnelTmpDir(), "funnel.log")} — the daemon's diagnostic stream covering
|
|
6476
|
+
gateway lifecycle, listener start/stop/error, and WebSocket connect/disconnect.
|
|
6477
|
+
Exit with Ctrl-C.
|
|
5212
6478
|
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
Output is formatted as YAML.
|
|
6479
|
+
plain format: HH:MM:SS LEVEL message key=value ...
|
|
6480
|
+
json format: raw JSON lines (pipe to jq for filtering)
|
|
5216
6481
|
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
6482
|
+
This log does NOT contain inbound Slack/connector events. For those, use:
|
|
6483
|
+
fnl gateway sql --preset recent last 20 processed events
|
|
6484
|
+
fnl debug --channel <name> per-channel diagnosis with outcome summary
|
|
5220
6485
|
|
|
5221
6486
|
examples:
|
|
5222
6487
|
funnel gateway logs
|
|
5223
|
-
funnel gateway logs -n 100
|
|
6488
|
+
funnel gateway logs -n 100
|
|
6489
|
+
funnel gateway logs --format json | jq 'select(.level == "error")'
|
|
6490
|
+
|
|
6491
|
+
see also: fnl debug, fnl gateway sql`;
|
|
5224
6492
|
const logger = new NodeFunnelLogger();
|
|
5225
6493
|
const tryParseJson = (line) => {
|
|
5226
6494
|
try {
|
|
@@ -5236,11 +6504,27 @@ const isLogEntry = (value) => {
|
|
|
5236
6504
|
if (!("message" in value) || typeof value.message !== "string") return false;
|
|
5237
6505
|
return true;
|
|
5238
6506
|
};
|
|
5239
|
-
const
|
|
6507
|
+
const formatMetaValue = (value) => {
|
|
6508
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
6509
|
+
return str.includes(" ") ? `"${str}"` : str;
|
|
6510
|
+
};
|
|
6511
|
+
const formatMeta = (meta) => {
|
|
6512
|
+
if (meta === null || typeof meta !== "object") return "";
|
|
6513
|
+
const pairs = Object.entries(meta).map(([k, v]) => `${k}=${formatMetaValue(v)}`).join(" ");
|
|
6514
|
+
return pairs ? ` ${pairs}` : "";
|
|
6515
|
+
};
|
|
6516
|
+
const formatPlain = (entry) => {
|
|
6517
|
+
return `${entry.time.slice(11, 19)} ${entry.level.toUpperCase().padEnd(5)} ${entry.message.padEnd(30)}${formatMeta(entry.meta)}\n`;
|
|
6518
|
+
};
|
|
6519
|
+
const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6520
|
+
n: z.string().optional(),
|
|
6521
|
+
format: z.enum(["plain", "json"]).optional()
|
|
6522
|
+
}), logsHelp), async (c) => {
|
|
5240
6523
|
const query = c.req.valid("query");
|
|
5241
6524
|
const path = logger.file;
|
|
5242
6525
|
if (!path || !existsSync(path)) return c.text("no logs");
|
|
5243
6526
|
const lineCount = query.n ? Number(query.n) : 20;
|
|
6527
|
+
const format = query.format ?? "plain";
|
|
5244
6528
|
const tail = Bun.spawn([
|
|
5245
6529
|
"tail",
|
|
5246
6530
|
"-f",
|
|
@@ -5259,7 +6543,6 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5259
6543
|
const reader = tail.stdout.getReader();
|
|
5260
6544
|
const decoder = new TextDecoder();
|
|
5261
6545
|
let buffer = "";
|
|
5262
|
-
logger.info("gateway.logs tail start", { file: path });
|
|
5263
6546
|
while (true) {
|
|
5264
6547
|
const result = await reader.read();
|
|
5265
6548
|
if (result.done) break;
|
|
@@ -5273,13 +6556,8 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5273
6556
|
process.stdout.write(`${line}\n`);
|
|
5274
6557
|
continue;
|
|
5275
6558
|
}
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
level: parsed.level,
|
|
5279
|
-
message: parsed.message,
|
|
5280
|
-
...parsed.meta ? { meta: parsed.meta } : {}
|
|
5281
|
-
};
|
|
5282
|
-
process.stdout.write(`---\n${stringify(output)}`);
|
|
6559
|
+
if (format === "json") process.stdout.write(`${JSON.stringify(parsed)}\n`);
|
|
6560
|
+
else process.stdout.write(formatPlain(parsed));
|
|
5283
6561
|
}
|
|
5284
6562
|
}
|
|
5285
6563
|
await tail.exited;
|
|
@@ -5287,47 +6565,78 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5287
6565
|
});
|
|
5288
6566
|
//#endregion
|
|
5289
6567
|
//#region lib/cli/routes/gateway.sql.ts
|
|
6568
|
+
const PRESETS = {
|
|
6569
|
+
recent: "SELECT seq, ts, type, outcome FROM processed ORDER BY seq DESC LIMIT 20",
|
|
6570
|
+
skipped: "SELECT seq, ts, type, outcome, payload FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT 20",
|
|
6571
|
+
errors: "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 20",
|
|
6572
|
+
summary: "SELECT outcome, COUNT(*) AS count FROM processed GROUP BY outcome ORDER BY count DESC",
|
|
6573
|
+
"trace-dedup": "SELECT r.seq, r.ts, r.event_id, r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome='skip:dedup' ORDER BY r.seq DESC LIMIT 20"
|
|
6574
|
+
};
|
|
5290
6575
|
const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
|
|
5291
6576
|
|
|
5292
|
-
usage: funnel gateway sql --
|
|
6577
|
+
usage: funnel gateway sql --preset <name> [--channel <name|id>] [--limit <N>]
|
|
6578
|
+
funnel gateway sql --query "<SELECT ...>"
|
|
6579
|
+
|
|
6580
|
+
options:
|
|
6581
|
+
--preset <name> run a named preset (quickest starting point)
|
|
6582
|
+
--channel <name|id> filter preset results by channel name or id
|
|
6583
|
+
--limit <N> override the row limit for presets (default: 20)
|
|
6584
|
+
--query "<SQL>" run a custom SELECT; output is JSON
|
|
6585
|
+
|
|
6586
|
+
quick-start presets (--preset <name>):
|
|
6587
|
+
recent last N processed events — type, outcome, preview
|
|
6588
|
+
skipped last N events filtered out (skip:*) — see why events were dropped
|
|
6589
|
+
errors listener auth-failed or error events — start here for connection failures
|
|
6590
|
+
summary outcome counts grouped by type — high-level health snapshot (no limit)
|
|
6591
|
+
trace-dedup raw payload of events dropped as duplicates
|
|
5293
6592
|
|
|
5294
|
-
|
|
5295
|
-
connector events and prints the rows as JSON. Use it to answer "Slack
|
|
5296
|
-
delivered an event, so why was there no notification?".
|
|
6593
|
+
Output is always JSON — pipe to jq or pass directly to Claude.
|
|
5297
6594
|
|
|
5298
|
-
|
|
5299
|
-
raw every inbound event
|
|
5300
|
-
processed
|
|
5301
|
-
connection
|
|
6595
|
+
three SQL views (for --query):
|
|
6596
|
+
raw every inbound event before filtering (payload = original JSON)
|
|
6597
|
+
processed filter verdict per event (outcome: emitted | skip:<reason>)
|
|
6598
|
+
connection listener lifecycle (status: connected | auth-failed | error | ...)
|
|
5302
6599
|
|
|
5303
|
-
|
|
5304
|
-
seq row id within the view (not comparable across views)
|
|
5305
|
-
ts epoch milliseconds
|
|
5306
|
-
type connector kind: slack | discord | gh | schedule
|
|
5307
|
-
connector_id funnel connector id
|
|
5308
|
-
channel_id funnel channel id
|
|
5309
|
-
raw and processed also have:
|
|
5310
|
-
event_id correlation id shared by an event's raw and processed rows
|
|
5311
|
-
payload raw: the original event JSON (text); processed: the delivered body, or "" when skipped
|
|
5312
|
-
processed also has:
|
|
5313
|
-
outcome 'emitted' | 'emitted:delivery-failed' | 'skip:<reason>'
|
|
5314
|
-
(skip reasons: skip:type, skip:subtype, skip:dedup,
|
|
5315
|
-
skip:self-user, skip:self-bot, skip:preprocess)
|
|
5316
|
-
connection also has:
|
|
5317
|
-
status 'started' | 'connected' | 'disconnected' | 'auth-failed' | 'stopped' | 'error'
|
|
5318
|
-
detail an error message or reason, or "" when none
|
|
6600
|
+
common join: SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome = 'skip:dedup'
|
|
5319
6601
|
|
|
5320
|
-
|
|
5321
|
-
event tables are empty, query connection — a listener that never connected, or
|
|
5322
|
-
failed auth, explains why nothing arrived.
|
|
6602
|
+
skip reasons: skip:type skip:subtype skip:dedup skip:self-user skip:self-bot skip:preprocess
|
|
5323
6603
|
|
|
5324
6604
|
examples:
|
|
5325
|
-
funnel gateway sql --
|
|
6605
|
+
funnel gateway sql --preset recent
|
|
6606
|
+
funnel gateway sql --preset recent --limit 100
|
|
6607
|
+
funnel gateway sql --preset skipped --channel open-karte
|
|
6608
|
+
funnel gateway sql --preset errors
|
|
6609
|
+
funnel gateway sql --preset summary
|
|
5326
6610
|
funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
6611
|
+
|
|
6612
|
+
tip: for a higher-level view without writing SQL, use: fnl debug --channel <name> --json
|
|
6613
|
+
|
|
6614
|
+
see also: fnl debug, fnl gateway logs`;
|
|
6615
|
+
const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6616
|
+
query: z.string().optional(),
|
|
6617
|
+
preset: z.enum(Object.keys(PRESETS)).optional(),
|
|
6618
|
+
channel: z.string().optional(),
|
|
6619
|
+
limit: z.string().optional()
|
|
6620
|
+
}), sqlHelp), async (c) => {
|
|
6621
|
+
const query = c.req.valid("query");
|
|
6622
|
+
const funnel = c.var.funnel;
|
|
6623
|
+
let sql = null;
|
|
6624
|
+
let params = [];
|
|
6625
|
+
let resolvedChannelId = null;
|
|
6626
|
+
if (query.channel) resolvedChannelId = funnel.channels.list().find((ch) => ch.id === query.channel || ch.name === query.channel)?.id ?? query.channel;
|
|
6627
|
+
if (query.preset) {
|
|
6628
|
+
const base = PRESETS[query.preset] ?? null;
|
|
6629
|
+
if (!base) return c.text(sqlHelp);
|
|
6630
|
+
let applied = base;
|
|
6631
|
+
if (query.limit) {
|
|
6632
|
+
const n = Math.max(1, Number(query.limit));
|
|
6633
|
+
applied = applied.replace(/LIMIT \d+/, `LIMIT ${n}`);
|
|
6634
|
+
}
|
|
6635
|
+
if (resolvedChannelId) {
|
|
6636
|
+
sql = applied.replace(/FROM (raw|processed|connection)\b/, "FROM $1 WHERE channel_id = ?");
|
|
6637
|
+
params = [resolvedChannelId];
|
|
6638
|
+
} else sql = applied;
|
|
6639
|
+
} else if (query.query) sql = query.query;
|
|
5331
6640
|
if (!sql) return c.text(sqlHelp);
|
|
5332
6641
|
const tmpDir = funnelTmpDir();
|
|
5333
6642
|
const rawPath = join(tmpDir, "connector-raw.db");
|
|
@@ -5341,7 +6650,7 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
|
|
|
5341
6650
|
});
|
|
5342
6651
|
const rows = (() => {
|
|
5343
6652
|
try {
|
|
5344
|
-
return reader.query(sql);
|
|
6653
|
+
return reader.query(sql, params);
|
|
5345
6654
|
} finally {
|
|
5346
6655
|
reader.close();
|
|
5347
6656
|
}
|
|
@@ -5419,15 +6728,41 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
|
|
|
5419
6728
|
if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
|
|
5420
6729
|
return c.text("funnel gateway: started");
|
|
5421
6730
|
});
|
|
5422
|
-
const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6731
|
+
const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
|
|
6732
|
+
"true",
|
|
6733
|
+
"false",
|
|
6734
|
+
""
|
|
6735
|
+
]).optional() }), `funnel gateway status — show gateway running status
|
|
5423
6736
|
|
|
5424
|
-
usage: funnel gateway status
|
|
6737
|
+
usage: funnel gateway status [--json]
|
|
6738
|
+
|
|
6739
|
+
options:
|
|
6740
|
+
--json output as JSON
|
|
5425
6741
|
|
|
5426
|
-
When running, prints PID, port,
|
|
6742
|
+
When running, prints PID, port, uptime, listeners (alive/dead), and WS clients.
|
|
6743
|
+
When not running, exits with 503.
|
|
5427
6744
|
|
|
5428
6745
|
examples:
|
|
5429
6746
|
funnel gateway status
|
|
5430
|
-
funnel gateway`),
|
|
6747
|
+
funnel gateway status --json`), async (c) => {
|
|
6748
|
+
const query = c.req.valid("query");
|
|
6749
|
+
if (!(query.json === "true" || query.json === "")) return renderGatewayStatus(c);
|
|
6750
|
+
const status = c.var.funnel.gateway.getStatus();
|
|
6751
|
+
if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
|
|
6752
|
+
const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
|
|
6753
|
+
if (!res) return c.json({
|
|
6754
|
+
running: true,
|
|
6755
|
+
pid: status.pid,
|
|
6756
|
+
port: status.port,
|
|
6757
|
+
error: "health check failed"
|
|
6758
|
+
});
|
|
6759
|
+
const data = await res.json();
|
|
6760
|
+
return c.json({
|
|
6761
|
+
running: true,
|
|
6762
|
+
port: status.port,
|
|
6763
|
+
...data
|
|
6764
|
+
});
|
|
6765
|
+
});
|
|
5431
6766
|
const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
|
|
5432
6767
|
|
|
5433
6768
|
usage: funnel gateway stop
|
|
@@ -5666,29 +7001,77 @@ can validate and autocomplete the config:
|
|
|
5666
7001
|
});
|
|
5667
7002
|
//#endregion
|
|
5668
7003
|
//#region lib/cli/routes/status.ts
|
|
5669
|
-
const statusHelp = `funnel status —
|
|
7004
|
+
const statusHelp = `funnel status — overall health at a glance
|
|
7005
|
+
|
|
7006
|
+
usage: funnel status [--watch] [--interval <N>]
|
|
7007
|
+
|
|
7008
|
+
options:
|
|
7009
|
+
--watch continuously refresh (Ctrl+C to stop)
|
|
7010
|
+
--interval <N> polling interval in seconds (used with --watch, default: 3)
|
|
7011
|
+
|
|
7012
|
+
Shows gateway running state (pid, port, uptime), per-channel listener health
|
|
7013
|
+
(● alive / ○ dead), and whether Claude is connected to each channel as a
|
|
7014
|
+
WebSocket client. Use this as the first step when debugging missing events.
|
|
5670
7015
|
|
|
5671
|
-
|
|
7016
|
+
examples:
|
|
7017
|
+
funnel status
|
|
7018
|
+
funnel status --watch
|
|
7019
|
+
funnel status --watch --interval 5
|
|
5672
7020
|
|
|
5673
|
-
|
|
5674
|
-
and active MCP WebSocket clients.`;
|
|
7021
|
+
see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
|
|
5675
7022
|
const isGatewayStatus = (value) => {
|
|
5676
7023
|
if (value === null || typeof value !== "object") return false;
|
|
5677
7024
|
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
5678
|
-
|
|
7025
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
7026
|
+
return true;
|
|
5679
7027
|
};
|
|
5680
|
-
const
|
|
5681
|
-
const funnel = c.var.funnel;
|
|
7028
|
+
const buildStatusLines = async (funnel) => {
|
|
5682
7029
|
const channels = funnel.channels.list();
|
|
5683
7030
|
const profiles = funnel.profiles.list();
|
|
5684
7031
|
const gatewayStatus = funnel.gateway.getStatus();
|
|
5685
7032
|
const lines = [];
|
|
5686
7033
|
lines.push("= funnel status =");
|
|
5687
7034
|
lines.push("");
|
|
7035
|
+
let gatewayData = null;
|
|
7036
|
+
if (!gatewayStatus.running) lines.push("gateway: not running");
|
|
7037
|
+
else {
|
|
7038
|
+
const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
|
|
7039
|
+
let uptimeStr = "";
|
|
7040
|
+
if (res && res.ok) {
|
|
7041
|
+
const body = await res.json();
|
|
7042
|
+
if (isGatewayStatus(body)) {
|
|
7043
|
+
gatewayData = body;
|
|
7044
|
+
const uptimeSec = Math.floor(body.uptimeMs / 1e3);
|
|
7045
|
+
const uptimeMin = Math.floor(uptimeSec / 60);
|
|
7046
|
+
uptimeStr = uptimeMin >= 60 ? ` · ${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? ` · ${uptimeMin}m ${uptimeSec % 60}s` : ` · ${uptimeSec}s`;
|
|
7047
|
+
}
|
|
7048
|
+
}
|
|
7049
|
+
lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})${uptimeStr}`);
|
|
7050
|
+
}
|
|
7051
|
+
lines.push("");
|
|
7052
|
+
const clientsByChannel = /* @__PURE__ */ new Map();
|
|
7053
|
+
const listenerAliveByChannel = /* @__PURE__ */ new Map();
|
|
7054
|
+
if (gatewayData) {
|
|
7055
|
+
for (const client of gatewayData.clients) {
|
|
7056
|
+
if (client.tapAll) continue;
|
|
7057
|
+
const key = client.channelName ?? client.channel;
|
|
7058
|
+
clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
|
|
7059
|
+
}
|
|
7060
|
+
for (const listener of gatewayData.listeners) {
|
|
7061
|
+
const current = listenerAliveByChannel.get(listener.channelName);
|
|
7062
|
+
listenerAliveByChannel.set(listener.channelName, current === void 0 ? listener.alive : current && listener.alive);
|
|
7063
|
+
}
|
|
7064
|
+
}
|
|
7065
|
+
const maxNameLen = Math.max(...channels.map((ch) => ch.name.length), 0);
|
|
5688
7066
|
lines.push(`channels: ${channels.length}`);
|
|
5689
7067
|
for (const ch of channels) {
|
|
5690
|
-
const
|
|
5691
|
-
|
|
7068
|
+
const connectorLabel = ch.connectors.length > 0 ? ch.connectors.map((conn) => conn.type).join(", ") : "no connectors";
|
|
7069
|
+
const isAlive = listenerAliveByChannel.get(ch.name);
|
|
7070
|
+
const indicator = gatewayData === null ? "-" : isAlive === true ? "●" : isAlive === false ? "○" : "-";
|
|
7071
|
+
const claudeCount = clientsByChannel.get(ch.name) ?? 0;
|
|
7072
|
+
const claudeLabel = gatewayData === null ? "" : claudeCount === 0 ? " no Claude" : claudeCount === 1 ? " Claude connected (1 client)" : ` Claude connected (${claudeCount} clients)`;
|
|
7073
|
+
const paddedName = ch.name.padEnd(maxNameLen);
|
|
7074
|
+
lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
|
|
5692
7075
|
}
|
|
5693
7076
|
lines.push("");
|
|
5694
7077
|
lines.push(`profiles: ${profiles.length}`);
|
|
@@ -5698,23 +7081,41 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}),
|
|
|
5698
7081
|
const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
|
|
5699
7082
|
lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
|
|
5700
7083
|
}
|
|
5701
|
-
lines
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
7084
|
+
return lines;
|
|
7085
|
+
};
|
|
7086
|
+
const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
7087
|
+
watch: z.enum([
|
|
7088
|
+
"true",
|
|
7089
|
+
"false",
|
|
7090
|
+
""
|
|
7091
|
+
]).optional(),
|
|
7092
|
+
interval: z.string().optional()
|
|
7093
|
+
}), statusHelp), async (c) => {
|
|
7094
|
+
const query = c.req.valid("query");
|
|
7095
|
+
const funnel = c.var.funnel;
|
|
7096
|
+
const isWatch = query.watch === "true" || query.watch === "";
|
|
7097
|
+
const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
|
|
7098
|
+
if (!isWatch) {
|
|
7099
|
+
const lines = await buildStatusLines(funnel);
|
|
7100
|
+
return c.text(lines.join("\n"));
|
|
7101
|
+
}
|
|
7102
|
+
const render = async () => {
|
|
7103
|
+
const lines = await buildStatusLines(funnel);
|
|
7104
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
7105
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
7106
|
+
process.stdout.write(lines.join("\n"));
|
|
7107
|
+
process.stdout.write(`\n\n refreshing every ${intervalSec}s · ${ts} · Ctrl+C to stop\n`);
|
|
7108
|
+
};
|
|
7109
|
+
process.on("SIGINT", () => {
|
|
7110
|
+
process.stdout.write("\n");
|
|
7111
|
+
process.exit(0);
|
|
7112
|
+
});
|
|
7113
|
+
await render();
|
|
7114
|
+
const timer = setInterval(render, intervalSec * 1e3);
|
|
7115
|
+
await new Promise(() => {
|
|
7116
|
+
process.on("exit", () => clearInterval(timer));
|
|
7117
|
+
});
|
|
7118
|
+
return c.text("");
|
|
5718
7119
|
});
|
|
5719
7120
|
//#endregion
|
|
5720
7121
|
//#region lib/cli/routes/update.ts
|
|
@@ -5757,9 +7158,9 @@ const createCliApp = (funnel) => {
|
|
|
5757
7158
|
if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
|
|
5758
7159
|
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
5759
7160
|
});
|
|
5760
|
-
return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).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", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).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", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).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("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
|
|
7161
|
+
return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel/validate", ...channelsValidateHandler).get("/channels/validate", ...helpRoute(validateHelp)).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).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", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).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", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).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);
|
|
5761
7162
|
};
|
|
5762
7163
|
/** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
|
|
5763
7164
|
const app = createCliApp(new Funnel());
|
|
5764
7165
|
//#endregion
|
|
5765
|
-
export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
|
|
7166
|
+
export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
|