@interactive-inc/claude-funnel 0.36.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 +1445 -287
- package/dist/index.js +1583 -191
- 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()`;
|
|
@@ -2582,7 +2581,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
2582
2581
|
record(record) {
|
|
2583
2582
|
const event = {
|
|
2584
2583
|
type: record.meta?.event_type ?? "unknown",
|
|
2585
|
-
content: truncate(record.content),
|
|
2584
|
+
content: truncate$1(record.content),
|
|
2586
2585
|
channel_id: record.channelId,
|
|
2587
2586
|
connector_id: record.connectorId,
|
|
2588
2587
|
meta: record.meta
|
|
@@ -2639,7 +2638,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
2639
2638
|
this.sink.close();
|
|
2640
2639
|
}
|
|
2641
2640
|
};
|
|
2642
|
-
function truncate(content) {
|
|
2641
|
+
function truncate$1(content) {
|
|
2643
2642
|
if (content.length <= MAX_CONTENT_CHARS) return content;
|
|
2644
2643
|
return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
|
|
2645
2644
|
}
|
|
@@ -2995,6 +2994,240 @@ const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ chan
|
|
|
2995
2994
|
return c.json(response);
|
|
2996
2995
|
});
|
|
2997
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
|
|
2998
3231
|
//#region lib/gateway/routes/health.ts
|
|
2999
3232
|
/** GET /health — liveness + listener registry snapshot. */
|
|
3000
3233
|
const healthHandler = factory$1.createHandlers((c) => {
|
|
@@ -3067,7 +3300,7 @@ const statusHandler$1 = factory$1.createHandlers((c) => {
|
|
|
3067
3300
|
* from the `deps` variable set by `FunnelGatewayServer`'s middleware — same
|
|
3068
3301
|
* shape as CLI's `c.var.funnel`.
|
|
3069
3302
|
*/
|
|
3070
|
-
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);
|
|
3071
3304
|
//#endregion
|
|
3072
3305
|
//#region lib/gateway/gateway-server.ts
|
|
3073
3306
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -3300,6 +3533,7 @@ var FunnelGatewayServer = class {
|
|
|
3300
3533
|
if (this.token) {
|
|
3301
3534
|
base.use("/listeners/*", requireBearerToken({ expected: this.token }));
|
|
3302
3535
|
base.use("/status", requireBearerToken({ expected: this.token }));
|
|
3536
|
+
base.use("/debug", requireBearerToken({ expected: this.token }));
|
|
3303
3537
|
base.use("/channels/*", requireBearerToken({ expected: this.token }));
|
|
3304
3538
|
}
|
|
3305
3539
|
return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
|
|
@@ -3535,6 +3769,84 @@ var FunnelListenersClient = class {
|
|
|
3535
3769
|
}
|
|
3536
3770
|
};
|
|
3537
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
|
|
3538
3850
|
//#region lib/funnel.ts
|
|
3539
3851
|
const SANDBOX_DIR = "/sandbox/.funnel";
|
|
3540
3852
|
const SANDBOX_TMP_DIR = "/sandbox/tmp";
|
|
@@ -3781,6 +4093,13 @@ var Funnel = class Funnel {
|
|
|
3781
4093
|
extraRoutes: options.extraRoutes
|
|
3782
4094
|
});
|
|
3783
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
|
+
}
|
|
3784
4103
|
};
|
|
3785
4104
|
//#endregion
|
|
3786
4105
|
//#region lib/engine/mcp/channel-subscriber.ts
|
|
@@ -3874,14 +4193,44 @@ const readGatewayToken = (dir) => {
|
|
|
3874
4193
|
//#endregion
|
|
3875
4194
|
//#region lib/engine/mcp/usage-hint-for-type.ts
|
|
3876
4195
|
const usageHintForType = (type) => {
|
|
3877
|
-
if (type === "slack") return
|
|
3878
|
-
|
|
3879
|
-
|
|
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(" ");
|
|
3880
4212
|
return "Generic adapter call.";
|
|
3881
4213
|
};
|
|
3882
4214
|
//#endregion
|
|
3883
4215
|
//#region lib/engine/mcp/channel-server.ts
|
|
3884
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
|
+
};
|
|
3885
4234
|
const startChannelServer = async (options = {}) => {
|
|
3886
4235
|
const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
|
|
3887
4236
|
const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
|
|
@@ -3889,6 +4238,13 @@ const startChannelServer = async (options = {}) => {
|
|
|
3889
4238
|
const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
|
|
3890
4239
|
const channel = channelId ? readChannelConnectors(dir, channelId) : null;
|
|
3891
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") : "";
|
|
3892
4248
|
const server = new Server({
|
|
3893
4249
|
name: FUNNEL_MCP_NAME,
|
|
3894
4250
|
version: "1.0.0"
|
|
@@ -3898,13 +4254,35 @@ const startChannelServer = async (options = {}) => {
|
|
|
3898
4254
|
tools: {}
|
|
3899
4255
|
},
|
|
3900
4256
|
instructions: [
|
|
3901
|
-
`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 }`,
|
|
4271
|
+
"",
|
|
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" }`,
|
|
3902
4276
|
"",
|
|
3903
|
-
"
|
|
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
|
|
3904
4282
|
].join("\n")
|
|
3905
4283
|
});
|
|
3906
4284
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3907
|
-
|
|
4285
|
+
const connectorTools = (channel?.connectors ?? []).map((c) => ({
|
|
3908
4286
|
name: c.name,
|
|
3909
4287
|
description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
|
|
3910
4288
|
inputSchema: {
|
|
@@ -3925,17 +4303,42 @@ const startChannelServer = async (options = {}) => {
|
|
|
3925
4303
|
},
|
|
3926
4304
|
required: ["method", "path"]
|
|
3927
4305
|
}
|
|
3928
|
-
}))
|
|
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] };
|
|
3929
4331
|
});
|
|
3930
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);
|
|
3931
4335
|
if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
|
|
3932
|
-
const connectorName = request.params.name;
|
|
3933
4336
|
const args = request.params.arguments ?? {};
|
|
3934
4337
|
const method = typeof args.method === "string" ? args.method : "";
|
|
3935
4338
|
const path = typeof args.path === "string" ? args.path : "";
|
|
3936
4339
|
const body = args.body ?? {};
|
|
3937
4340
|
if (!method || !path) throw new Error("`method` and `path` are required");
|
|
3938
|
-
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(
|
|
4341
|
+
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
|
|
3939
4342
|
const headers = { "content-type": "application/json" };
|
|
3940
4343
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
3941
4344
|
const res = await fetch(url, {
|
|
@@ -3963,6 +4366,51 @@ const startChannelServer = async (options = {}) => {
|
|
|
3963
4366
|
protocols: token ? [`funnel.token.${token}`] : void 0
|
|
3964
4367
|
}).start();
|
|
3965
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
|
+
};
|
|
3966
4414
|
//#endregion
|
|
3967
4415
|
//#region lib/engine/local-config/local-config-json-schema.ts
|
|
3968
4416
|
/**
|
|
@@ -4506,87 +4954,6 @@ const takeRecent = (events, limit) => {
|
|
|
4506
4954
|
return events.slice(-limit);
|
|
4507
4955
|
};
|
|
4508
4956
|
//#endregion
|
|
4509
|
-
//#region lib/gateway/connector-diagnostic-sql-reader.ts
|
|
4510
|
-
/**
|
|
4511
|
-
* Read-only SQL surface over the three diagnostic tables, for Claude to query
|
|
4512
|
-
* the log with arbitrary `SELECT`s. It opens all files read-only and exposes
|
|
4513
|
-
* three views — `raw`, `processed`, `connection` — that hide the storage
|
|
4514
|
-
* details (the physical table is `leuco_log` and each row's columns live
|
|
4515
|
-
* inside a JSON `event` blob): the views surface the columns as plain fields,
|
|
4516
|
-
* with `payload` already pulled out of the nested JSON.
|
|
4517
|
-
*
|
|
4518
|
-
* The tables are separate files. `raw` and `processed` share an `event_id`,
|
|
4519
|
-
* so a `JOIN` answers "the event arrived, but what verdict did it get?";
|
|
4520
|
-
* `connection` answers the other half — "did the listener ever connect at
|
|
4521
|
-
* all?". Writes are impossible: the connection is read-only and `query`
|
|
4522
|
-
* rejects anything but a single `SELECT`.
|
|
4523
|
-
*/
|
|
4524
|
-
var ConnectorDiagnosticSqlReader = class {
|
|
4525
|
-
db;
|
|
4526
|
-
constructor(props) {
|
|
4527
|
-
const db = new Database(props.rawPath, { readonly: true });
|
|
4528
|
-
try {
|
|
4529
|
-
db.run("PRAGMA busy_timeout = 500");
|
|
4530
|
-
db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
|
|
4531
|
-
db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
|
|
4532
|
-
db.run(rawViewSql);
|
|
4533
|
-
db.run(processedViewSql);
|
|
4534
|
-
db.run(connectionViewSql);
|
|
4535
|
-
} catch (error) {
|
|
4536
|
-
db.close();
|
|
4537
|
-
throw error;
|
|
4538
|
-
}
|
|
4539
|
-
this.db = db;
|
|
4540
|
-
Object.freeze(this);
|
|
4541
|
-
}
|
|
4542
|
-
/**
|
|
4543
|
-
* Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
|
|
4544
|
-
* than throwing) for a non-SELECT statement or a SQL error, so the caller
|
|
4545
|
-
* can surface the message without a stack trace.
|
|
4546
|
-
*/
|
|
4547
|
-
query(sql) {
|
|
4548
|
-
const trimmed = sql.trim().replace(/;$/, "").trim();
|
|
4549
|
-
if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
|
|
4550
|
-
if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
|
|
4551
|
-
try {
|
|
4552
|
-
return this.db.prepare(trimmed).all();
|
|
4553
|
-
} catch (error) {
|
|
4554
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
4555
|
-
}
|
|
4556
|
-
}
|
|
4557
|
-
close() {
|
|
4558
|
-
this.db.close();
|
|
4559
|
-
}
|
|
4560
|
-
};
|
|
4561
|
-
const rawViewSql = `CREATE TEMP VIEW raw 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, '$.payload') AS payload
|
|
4569
|
-
FROM main.leuco_log`;
|
|
4570
|
-
const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
|
|
4571
|
-
seq,
|
|
4572
|
-
ts,
|
|
4573
|
-
json_extract(event, '$.event_id') AS event_id,
|
|
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, '$.outcome') AS outcome,
|
|
4578
|
-
json_extract(event, '$.payload') AS payload
|
|
4579
|
-
FROM processeddb.leuco_log`;
|
|
4580
|
-
const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
|
|
4581
|
-
seq,
|
|
4582
|
-
ts,
|
|
4583
|
-
json_extract(event, '$.type') AS type,
|
|
4584
|
-
json_extract(event, '$.connector_id') AS connector_id,
|
|
4585
|
-
json_extract(event, '$.channel_id') AS channel_id,
|
|
4586
|
-
json_extract(event, '$.status') AS status,
|
|
4587
|
-
json_extract(event, '$.detail') AS detail
|
|
4588
|
-
FROM connectiondb.leuco_log`;
|
|
4589
|
-
//#endregion
|
|
4590
4957
|
//#region lib/cli/factory.ts
|
|
4591
4958
|
const factory = createFactory();
|
|
4592
4959
|
//#endregion
|
|
@@ -5051,9 +5418,16 @@ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.objec
|
|
|
5051
5418
|
];
|
|
5052
5419
|
return c.text(lines.join("\n"));
|
|
5053
5420
|
});
|
|
5054
|
-
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
|
|
5426
|
+
|
|
5427
|
+
usage: funnel channels [--json]
|
|
5055
5428
|
|
|
5056
|
-
|
|
5429
|
+
options:
|
|
5430
|
+
--json output as JSON array (machine-readable, useful for Claude)
|
|
5057
5431
|
|
|
5058
5432
|
subcommands:
|
|
5059
5433
|
(none) list
|
|
@@ -5064,13 +5438,26 @@ subcommands:
|
|
|
5064
5438
|
<name> connectors add <c> --type=... add a connector
|
|
5065
5439
|
|
|
5066
5440
|
examples:
|
|
5441
|
+
funnel channels
|
|
5442
|
+
funnel channels --json
|
|
5067
5443
|
funnel channels add prod-inbox
|
|
5068
5444
|
funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
|
|
5069
5445
|
funnel channels prod-inbox`), (c) => {
|
|
5446
|
+
const query = c.req.valid("query");
|
|
5070
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
|
+
})));
|
|
5071
5458
|
if (channels.length === 0) return c.text("no channels");
|
|
5072
5459
|
const lines = channels.map((ch) => {
|
|
5073
|
-
const names = ch.connectors.map((
|
|
5460
|
+
const names = ch.connectors.map((conn) => conn.name);
|
|
5074
5461
|
const connectors = names.length > 0 ? names.join(", ") : "(none)";
|
|
5075
5462
|
return `${ch.name} [${connectors}]`;
|
|
5076
5463
|
});
|
|
@@ -5161,12 +5548,848 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
5161
5548
|
process.exit(exitCode);
|
|
5162
5549
|
});
|
|
5163
5550
|
//#endregion
|
|
5164
|
-
//#region lib/cli/routes/
|
|
5165
|
-
const
|
|
5551
|
+
//#region lib/cli/routes/debug.ts
|
|
5552
|
+
const debugHelp = `funnel debug — diagnose why Claude is not receiving events
|
|
5166
5553
|
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
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
|
|
6388
|
+
const groupHelp$1 = `funnel gateway — manage the funnel daemon
|
|
6389
|
+
|
|
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.
|
|
5170
6393
|
|
|
5171
6394
|
usage: funnel gateway [subcommand]
|
|
5172
6395
|
|
|
@@ -5177,20 +6400,49 @@ subcommands:
|
|
|
5177
6400
|
restart stop then start
|
|
5178
6401
|
run start in foreground (for developers)
|
|
5179
6402
|
logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
|
|
5180
|
-
sql
|
|
6403
|
+
sql query inbound connector traffic (raw + processed verdict)
|
|
5181
6404
|
listeners list running connector listeners (alive / dead)
|
|
5182
6405
|
|
|
5183
6406
|
examples:
|
|
5184
|
-
funnel gateway
|
|
5185
|
-
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)`;
|
|
5186
6413
|
const renderGatewayStatus = async (c) => {
|
|
5187
6414
|
const status = c.var.funnel.gateway.getStatus();
|
|
5188
6415
|
if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
|
|
5189
|
-
const res = await fetch(`http://127.0.0.1:${status.port}/
|
|
6416
|
+
const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
|
|
5190
6417
|
if (!res) return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`);
|
|
5191
|
-
const
|
|
5192
|
-
const
|
|
5193
|
-
|
|
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"));
|
|
5194
6446
|
};
|
|
5195
6447
|
const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
|
|
5196
6448
|
const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners — show running connector listeners
|
|
@@ -5212,24 +6464,31 @@ examples:
|
|
|
5212
6464
|
});
|
|
5213
6465
|
//#endregion
|
|
5214
6466
|
//#region lib/cli/routes/gateway.logs.ts
|
|
5215
|
-
const logsHelp = `funnel gateway logs — tail diagnostic
|
|
6467
|
+
const logsHelp = `funnel gateway logs — tail the daemon diagnostic log
|
|
5216
6468
|
|
|
5217
|
-
usage: funnel gateway logs [-n <N>]
|
|
6469
|
+
usage: funnel gateway logs [-n <N>] [--format <plain|json>]
|
|
5218
6470
|
|
|
5219
6471
|
options:
|
|
5220
6472
|
-n <N> number of trailing lines to show (default: 20)
|
|
6473
|
+
--format <plain|json> output format (default: plain)
|
|
5221
6474
|
|
|
5222
|
-
|
|
5223
|
-
lifecycle,
|
|
5224
|
-
|
|
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.
|
|
5225
6478
|
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
6479
|
+
plain format: HH:MM:SS LEVEL message key=value ...
|
|
6480
|
+
json format: raw JSON lines (pipe to jq for filtering)
|
|
6481
|
+
|
|
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
|
|
5229
6485
|
|
|
5230
6486
|
examples:
|
|
5231
6487
|
funnel gateway logs
|
|
5232
|
-
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`;
|
|
5233
6492
|
const logger = new NodeFunnelLogger();
|
|
5234
6493
|
const tryParseJson = (line) => {
|
|
5235
6494
|
try {
|
|
@@ -5245,11 +6504,27 @@ const isLogEntry = (value) => {
|
|
|
5245
6504
|
if (!("message" in value) || typeof value.message !== "string") return false;
|
|
5246
6505
|
return true;
|
|
5247
6506
|
};
|
|
5248
|
-
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) => {
|
|
5249
6523
|
const query = c.req.valid("query");
|
|
5250
6524
|
const path = logger.file;
|
|
5251
6525
|
if (!path || !existsSync(path)) return c.text("no logs");
|
|
5252
6526
|
const lineCount = query.n ? Number(query.n) : 20;
|
|
6527
|
+
const format = query.format ?? "plain";
|
|
5253
6528
|
const tail = Bun.spawn([
|
|
5254
6529
|
"tail",
|
|
5255
6530
|
"-f",
|
|
@@ -5268,7 +6543,6 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5268
6543
|
const reader = tail.stdout.getReader();
|
|
5269
6544
|
const decoder = new TextDecoder();
|
|
5270
6545
|
let buffer = "";
|
|
5271
|
-
logger.info("gateway.logs tail start", { file: path });
|
|
5272
6546
|
while (true) {
|
|
5273
6547
|
const result = await reader.read();
|
|
5274
6548
|
if (result.done) break;
|
|
@@ -5282,13 +6556,8 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5282
6556
|
process.stdout.write(`${line}\n`);
|
|
5283
6557
|
continue;
|
|
5284
6558
|
}
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
level: parsed.level,
|
|
5288
|
-
message: parsed.message,
|
|
5289
|
-
...parsed.meta ? { meta: parsed.meta } : {}
|
|
5290
|
-
};
|
|
5291
|
-
process.stdout.write(`---\n${stringify(output)}`);
|
|
6559
|
+
if (format === "json") process.stdout.write(`${JSON.stringify(parsed)}\n`);
|
|
6560
|
+
else process.stdout.write(formatPlain(parsed));
|
|
5292
6561
|
}
|
|
5293
6562
|
}
|
|
5294
6563
|
await tail.exited;
|
|
@@ -5296,47 +6565,78 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5296
6565
|
});
|
|
5297
6566
|
//#endregion
|
|
5298
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
|
+
};
|
|
5299
6575
|
const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
|
|
5300
6576
|
|
|
5301
|
-
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
|
|
5302
6592
|
|
|
5303
|
-
|
|
5304
|
-
connector events and prints the rows as JSON. Use it to answer "Slack
|
|
5305
|
-
delivered an event, so why was there no notification?".
|
|
6593
|
+
Output is always JSON — pipe to jq or pass directly to Claude.
|
|
5306
6594
|
|
|
5307
|
-
|
|
5308
|
-
raw every inbound event
|
|
5309
|
-
processed
|
|
5310
|
-
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 | ...)
|
|
5311
6599
|
|
|
5312
|
-
|
|
5313
|
-
seq row id within the view (not comparable across views)
|
|
5314
|
-
ts epoch milliseconds
|
|
5315
|
-
type connector kind: slack | discord | gh | schedule
|
|
5316
|
-
connector_id funnel connector id
|
|
5317
|
-
channel_id funnel channel id
|
|
5318
|
-
raw and processed also have:
|
|
5319
|
-
event_id correlation id shared by an event's raw and processed rows
|
|
5320
|
-
payload raw: the original event JSON (text); processed: the delivered body, or "" when skipped
|
|
5321
|
-
processed also has:
|
|
5322
|
-
outcome 'emitted' | 'emitted:delivery-failed' | 'skip:<reason>'
|
|
5323
|
-
(skip reasons: skip:type, skip:subtype, skip:dedup,
|
|
5324
|
-
skip:self-user, skip:self-bot, skip:preprocess)
|
|
5325
|
-
connection also has:
|
|
5326
|
-
status 'started' | 'connected' | 'disconnected' | 'auth-failed' | 'stopped' | 'error'
|
|
5327
|
-
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'
|
|
5328
6601
|
|
|
5329
|
-
|
|
5330
|
-
event tables are empty, query connection — a listener that never connected, or
|
|
5331
|
-
failed auth, explains why nothing arrived.
|
|
6602
|
+
skip reasons: skip:type skip:subtype skip:dedup skip:self-user skip:self-bot skip:preprocess
|
|
5332
6603
|
|
|
5333
6604
|
examples:
|
|
5334
|
-
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
|
|
5335
6610
|
funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
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;
|
|
5340
6640
|
if (!sql) return c.text(sqlHelp);
|
|
5341
6641
|
const tmpDir = funnelTmpDir();
|
|
5342
6642
|
const rawPath = join(tmpDir, "connector-raw.db");
|
|
@@ -5350,7 +6650,7 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
|
|
|
5350
6650
|
});
|
|
5351
6651
|
const rows = (() => {
|
|
5352
6652
|
try {
|
|
5353
|
-
return reader.query(sql);
|
|
6653
|
+
return reader.query(sql, params);
|
|
5354
6654
|
} finally {
|
|
5355
6655
|
reader.close();
|
|
5356
6656
|
}
|
|
@@ -5428,15 +6728,41 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
|
|
|
5428
6728
|
if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
|
|
5429
6729
|
return c.text("funnel gateway: started");
|
|
5430
6730
|
});
|
|
5431
|
-
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
|
|
6736
|
+
|
|
6737
|
+
usage: funnel gateway status [--json]
|
|
5432
6738
|
|
|
5433
|
-
|
|
6739
|
+
options:
|
|
6740
|
+
--json output as JSON
|
|
5434
6741
|
|
|
5435
|
-
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.
|
|
5436
6744
|
|
|
5437
6745
|
examples:
|
|
5438
6746
|
funnel gateway status
|
|
5439
|
-
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
|
+
});
|
|
5440
6766
|
const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
|
|
5441
6767
|
|
|
5442
6768
|
usage: funnel gateway stop
|
|
@@ -5675,29 +7001,77 @@ can validate and autocomplete the config:
|
|
|
5675
7001
|
});
|
|
5676
7002
|
//#endregion
|
|
5677
7003
|
//#region lib/cli/routes/status.ts
|
|
5678
|
-
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.
|
|
5679
7015
|
|
|
5680
|
-
|
|
7016
|
+
examples:
|
|
7017
|
+
funnel status
|
|
7018
|
+
funnel status --watch
|
|
7019
|
+
funnel status --watch --interval 5
|
|
5681
7020
|
|
|
5682
|
-
|
|
5683
|
-
and active MCP WebSocket clients.`;
|
|
7021
|
+
see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
|
|
5684
7022
|
const isGatewayStatus = (value) => {
|
|
5685
7023
|
if (value === null || typeof value !== "object") return false;
|
|
5686
7024
|
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
5687
|
-
|
|
7025
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
7026
|
+
return true;
|
|
5688
7027
|
};
|
|
5689
|
-
const
|
|
5690
|
-
const funnel = c.var.funnel;
|
|
7028
|
+
const buildStatusLines = async (funnel) => {
|
|
5691
7029
|
const channels = funnel.channels.list();
|
|
5692
7030
|
const profiles = funnel.profiles.list();
|
|
5693
7031
|
const gatewayStatus = funnel.gateway.getStatus();
|
|
5694
7032
|
const lines = [];
|
|
5695
7033
|
lines.push("= funnel status =");
|
|
5696
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);
|
|
5697
7066
|
lines.push(`channels: ${channels.length}`);
|
|
5698
7067
|
for (const ch of channels) {
|
|
5699
|
-
const
|
|
5700
|
-
|
|
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}`);
|
|
5701
7075
|
}
|
|
5702
7076
|
lines.push("");
|
|
5703
7077
|
lines.push(`profiles: ${profiles.length}`);
|
|
@@ -5707,23 +7081,41 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}),
|
|
|
5707
7081
|
const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
|
|
5708
7082
|
lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
|
|
5709
7083
|
}
|
|
5710
|
-
lines
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
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("");
|
|
5727
7119
|
});
|
|
5728
7120
|
//#endregion
|
|
5729
7121
|
//#region lib/cli/routes/update.ts
|
|
@@ -5766,7 +7158,7 @@ const createCliApp = (funnel) => {
|
|
|
5766
7158
|
if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
|
|
5767
7159
|
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
5768
7160
|
});
|
|
5769
|
-
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);
|
|
5770
7162
|
};
|
|
5771
7163
|
/** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
|
|
5772
7164
|
const app = createCliApp(new Funnel());
|