@interactive-inc/claude-funnel 0.36.0 → 0.38.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 +244 -322
- package/dist/bin.js +554 -509
- package/dist/gateway/daemon.js +275 -237
- package/dist/index.d.ts +1904 -2308
- package/dist/index.js +1698 -312
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhLi
|
|
|
4
4
|
import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CM-sRkac.js";
|
|
5
5
|
import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-DDbSGPZn.js";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { hc } from "hono/client";
|
|
7
8
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -17,7 +18,6 @@ import { zValidator } from "@hono/zod-validator";
|
|
|
17
18
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
19
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
20
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
-
import { stringify } from "yaml";
|
|
21
21
|
//#region lib/engine/id/id-generator.ts
|
|
22
22
|
/**
|
|
23
23
|
* ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
|
|
@@ -2582,7 +2582,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
2582
2582
|
record(record) {
|
|
2583
2583
|
const event = {
|
|
2584
2584
|
type: record.meta?.event_type ?? "unknown",
|
|
2585
|
-
content: truncate(record.content),
|
|
2585
|
+
content: truncate$1(record.content),
|
|
2586
2586
|
channel_id: record.channelId,
|
|
2587
2587
|
connector_id: record.connectorId,
|
|
2588
2588
|
meta: record.meta
|
|
@@ -2639,7 +2639,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
|
|
|
2639
2639
|
this.sink.close();
|
|
2640
2640
|
}
|
|
2641
2641
|
};
|
|
2642
|
-
function truncate(content) {
|
|
2642
|
+
function truncate$1(content) {
|
|
2643
2643
|
if (content.length <= MAX_CONTENT_CHARS) return content;
|
|
2644
2644
|
return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
|
|
2645
2645
|
}
|
|
@@ -2995,6 +2995,240 @@ const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ chan
|
|
|
2995
2995
|
return c.json(response);
|
|
2996
2996
|
});
|
|
2997
2997
|
//#endregion
|
|
2998
|
+
//#region lib/gateway/connector-diagnostic-sql-reader.ts
|
|
2999
|
+
/**
|
|
3000
|
+
* Read-only SQL surface over the three diagnostic tables, for Claude to query
|
|
3001
|
+
* the log with arbitrary `SELECT`s. It opens all files read-only and exposes
|
|
3002
|
+
* three views — `raw`, `processed`, `connection` — that hide the storage
|
|
3003
|
+
* details (the physical table is `leuco_log` and each row's columns live
|
|
3004
|
+
* inside a JSON `event` blob): the views surface the columns as plain fields,
|
|
3005
|
+
* with `payload` already pulled out of the nested JSON.
|
|
3006
|
+
*
|
|
3007
|
+
* The tables are separate files. `raw` and `processed` share an `event_id`,
|
|
3008
|
+
* so a `JOIN` answers "the event arrived, but what verdict did it get?";
|
|
3009
|
+
* `connection` answers the other half — "did the listener ever connect at
|
|
3010
|
+
* all?". Writes are impossible: the connection is read-only and `query`
|
|
3011
|
+
* rejects anything but a single `SELECT`.
|
|
3012
|
+
*/
|
|
3013
|
+
var ConnectorDiagnosticSqlReader = class {
|
|
3014
|
+
db;
|
|
3015
|
+
constructor(props) {
|
|
3016
|
+
const db = new Database(props.rawPath, { readonly: true });
|
|
3017
|
+
try {
|
|
3018
|
+
db.run("PRAGMA busy_timeout = 500");
|
|
3019
|
+
db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
|
|
3020
|
+
db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
|
|
3021
|
+
db.run(rawViewSql);
|
|
3022
|
+
db.run(processedViewSql);
|
|
3023
|
+
db.run(connectionViewSql);
|
|
3024
|
+
} catch (error) {
|
|
3025
|
+
db.close();
|
|
3026
|
+
throw error;
|
|
3027
|
+
}
|
|
3028
|
+
this.db = db;
|
|
3029
|
+
Object.freeze(this);
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
|
|
3033
|
+
* than throwing) for a non-SELECT statement or a SQL error, so the caller
|
|
3034
|
+
* can surface the message without a stack trace.
|
|
3035
|
+
*/
|
|
3036
|
+
query(sql, params = []) {
|
|
3037
|
+
const trimmed = sql.trim().replace(/;$/, "").trim();
|
|
3038
|
+
if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
|
|
3039
|
+
if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
|
|
3040
|
+
try {
|
|
3041
|
+
return this.db.prepare(trimmed).all(...params);
|
|
3042
|
+
} catch (error) {
|
|
3043
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
close() {
|
|
3047
|
+
this.db.close();
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
|
|
3051
|
+
seq,
|
|
3052
|
+
ts,
|
|
3053
|
+
json_extract(event, '$.event_id') AS event_id,
|
|
3054
|
+
json_extract(event, '$.type') AS type,
|
|
3055
|
+
json_extract(event, '$.connector_id') AS connector_id,
|
|
3056
|
+
json_extract(event, '$.channel_id') AS channel_id,
|
|
3057
|
+
json_extract(event, '$.payload') AS payload
|
|
3058
|
+
FROM main.leuco_log`;
|
|
3059
|
+
const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
|
|
3060
|
+
seq,
|
|
3061
|
+
ts,
|
|
3062
|
+
json_extract(event, '$.event_id') AS event_id,
|
|
3063
|
+
json_extract(event, '$.type') AS type,
|
|
3064
|
+
json_extract(event, '$.connector_id') AS connector_id,
|
|
3065
|
+
json_extract(event, '$.channel_id') AS channel_id,
|
|
3066
|
+
json_extract(event, '$.outcome') AS outcome,
|
|
3067
|
+
json_extract(event, '$.payload') AS payload
|
|
3068
|
+
FROM processeddb.leuco_log`;
|
|
3069
|
+
const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
|
|
3070
|
+
seq,
|
|
3071
|
+
ts,
|
|
3072
|
+
json_extract(event, '$.type') AS type,
|
|
3073
|
+
json_extract(event, '$.connector_id') AS connector_id,
|
|
3074
|
+
json_extract(event, '$.channel_id') AS channel_id,
|
|
3075
|
+
json_extract(event, '$.status') AS status,
|
|
3076
|
+
json_extract(event, '$.detail') AS detail
|
|
3077
|
+
FROM connectiondb.leuco_log`;
|
|
3078
|
+
//#endregion
|
|
3079
|
+
//#region lib/gateway/routes/debug.ts
|
|
3080
|
+
const extractPreview = (payload) => {
|
|
3081
|
+
if (typeof payload !== "string" || payload.length === 0) return null;
|
|
3082
|
+
try {
|
|
3083
|
+
const parsed = JSON.parse(payload);
|
|
3084
|
+
if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
|
|
3085
|
+
const text = String(parsed.text);
|
|
3086
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
3087
|
+
}
|
|
3088
|
+
} catch {
|
|
3089
|
+
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3090
|
+
}
|
|
3091
|
+
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
|
|
3092
|
+
};
|
|
3093
|
+
const buildChannelDiagnosis = (channel) => {
|
|
3094
|
+
const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
|
|
3095
|
+
if (channel.connectors.length === 0) return {
|
|
3096
|
+
status: "warn",
|
|
3097
|
+
message: "no connectors configured on this channel",
|
|
3098
|
+
nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
|
|
3099
|
+
rootCause: null
|
|
3100
|
+
};
|
|
3101
|
+
if (!channel.listener) return {
|
|
3102
|
+
status: "error",
|
|
3103
|
+
message: "no listener running for this channel",
|
|
3104
|
+
nextActions: ["fnl gateway restart"],
|
|
3105
|
+
rootCause
|
|
3106
|
+
};
|
|
3107
|
+
if (!channel.listener.alive) return {
|
|
3108
|
+
status: "error",
|
|
3109
|
+
message: "listener is dead",
|
|
3110
|
+
nextActions: ["fnl gateway logs", "fnl gateway restart"],
|
|
3111
|
+
rootCause
|
|
3112
|
+
};
|
|
3113
|
+
if (channel.claudeClients === 0) return {
|
|
3114
|
+
status: "warn",
|
|
3115
|
+
message: "no Claude connected to this channel",
|
|
3116
|
+
nextActions: [`fnl claude --channel ${channel.name}`],
|
|
3117
|
+
rootCause: null
|
|
3118
|
+
};
|
|
3119
|
+
if (channel.listener.errors > 0) return {
|
|
3120
|
+
status: "warn",
|
|
3121
|
+
message: "listener has errors",
|
|
3122
|
+
nextActions: ["fnl gateway logs"],
|
|
3123
|
+
rootCause
|
|
3124
|
+
};
|
|
3125
|
+
return {
|
|
3126
|
+
status: "ok",
|
|
3127
|
+
message: "healthy",
|
|
3128
|
+
nextActions: [],
|
|
3129
|
+
rootCause: null
|
|
3130
|
+
};
|
|
3131
|
+
};
|
|
3132
|
+
/** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
|
|
3133
|
+
const debugHandler$1 = factory$1.createHandlers(async (c) => {
|
|
3134
|
+
const deps = c.var.deps;
|
|
3135
|
+
const channelFilter = c.req.query("channel") ?? null;
|
|
3136
|
+
const allChannels = deps.channels.list();
|
|
3137
|
+
const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
|
|
3138
|
+
const gatewayListeners = deps.supervisor.list();
|
|
3139
|
+
const gatewayClients = deps.broadcaster.listChannels();
|
|
3140
|
+
const metrics = deps.broadcaster.getMetrics();
|
|
3141
|
+
const tmpDir = funnelTmpDir();
|
|
3142
|
+
const rawPath = join(tmpDir, "connector-raw.db");
|
|
3143
|
+
const processedPath = join(tmpDir, "connector-processed.db");
|
|
3144
|
+
const connectionPath = join(tmpDir, "connector-connection.db");
|
|
3145
|
+
const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
|
|
3146
|
+
const channels = targetChannels.map((ch) => {
|
|
3147
|
+
const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
|
|
3148
|
+
const listener = listenerEntry ? {
|
|
3149
|
+
alive: listenerEntry.alive,
|
|
3150
|
+
events: listenerEntry.events,
|
|
3151
|
+
errors: listenerEntry.errors,
|
|
3152
|
+
lastEventAt: listenerEntry.lastEventAt
|
|
3153
|
+
} : null;
|
|
3154
|
+
const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
|
|
3155
|
+
const recentEvents = [];
|
|
3156
|
+
const connectionErrors = [];
|
|
3157
|
+
if (hasStore) {
|
|
3158
|
+
const reader = new ConnectorDiagnosticSqlReader({
|
|
3159
|
+
rawPath,
|
|
3160
|
+
processedPath,
|
|
3161
|
+
connectionPath
|
|
3162
|
+
});
|
|
3163
|
+
const rows = (() => {
|
|
3164
|
+
try {
|
|
3165
|
+
return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
|
|
3166
|
+
} finally {
|
|
3167
|
+
reader.close();
|
|
3168
|
+
}
|
|
3169
|
+
})();
|
|
3170
|
+
if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
|
|
3171
|
+
const rawPayload = typeof row.payload === "string" ? row.payload : null;
|
|
3172
|
+
let payloadParsed = null;
|
|
3173
|
+
if (rawPayload) try {
|
|
3174
|
+
const parsed = JSON.parse(rawPayload);
|
|
3175
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
|
|
3176
|
+
} catch {
|
|
3177
|
+
payloadParsed = null;
|
|
3178
|
+
}
|
|
3179
|
+
recentEvents.push({
|
|
3180
|
+
seq: typeof row.seq === "number" ? row.seq : null,
|
|
3181
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3182
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
3183
|
+
outcome: typeof row.outcome === "string" ? row.outcome : "?",
|
|
3184
|
+
payload: rawPayload,
|
|
3185
|
+
payloadParsed,
|
|
3186
|
+
preview: extractPreview(row.payload)
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
if (listener && (!listener.alive || listener.errors > 0) || !listener) {
|
|
3190
|
+
const errReader = new ConnectorDiagnosticSqlReader({
|
|
3191
|
+
rawPath,
|
|
3192
|
+
processedPath,
|
|
3193
|
+
connectionPath
|
|
3194
|
+
});
|
|
3195
|
+
const errRows = (() => {
|
|
3196
|
+
try {
|
|
3197
|
+
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]);
|
|
3198
|
+
} finally {
|
|
3199
|
+
errReader.close();
|
|
3200
|
+
}
|
|
3201
|
+
})();
|
|
3202
|
+
if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
|
|
3203
|
+
ts: typeof row.ts === "number" ? row.ts : null,
|
|
3204
|
+
type: typeof row.type === "string" ? row.type : "?",
|
|
3205
|
+
status: typeof row.status === "string" ? row.status : "?",
|
|
3206
|
+
detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
const base = {
|
|
3211
|
+
id: ch.id,
|
|
3212
|
+
name: ch.name,
|
|
3213
|
+
connectors: ch.connectors.map((conn) => conn.name),
|
|
3214
|
+
listener,
|
|
3215
|
+
claudeClients,
|
|
3216
|
+
recentEvents,
|
|
3217
|
+
connectionErrors
|
|
3218
|
+
};
|
|
3219
|
+
return {
|
|
3220
|
+
...base,
|
|
3221
|
+
diagnosis: buildChannelDiagnosis(base)
|
|
3222
|
+
};
|
|
3223
|
+
});
|
|
3224
|
+
return c.json({
|
|
3225
|
+
pid: deps.selfPid,
|
|
3226
|
+
uptimeMs: deps.uptimeMs(),
|
|
3227
|
+
eventsBroadcast: metrics.eventsBroadcast,
|
|
3228
|
+
channels
|
|
3229
|
+
});
|
|
3230
|
+
});
|
|
3231
|
+
//#endregion
|
|
2998
3232
|
//#region lib/gateway/routes/health.ts
|
|
2999
3233
|
/** GET /health — liveness + listener registry snapshot. */
|
|
3000
3234
|
const healthHandler = factory$1.createHandlers((c) => {
|
|
@@ -3061,13 +3295,10 @@ const statusHandler$1 = factory$1.createHandlers((c) => {
|
|
|
3061
3295
|
});
|
|
3062
3296
|
//#endregion
|
|
3063
3297
|
//#region lib/gateway/routes/index.ts
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
* shape as CLI's `c.var.funnel`.
|
|
3069
|
-
*/
|
|
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);
|
|
3298
|
+
function buildGatewayRoutes() {
|
|
3299
|
+
return factory$1.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler$1).get("/debug", ...debugHandler$1).get("/listeners", ...listenersListHandler).post("/listeners/:channel/:connector/start", ...listenersStartHandler).delete("/listeners/:channel/:connector", ...listenersStopHandler).post("/listeners/:channel/:connector/restart", ...listenersRestartHandler).post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler).post("/channels/:channel/publish", ...channelsPublishHandler$1);
|
|
3300
|
+
}
|
|
3301
|
+
const gatewayRoutes = buildGatewayRoutes();
|
|
3071
3302
|
//#endregion
|
|
3072
3303
|
//#region lib/gateway/gateway-server.ts
|
|
3073
3304
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -3300,6 +3531,7 @@ var FunnelGatewayServer = class {
|
|
|
3300
3531
|
if (this.token) {
|
|
3301
3532
|
base.use("/listeners/*", requireBearerToken({ expected: this.token }));
|
|
3302
3533
|
base.use("/status", requireBearerToken({ expected: this.token }));
|
|
3534
|
+
base.use("/debug", requireBearerToken({ expected: this.token }));
|
|
3303
3535
|
base.use("/channels/*", requireBearerToken({ expected: this.token }));
|
|
3304
3536
|
}
|
|
3305
3537
|
return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
|
|
@@ -3535,6 +3767,84 @@ var FunnelListenersClient = class {
|
|
|
3535
3767
|
}
|
|
3536
3768
|
};
|
|
3537
3769
|
//#endregion
|
|
3770
|
+
//#region lib/gateway/funnel-debug.ts
|
|
3771
|
+
const isGatewayStatusResponse$1 = (value) => {
|
|
3772
|
+
if (value === null || typeof value !== "object") return false;
|
|
3773
|
+
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
3774
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
3775
|
+
return true;
|
|
3776
|
+
};
|
|
3777
|
+
const buildFunnelDebugReport = async (deps, channelFilter) => {
|
|
3778
|
+
const gatewayStatus = deps.gateway.getStatus();
|
|
3779
|
+
const report = {
|
|
3780
|
+
gateway: {
|
|
3781
|
+
running: gatewayStatus.running,
|
|
3782
|
+
pid: gatewayStatus.pid,
|
|
3783
|
+
port: gatewayStatus.running ? gatewayStatus.port : null,
|
|
3784
|
+
uptimeMs: null
|
|
3785
|
+
},
|
|
3786
|
+
channels: [],
|
|
3787
|
+
recentEvents: null
|
|
3788
|
+
};
|
|
3789
|
+
const allChannels = deps.channels.list();
|
|
3790
|
+
const filteredChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter) : allChannels;
|
|
3791
|
+
let gatewayData = null;
|
|
3792
|
+
if (gatewayStatus.running) {
|
|
3793
|
+
const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
|
|
3794
|
+
if (res && res.ok) {
|
|
3795
|
+
const body = await res.json();
|
|
3796
|
+
if (isGatewayStatusResponse$1(body)) {
|
|
3797
|
+
gatewayData = body;
|
|
3798
|
+
report.gateway.uptimeMs = body.uptimeMs;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
for (const ch of filteredChannels) {
|
|
3803
|
+
const listenerEntry = gatewayData?.listeners.find((l) => l.channelName === ch.name) ?? null;
|
|
3804
|
+
const listener = listenerEntry ? {
|
|
3805
|
+
alive: listenerEntry.alive,
|
|
3806
|
+
events: listenerEntry.events,
|
|
3807
|
+
errors: listenerEntry.errors,
|
|
3808
|
+
lastEventAt: listenerEntry.lastEventAt
|
|
3809
|
+
} : null;
|
|
3810
|
+
const claudeClients = (gatewayData?.clients ?? []).filter((cl) => !cl.tapAll && (cl.channelName === ch.name || cl.channel === ch.name));
|
|
3811
|
+
report.channels.push({
|
|
3812
|
+
name: ch.name,
|
|
3813
|
+
connectors: ch.connectors.map((conn) => conn.name),
|
|
3814
|
+
listener,
|
|
3815
|
+
claudeConnected: claudeClients.length > 0,
|
|
3816
|
+
claudeClientCount: claudeClients.length
|
|
3817
|
+
});
|
|
3818
|
+
}
|
|
3819
|
+
const rawPath = join(deps.tmpDir, "connector-raw.db");
|
|
3820
|
+
const processedPath = join(deps.tmpDir, "connector-processed.db");
|
|
3821
|
+
const connectionPath = join(deps.tmpDir, "connector-connection.db");
|
|
3822
|
+
if (existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath)) {
|
|
3823
|
+
const reader = new ConnectorDiagnosticSqlReader({
|
|
3824
|
+
rawPath,
|
|
3825
|
+
processedPath,
|
|
3826
|
+
connectionPath
|
|
3827
|
+
});
|
|
3828
|
+
const filteredChannelId = channelFilter ? allChannels.find((ch) => ch.name === channelFilter)?.id ?? null : null;
|
|
3829
|
+
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";
|
|
3830
|
+
const params = filteredChannelId ? [filteredChannelId] : [];
|
|
3831
|
+
const rows = reader.query(sql, params);
|
|
3832
|
+
reader.close();
|
|
3833
|
+
if (!(rows instanceof Error)) report.recentEvents = rows.map((row) => {
|
|
3834
|
+
const ts = typeof row.ts === "number" ? row.ts : 0;
|
|
3835
|
+
const outcome = typeof row.outcome === "string" ? row.outcome : "";
|
|
3836
|
+
const payload = typeof row.payload === "string" ? row.payload : null;
|
|
3837
|
+
return {
|
|
3838
|
+
ts,
|
|
3839
|
+
outcome,
|
|
3840
|
+
payload,
|
|
3841
|
+
preview: payload ? payload.slice(0, 120) : null
|
|
3842
|
+
};
|
|
3843
|
+
});
|
|
3844
|
+
}
|
|
3845
|
+
return report;
|
|
3846
|
+
};
|
|
3847
|
+
//#endregion
|
|
3538
3848
|
//#region lib/funnel.ts
|
|
3539
3849
|
const SANDBOX_DIR = "/sandbox/.funnel";
|
|
3540
3850
|
const SANDBOX_TMP_DIR = "/sandbox/tmp";
|
|
@@ -3781,6 +4091,17 @@ var Funnel = class Funnel {
|
|
|
3781
4091
|
extraRoutes: options.extraRoutes
|
|
3782
4092
|
});
|
|
3783
4093
|
}
|
|
4094
|
+
async debug(channelName) {
|
|
4095
|
+
return buildFunnelDebugReport({
|
|
4096
|
+
gateway: this.gateway,
|
|
4097
|
+
channels: this.channels,
|
|
4098
|
+
tmpDir: this.paths.tmpDir
|
|
4099
|
+
}, channelName ?? null);
|
|
4100
|
+
}
|
|
4101
|
+
gatewayClient() {
|
|
4102
|
+
const { port } = this.gateway.getStatus();
|
|
4103
|
+
return hc(`http://127.0.0.1:${port}`);
|
|
4104
|
+
}
|
|
3784
4105
|
};
|
|
3785
4106
|
//#endregion
|
|
3786
4107
|
//#region lib/engine/mcp/channel-subscriber.ts
|
|
@@ -3874,14 +4195,44 @@ const readGatewayToken = (dir) => {
|
|
|
3874
4195
|
//#endregion
|
|
3875
4196
|
//#region lib/engine/mcp/usage-hint-for-type.ts
|
|
3876
4197
|
const usageHintForType = (type) => {
|
|
3877
|
-
if (type === "slack") return
|
|
3878
|
-
|
|
3879
|
-
|
|
4198
|
+
if (type === "slack") return [
|
|
4199
|
+
"Slack Web API.",
|
|
4200
|
+
"To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
|
|
4201
|
+
"To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
|
|
4202
|
+
"Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
|
|
4203
|
+
].join(" ");
|
|
4204
|
+
if (type === "discord") return [
|
|
4205
|
+
"Discord REST API.",
|
|
4206
|
+
"To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
|
|
4207
|
+
"Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
|
|
4208
|
+
].join(" ");
|
|
4209
|
+
if (type === "gh") return [
|
|
4210
|
+
"GitHub REST via gh CLI.",
|
|
4211
|
+
"To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
|
|
4212
|
+
"Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
|
|
4213
|
+
].join(" ");
|
|
3880
4214
|
return "Generic adapter call.";
|
|
3881
4215
|
};
|
|
3882
4216
|
//#endregion
|
|
3883
4217
|
//#region lib/engine/mcp/channel-server.ts
|
|
3884
4218
|
const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
|
|
4219
|
+
const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
|
|
4220
|
+
const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
|
|
4221
|
+
const readAllChannels = (dir) => {
|
|
4222
|
+
const settingsPath = join(dir, "settings.json");
|
|
4223
|
+
if (!existsSync(settingsPath)) return [];
|
|
4224
|
+
try {
|
|
4225
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
4226
|
+
const parsed = settingsSchema.safeParse(raw);
|
|
4227
|
+
if (!parsed.success) return [];
|
|
4228
|
+
return parsed.data.channels.map((c) => ({
|
|
4229
|
+
id: c.id,
|
|
4230
|
+
name: c.name
|
|
4231
|
+
}));
|
|
4232
|
+
} catch {
|
|
4233
|
+
return [];
|
|
4234
|
+
}
|
|
4235
|
+
};
|
|
3885
4236
|
const startChannelServer = async (options = {}) => {
|
|
3886
4237
|
const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
|
|
3887
4238
|
const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
|
|
@@ -3889,6 +4240,13 @@ const startChannelServer = async (options = {}) => {
|
|
|
3889
4240
|
const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
|
|
3890
4241
|
const channel = channelId ? readChannelConnectors(dir, channelId) : null;
|
|
3891
4242
|
const token = options.token ?? readGatewayToken(dir);
|
|
4243
|
+
const allChannels = readAllChannels(dir);
|
|
4244
|
+
const currentChannelName = channel?.channelName ?? null;
|
|
4245
|
+
const channelContext = allChannels.length > 0 ? [
|
|
4246
|
+
"",
|
|
4247
|
+
"Configured channels (use as the `channel` argument to fnl_debug):",
|
|
4248
|
+
...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
|
|
4249
|
+
].join("\n") : "";
|
|
3892
4250
|
const server = new Server({
|
|
3893
4251
|
name: FUNNEL_MCP_NAME,
|
|
3894
4252
|
version: "1.0.0"
|
|
@@ -3898,13 +4256,35 @@ const startChannelServer = async (options = {}) => {
|
|
|
3898
4256
|
tools: {}
|
|
3899
4257
|
},
|
|
3900
4258
|
instructions: [
|
|
3901
|
-
`Events arrive
|
|
4259
|
+
`Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
|
|
4260
|
+
` content — the event payload as a JSON string (parse it to read the message)`,
|
|
4261
|
+
` meta — key/value strings describing the event`,
|
|
4262
|
+
"",
|
|
4263
|
+
"meta fields by event_type:",
|
|
4264
|
+
" slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
|
|
4265
|
+
" gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
|
|
4266
|
+
" discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
|
|
4267
|
+
" schedule: event_type=schedule entry_id=…",
|
|
4268
|
+
"",
|
|
4269
|
+
"To reply to a Slack message in the same thread, call the connector tool with:",
|
|
4270
|
+
` method: POST`,
|
|
4271
|
+
` path: chat.postMessage`,
|
|
4272
|
+
` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
|
|
4273
|
+
"",
|
|
4274
|
+
"To comment on a GitHub issue/PR (extract from subject_url in meta):",
|
|
4275
|
+
` method: POST`,
|
|
4276
|
+
` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
|
|
4277
|
+
` body: { body: "your reply" }`,
|
|
3902
4278
|
"",
|
|
3903
|
-
"
|
|
4279
|
+
"Built-in diagnostic tools — call proactively when events seem missing or delayed:",
|
|
4280
|
+
" fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
|
|
4281
|
+
" fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
|
|
4282
|
+
" omit channel arg to diagnose all channels; check summary.suggestedActions first",
|
|
4283
|
+
channelContext
|
|
3904
4284
|
].join("\n")
|
|
3905
4285
|
});
|
|
3906
4286
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3907
|
-
|
|
4287
|
+
const connectorTools = (channel?.connectors ?? []).map((c) => ({
|
|
3908
4288
|
name: c.name,
|
|
3909
4289
|
description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
|
|
3910
4290
|
inputSchema: {
|
|
@@ -3925,17 +4305,42 @@ const startChannelServer = async (options = {}) => {
|
|
|
3925
4305
|
},
|
|
3926
4306
|
required: ["method", "path"]
|
|
3927
4307
|
}
|
|
3928
|
-
}))
|
|
4308
|
+
}));
|
|
4309
|
+
const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
|
|
4310
|
+
const builtinTools = [{
|
|
4311
|
+
name: "fnl_status",
|
|
4312
|
+
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.",
|
|
4313
|
+
inputSchema: {
|
|
4314
|
+
type: "object",
|
|
4315
|
+
properties: {}
|
|
4316
|
+
}
|
|
4317
|
+
}, {
|
|
4318
|
+
name: "fnl_debug",
|
|
4319
|
+
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.",
|
|
4320
|
+
inputSchema: {
|
|
4321
|
+
type: "object",
|
|
4322
|
+
properties: { channel: channelEnum ? {
|
|
4323
|
+
type: "string",
|
|
4324
|
+
description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
|
|
4325
|
+
enum: channelEnum
|
|
4326
|
+
} : {
|
|
4327
|
+
type: "string",
|
|
4328
|
+
description: "Channel name to inspect. Omit to get all channels."
|
|
4329
|
+
} }
|
|
4330
|
+
}
|
|
4331
|
+
}];
|
|
4332
|
+
return { tools: [...connectorTools, ...builtinTools] };
|
|
3929
4333
|
});
|
|
3930
4334
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4335
|
+
const toolName = request.params.name;
|
|
4336
|
+
if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
|
|
3931
4337
|
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
4338
|
const args = request.params.arguments ?? {};
|
|
3934
4339
|
const method = typeof args.method === "string" ? args.method : "";
|
|
3935
4340
|
const path = typeof args.path === "string" ? args.path : "";
|
|
3936
4341
|
const body = args.body ?? {};
|
|
3937
4342
|
if (!method || !path) throw new Error("`method` and `path` are required");
|
|
3938
|
-
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(
|
|
4343
|
+
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
|
|
3939
4344
|
const headers = { "content-type": "application/json" };
|
|
3940
4345
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
3941
4346
|
const res = await fetch(url, {
|
|
@@ -3963,6 +4368,51 @@ const startChannelServer = async (options = {}) => {
|
|
|
3963
4368
|
protocols: token ? [`funnel.token.${token}`] : void 0
|
|
3964
4369
|
}).start();
|
|
3965
4370
|
};
|
|
4371
|
+
const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
|
|
4372
|
+
const headers = {};
|
|
4373
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
4374
|
+
if (name === "fnl_status") {
|
|
4375
|
+
const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
|
|
4376
|
+
if (!res) return { content: [{
|
|
4377
|
+
type: "text",
|
|
4378
|
+
text: JSON.stringify({
|
|
4379
|
+
running: false,
|
|
4380
|
+
error: "gateway unreachable",
|
|
4381
|
+
hint: "run: fnl gateway start",
|
|
4382
|
+
knownChannels: allChannels.map((ch) => ch.name)
|
|
4383
|
+
})
|
|
4384
|
+
}] };
|
|
4385
|
+
const body = await res.json();
|
|
4386
|
+
return { content: [{
|
|
4387
|
+
type: "text",
|
|
4388
|
+
text: JSON.stringify(body)
|
|
4389
|
+
}] };
|
|
4390
|
+
}
|
|
4391
|
+
const channelArg = typeof args?.channel === "string" ? args.channel : null;
|
|
4392
|
+
const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
|
|
4393
|
+
const res = await fetch(url, { headers }).catch(() => null);
|
|
4394
|
+
if (!res) return { content: [{
|
|
4395
|
+
type: "text",
|
|
4396
|
+
text: JSON.stringify({
|
|
4397
|
+
gateway: { running: false },
|
|
4398
|
+
channels: allChannels.map((ch) => ({
|
|
4399
|
+
id: ch.id,
|
|
4400
|
+
name: ch.name,
|
|
4401
|
+
diagnosis: {
|
|
4402
|
+
status: "error",
|
|
4403
|
+
message: "gateway is not running",
|
|
4404
|
+
nextAction: "fnl gateway start",
|
|
4405
|
+
rootCause: null
|
|
4406
|
+
}
|
|
4407
|
+
}))
|
|
4408
|
+
})
|
|
4409
|
+
}] };
|
|
4410
|
+
const body = await res.json();
|
|
4411
|
+
return { content: [{
|
|
4412
|
+
type: "text",
|
|
4413
|
+
text: JSON.stringify(body)
|
|
4414
|
+
}] };
|
|
4415
|
+
};
|
|
3966
4416
|
//#endregion
|
|
3967
4417
|
//#region lib/engine/local-config/local-config-json-schema.ts
|
|
3968
4418
|
/**
|
|
@@ -4506,87 +4956,6 @@ const takeRecent = (events, limit) => {
|
|
|
4506
4956
|
return events.slice(-limit);
|
|
4507
4957
|
};
|
|
4508
4958
|
//#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
4959
|
//#region lib/cli/factory.ts
|
|
4591
4960
|
const factory = createFactory();
|
|
4592
4961
|
//#endregion
|
|
@@ -4705,7 +5074,18 @@ const queryToCliArgs = (url, reservedKeys = []) => {
|
|
|
4705
5074
|
return args;
|
|
4706
5075
|
};
|
|
4707
5076
|
//#endregion
|
|
4708
|
-
//#region lib/cli/
|
|
5077
|
+
//#region lib/cli/routes/channels.add.ts
|
|
5078
|
+
const help$17 = `funnel channels add — add a channel
|
|
5079
|
+
|
|
5080
|
+
usage: funnel channels add <name> [--delivery fanout|exclusive]
|
|
5081
|
+
|
|
5082
|
+
options:
|
|
5083
|
+
--delivery routing mode (default fanout):
|
|
5084
|
+
fanout every connected client receives every event
|
|
5085
|
+
exclusive each event delivered to exactly one client (round-robin)`;
|
|
5086
|
+
const channelsAddHelpHandler = factory.createHandlers((c) => c.text(help$17));
|
|
5087
|
+
//#endregion
|
|
5088
|
+
//#region lib/cli/router/validator.ts
|
|
4709
5089
|
const zValidator$1 = (target, schema, helpText) => zValidator(target, schema, (result, c) => {
|
|
4710
5090
|
if (helpText && c.req.query("help")) return c.text(helpText);
|
|
4711
5091
|
if (result.success) return;
|
|
@@ -4716,18 +5096,10 @@ const zValidator$1 = (target, schema, helpText) => zValidator(target, schema, (r
|
|
|
4716
5096
|
});
|
|
4717
5097
|
//#endregion
|
|
4718
5098
|
//#region lib/cli/routes/channels.add.$channel.ts
|
|
4719
|
-
const
|
|
4720
|
-
|
|
4721
|
-
usage: funnel channels add <name> [--delivery fanout|exclusive]
|
|
4722
|
-
|
|
4723
|
-
options:
|
|
4724
|
-
--delivery routing mode (default fanout):
|
|
4725
|
-
fanout every connected client receives every event
|
|
4726
|
-
exclusive each event delivered to exactly one client (round-robin)`;
|
|
4727
|
-
const channelsAddHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ delivery: channelDeliveryModeSchema.optional() }), addHelp$3), (c) => {
|
|
5099
|
+
const channelsAddHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ delivery: channelDeliveryModeSchema.optional() })), (c) => {
|
|
4728
5100
|
const param = c.req.valid("param");
|
|
4729
5101
|
const query = c.req.valid("query");
|
|
4730
|
-
const created = c.
|
|
5102
|
+
const created = c.env.funnel.channels.add({
|
|
4731
5103
|
name: param.channel,
|
|
4732
5104
|
delivery: query.delivery
|
|
4733
5105
|
});
|
|
@@ -4737,14 +5109,14 @@ const channelsConnectorsGroupHandler = factory.createHandlers(zValidator$1("para
|
|
|
4737
5109
|
|
|
4738
5110
|
usage: funnel channels <channel> connectors`), (c) => {
|
|
4739
5111
|
const param = c.req.valid("param");
|
|
4740
|
-
const channel = c.
|
|
5112
|
+
const channel = c.env.funnel.channels.get(param.channel);
|
|
4741
5113
|
if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
|
|
4742
5114
|
if (channel.connectors.length === 0) return c.text(`no connectors in channel "${channel.name}"`);
|
|
4743
5115
|
return c.text(channel.connectors.map((c) => `${c.name} (${c.type}, id: ${c.id})`).join("\n"));
|
|
4744
5116
|
});
|
|
4745
5117
|
//#endregion
|
|
4746
|
-
//#region lib/cli/routes/channels.$channel.connectors.add
|
|
4747
|
-
const
|
|
5118
|
+
//#region lib/cli/routes/channels.$channel.connectors.add.ts
|
|
5119
|
+
const help$16 = `funnel channels <channel> connectors add <connector> — add a connector to a channel
|
|
4748
5120
|
|
|
4749
5121
|
usage:
|
|
4750
5122
|
funnel channels <channel> connectors add <connector> --type=slack --bot-token=xoxb-... --app-token=xapp-...
|
|
@@ -4753,6 +5125,9 @@ usage:
|
|
|
4753
5125
|
funnel channels <channel> connectors add <connector> --type=schedule
|
|
4754
5126
|
|
|
4755
5127
|
Token uniqueness is enforced across all channels.`;
|
|
5128
|
+
const channelsConnectorsAddHelpHandler = factory.createHandlers((c) => c.text(help$16));
|
|
5129
|
+
//#endregion
|
|
5130
|
+
//#region lib/cli/routes/channels.$channel.connectors.add.$connector.ts
|
|
4756
5131
|
const slackBody = z.object({
|
|
4757
5132
|
type: z.literal("slack"),
|
|
4758
5133
|
"bot-token": z.string().startsWith("xoxb-"),
|
|
@@ -4776,10 +5151,10 @@ const addBody = z.discriminatedUnion("type", [
|
|
|
4776
5151
|
const channelsConnectorsAddHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4777
5152
|
channel: z.string(),
|
|
4778
5153
|
connector: z.string()
|
|
4779
|
-
})), zValidator$1("query", addBody
|
|
5154
|
+
})), zValidator$1("query", addBody), async (c) => {
|
|
4780
5155
|
const param = c.req.valid("param");
|
|
4781
5156
|
const query = c.req.valid("query");
|
|
4782
|
-
const funnel = c.
|
|
5157
|
+
const funnel = c.env.funnel;
|
|
4783
5158
|
if (query.type === "slack") {
|
|
4784
5159
|
const created = funnel.channels.addConnector(param.channel, {
|
|
4785
5160
|
type: "slack",
|
|
@@ -4817,28 +5192,34 @@ const channelsConnectorsAddHandler = factory.createHandlers(zValidator$1("param"
|
|
|
4817
5192
|
return c.text(`added schedule connector "${created.name}" to channel "${param.channel}"`);
|
|
4818
5193
|
});
|
|
4819
5194
|
//#endregion
|
|
4820
|
-
//#region lib/cli/routes/channels.$channel.connectors.remove
|
|
4821
|
-
const
|
|
5195
|
+
//#region lib/cli/routes/channels.$channel.connectors.remove.ts
|
|
5196
|
+
const help$15 = `funnel channels <channel> connectors remove <connector> — remove a connector
|
|
4822
5197
|
|
|
4823
5198
|
usage: funnel channels <channel> connectors remove <connector>`;
|
|
5199
|
+
const channelsConnectorsRemoveHelpHandler = factory.createHandlers((c) => c.text(help$15));
|
|
5200
|
+
//#endregion
|
|
5201
|
+
//#region lib/cli/routes/channels.$channel.connectors.remove.$connector.ts
|
|
4824
5202
|
const channelsConnectorsRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4825
5203
|
channel: z.string(),
|
|
4826
5204
|
connector: z.string()
|
|
4827
|
-
})), zValidator$1("query", z.object({})
|
|
5205
|
+
})), zValidator$1("query", z.object({})), async (c) => {
|
|
4828
5206
|
const param = c.req.valid("param");
|
|
4829
|
-
const funnel = c.
|
|
5207
|
+
const funnel = c.env.funnel;
|
|
4830
5208
|
await funnel.listeners.stop(param.channel, param.connector);
|
|
4831
5209
|
funnel.channels.removeConnector(param.channel, param.connector);
|
|
4832
5210
|
return c.text(`removed connector "${param.connector}" from channel "${param.channel}"`);
|
|
4833
5211
|
});
|
|
4834
5212
|
//#endregion
|
|
4835
|
-
//#region lib/cli/routes/channels.$channel.connectors.set
|
|
4836
|
-
const
|
|
5213
|
+
//#region lib/cli/routes/channels.$channel.connectors.set.ts
|
|
5214
|
+
const help$14 = `funnel channels <channel> connectors set <connector> — update connector fields
|
|
4837
5215
|
|
|
4838
5216
|
usage:
|
|
4839
5217
|
funnel channels <ch> connectors set <conn> [--bot-token=...] [--app-token=...] # slack
|
|
4840
5218
|
funnel channels <ch> connectors set <conn> [--bot-token=...] # discord
|
|
4841
5219
|
funnel channels <ch> connectors set <conn> [--poll-interval=N] # gh`;
|
|
5220
|
+
const channelsConnectorsSetHelpHandler = factory.createHandlers((c) => c.text(help$14));
|
|
5221
|
+
//#endregion
|
|
5222
|
+
//#region lib/cli/routes/channels.$channel.connectors.set.$connector.ts
|
|
4842
5223
|
const channelsConnectorsSetHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4843
5224
|
channel: z.string(),
|
|
4844
5225
|
connector: z.string()
|
|
@@ -4846,10 +5227,10 @@ const channelsConnectorsSetHandler = factory.createHandlers(zValidator$1("param"
|
|
|
4846
5227
|
"bot-token": z.string().optional(),
|
|
4847
5228
|
"app-token": z.string().optional(),
|
|
4848
5229
|
"poll-interval": z.coerce.number().int().positive().optional()
|
|
4849
|
-
}).passthrough()
|
|
5230
|
+
}).passthrough()), async (c) => {
|
|
4850
5231
|
const param = c.req.valid("param");
|
|
4851
5232
|
const query = c.req.valid("query");
|
|
4852
|
-
const funnel = c.
|
|
5233
|
+
const funnel = c.env.funnel;
|
|
4853
5234
|
const existing = funnel.channels.getConnector(param.channel, param.connector);
|
|
4854
5235
|
if (!existing) throw new HTTPException(404, { message: `connector "${param.connector}" not found in channel "${param.channel}"` });
|
|
4855
5236
|
if (existing.type === "slack") funnel.channels.updateSlackConnector(param.channel, param.connector, {
|
|
@@ -4869,27 +5250,36 @@ const channelsConnectorsShowHandler = factory.createHandlers(zValidator$1("param
|
|
|
4869
5250
|
|
|
4870
5251
|
usage: funnel channels <channel> connectors show <connector>`), (c) => {
|
|
4871
5252
|
const param = c.req.valid("param");
|
|
4872
|
-
const connector = c.
|
|
5253
|
+
const connector = c.env.funnel.channels.getConnector(param.channel, param.connector);
|
|
4873
5254
|
if (!connector) throw new HTTPException(404, { message: `connector "${param.connector}" not found in channel "${param.channel}"` });
|
|
4874
5255
|
return c.text(JSON.stringify(connector, null, 2));
|
|
4875
5256
|
});
|
|
4876
5257
|
//#endregion
|
|
4877
|
-
//#region lib/cli/routes/channels.$channel.connectors
|
|
4878
|
-
const
|
|
5258
|
+
//#region lib/cli/routes/channels.$channel.connectors.rename.ts
|
|
5259
|
+
const help$13 = `funnel channels <channel> connectors rename <connector> <new-name>
|
|
4879
5260
|
|
|
4880
5261
|
usage: funnel channels <channel> connectors rename <connector> <new-name>`;
|
|
5262
|
+
const channelsConnectorsRenameHelpHandler = factory.createHandlers((c) => c.text(help$13));
|
|
5263
|
+
//#endregion
|
|
5264
|
+
//#region lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts
|
|
4881
5265
|
const channelsConnectorsRenameHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4882
5266
|
channel: z.string(),
|
|
4883
5267
|
connector: z.string(),
|
|
4884
5268
|
newName: z.string()
|
|
4885
|
-
})), zValidator$1("query", z.object({})
|
|
5269
|
+
})), zValidator$1("query", z.object({})), async (c) => {
|
|
4886
5270
|
const param = c.req.valid("param");
|
|
4887
|
-
const funnel = c.
|
|
5271
|
+
const funnel = c.env.funnel;
|
|
4888
5272
|
await funnel.listeners.stop(param.channel, param.connector);
|
|
4889
5273
|
funnel.channels.renameConnector(param.channel, param.connector, param.newName);
|
|
4890
5274
|
await funnel.listeners.start(param.channel, param.newName);
|
|
4891
5275
|
return c.text(`renamed connector "${param.connector}" to "${param.newName}"`);
|
|
4892
5276
|
});
|
|
5277
|
+
//#endregion
|
|
5278
|
+
//#region lib/cli/routes/channels.$channel.connectors.$connector.rename.ts
|
|
5279
|
+
const help$12 = `funnel channels <channel> connectors rename <connector> <new-name>
|
|
5280
|
+
|
|
5281
|
+
usage: funnel channels <channel> connectors rename <connector> <new-name>`;
|
|
5282
|
+
const channelsConnectorRenameHelpHandler = factory.createHandlers((c) => c.text(help$12));
|
|
4893
5283
|
const channelsConnectorsRequestHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4894
5284
|
channel: z.string(),
|
|
4895
5285
|
connector: z.string()
|
|
@@ -4898,7 +5288,7 @@ const channelsConnectorsRequestHandler = factory.createHandlers(zValidator$1("pa
|
|
|
4898
5288
|
usage: funnel channels <channel> connectors <connector> request --method=<api.method> [--key=value ...]`), async (c) => {
|
|
4899
5289
|
const param = c.req.valid("param");
|
|
4900
5290
|
const query = c.req.valid("query");
|
|
4901
|
-
const funnel = c.
|
|
5291
|
+
const funnel = c.env.funnel;
|
|
4902
5292
|
const passthrough = {};
|
|
4903
5293
|
for (const [k, v] of new URL(c.req.url).searchParams) {
|
|
4904
5294
|
if (k === "method") continue;
|
|
@@ -4918,15 +5308,18 @@ const channelsConnectorsSchedulesGroupHandler = factory.createHandlers(zValidato
|
|
|
4918
5308
|
|
|
4919
5309
|
usage: funnel channels <ch> connectors <conn> schedules`), (c) => {
|
|
4920
5310
|
const param = c.req.valid("param");
|
|
4921
|
-
const entries = c.
|
|
5311
|
+
const entries = c.env.funnel.channels.listScheduleEntries(param.channel, param.connector);
|
|
4922
5312
|
if (entries.length === 0) return c.text("no schedule entries");
|
|
4923
5313
|
return c.text(entries.map((e) => `${e.id}\t${e.cron}\t${e.enabled ? "on" : "off"}\t${e.prompt}`).join("\n"));
|
|
4924
5314
|
});
|
|
4925
5315
|
//#endregion
|
|
4926
|
-
//#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.add
|
|
4927
|
-
const
|
|
5316
|
+
//#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.ts
|
|
5317
|
+
const help$11 = `funnel channels <ch> connectors <conn> schedules add <id> — add a schedule entry
|
|
4928
5318
|
|
|
4929
5319
|
usage: funnel channels <ch> connectors <conn> schedules add <id> --cron="*/5 * * * *" --prompt="..." [--enabled=true] [--catchup-policy=latest|all|skip]`;
|
|
5320
|
+
const channelsConnectorSchedulesAddHelpHandler = factory.createHandlers((c) => c.text(help$11));
|
|
5321
|
+
//#endregion
|
|
5322
|
+
//#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts
|
|
4930
5323
|
const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4931
5324
|
channel: z.string(),
|
|
4932
5325
|
connector: z.string(),
|
|
@@ -4936,10 +5329,10 @@ const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$
|
|
|
4936
5329
|
prompt: z.string(),
|
|
4937
5330
|
enabled: z.coerce.boolean().optional(),
|
|
4938
5331
|
"catchup-policy": scheduleCatchupPolicySchema.optional()
|
|
4939
|
-
})
|
|
5332
|
+
})), async (c) => {
|
|
4940
5333
|
const param = c.req.valid("param");
|
|
4941
5334
|
const query = c.req.valid("query");
|
|
4942
|
-
const funnel = c.
|
|
5335
|
+
const funnel = c.env.funnel;
|
|
4943
5336
|
const entry = funnel.channels.addScheduleEntry(param.channel, param.connector, {
|
|
4944
5337
|
id: param.id,
|
|
4945
5338
|
cron: query.cron,
|
|
@@ -4951,24 +5344,27 @@ const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$
|
|
|
4951
5344
|
return c.text(`added schedule entry "${entry.id}"`);
|
|
4952
5345
|
});
|
|
4953
5346
|
//#endregion
|
|
4954
|
-
//#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove
|
|
4955
|
-
const
|
|
5347
|
+
//#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.ts
|
|
5348
|
+
const help$10 = `funnel channels <ch> connectors <conn> schedules remove <id>
|
|
4956
5349
|
|
|
4957
5350
|
usage: funnel channels <ch> connectors <conn> schedules remove <id>`;
|
|
5351
|
+
const channelsConnectorSchedulesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$10));
|
|
5352
|
+
//#endregion
|
|
5353
|
+
//#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts
|
|
4958
5354
|
const channelsConnectorsSchedulesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
4959
5355
|
channel: z.string(),
|
|
4960
5356
|
connector: z.string(),
|
|
4961
5357
|
id: z.string()
|
|
4962
|
-
})), zValidator$1("query", z.object({})
|
|
5358
|
+
})), zValidator$1("query", z.object({})), async (c) => {
|
|
4963
5359
|
const param = c.req.valid("param");
|
|
4964
|
-
const funnel = c.
|
|
5360
|
+
const funnel = c.env.funnel;
|
|
4965
5361
|
funnel.channels.removeScheduleEntry(param.channel, param.connector, param.id);
|
|
4966
5362
|
await funnel.listeners.restart(param.channel, param.connector);
|
|
4967
5363
|
return c.text(`removed schedule entry "${param.id}"`);
|
|
4968
5364
|
});
|
|
4969
5365
|
//#endregion
|
|
4970
|
-
//#region lib/cli/routes/channels
|
|
4971
|
-
const
|
|
5366
|
+
//#region lib/cli/routes/channels.publish.ts
|
|
5367
|
+
const help$9 = `funnel channels <channel> publish — push arbitrary content into a channel
|
|
4972
5368
|
|
|
4973
5369
|
usage: funnel channels <channel> publish --content="<text>" [--connector=<name>] [--meta-<key>=<value> ...]
|
|
4974
5370
|
|
|
@@ -4976,14 +5372,17 @@ options:
|
|
|
4976
5372
|
--content Required. The event body delivered to subscribers.
|
|
4977
5373
|
--connector Optional. Stamp the event with a connector name (resolved to id when found).
|
|
4978
5374
|
--meta-<key> Optional. Repeatable. Added to meta. Example: --meta-source=cron`;
|
|
5375
|
+
const channelsPublishHelpHandler = factory.createHandlers((c) => c.text(help$9));
|
|
5376
|
+
//#endregion
|
|
5377
|
+
//#region lib/cli/routes/channels.$channel.publish.ts
|
|
4979
5378
|
const querySchema = z.object({
|
|
4980
5379
|
content: z.string().min(1, { message: "--content is required" }),
|
|
4981
5380
|
connector: z.string().min(1).optional()
|
|
4982
5381
|
}).passthrough();
|
|
4983
|
-
const channelsPublishHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", querySchema
|
|
5382
|
+
const channelsPublishHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", querySchema), async (c) => {
|
|
4984
5383
|
const param = c.req.valid("param");
|
|
4985
5384
|
const query = c.req.valid("query");
|
|
4986
|
-
const funnel = c.
|
|
5385
|
+
const funnel = c.env.funnel;
|
|
4987
5386
|
const meta = {};
|
|
4988
5387
|
for (const [k, v] of new URL(c.req.url).searchParams) if (k.startsWith("meta-")) meta[k.slice(5)] = v;
|
|
4989
5388
|
const result = await funnel.publisher.publish(param.channel, {
|
|
@@ -4996,28 +5395,42 @@ const channelsPublishHandler = factory.createHandlers(zValidator$1("param", z.ob
|
|
|
4996
5395
|
return c.text(`published (offset=${result.offset})`);
|
|
4997
5396
|
});
|
|
4998
5397
|
//#endregion
|
|
4999
|
-
//#region lib/cli/routes/channels.remove
|
|
5000
|
-
const
|
|
5398
|
+
//#region lib/cli/routes/channels.remove.ts
|
|
5399
|
+
const help$8 = `funnel channels remove — remove a channel
|
|
5001
5400
|
|
|
5002
5401
|
usage: funnel channels remove <name>`;
|
|
5003
|
-
const
|
|
5402
|
+
const channelsRemoveHelpHandler = factory.createHandlers((c) => c.text(help$8));
|
|
5403
|
+
//#endregion
|
|
5404
|
+
//#region lib/cli/routes/channels.remove.$channel.ts
|
|
5405
|
+
const channelsRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({})), (c) => {
|
|
5004
5406
|
const param = c.req.valid("param");
|
|
5005
|
-
c.
|
|
5407
|
+
c.env.funnel.channels.remove(param.channel);
|
|
5006
5408
|
return c.text(`removed channel "${param.channel}"`);
|
|
5007
5409
|
});
|
|
5008
5410
|
//#endregion
|
|
5009
|
-
//#region lib/cli/routes/channels
|
|
5010
|
-
const
|
|
5411
|
+
//#region lib/cli/routes/channels.rename.ts
|
|
5412
|
+
const help$7 = `funnel channels rename — rename a channel
|
|
5011
5413
|
|
|
5012
5414
|
usage:
|
|
5013
5415
|
funnel channels rename <old> <new>
|
|
5014
5416
|
funnel channels <old> rename <new>`;
|
|
5417
|
+
const channelsRenameHelpHandler = factory.createHandlers((c) => c.text(help$7));
|
|
5418
|
+
//#endregion
|
|
5419
|
+
//#region lib/cli/routes/channels.$channel.rename.ts
|
|
5420
|
+
const help$6 = `funnel channels rename — rename a channel
|
|
5421
|
+
|
|
5422
|
+
usage:
|
|
5423
|
+
funnel channels rename <old> <new>
|
|
5424
|
+
funnel channels <old> rename <new>`;
|
|
5425
|
+
const channelsChannelRenameHelpHandler = factory.createHandlers((c) => c.text(help$6));
|
|
5426
|
+
//#endregion
|
|
5427
|
+
//#region lib/cli/routes/channels.$channel.rename.$newName.ts
|
|
5015
5428
|
const channelsRenameHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
5016
5429
|
channel: z.string(),
|
|
5017
5430
|
newName: z.string()
|
|
5018
|
-
})), zValidator$1("query", z.object({})
|
|
5431
|
+
})), zValidator$1("query", z.object({})), (c) => {
|
|
5019
5432
|
const param = c.req.valid("param");
|
|
5020
|
-
c.
|
|
5433
|
+
c.env.funnel.channels.rename(param.channel, param.newName);
|
|
5021
5434
|
return c.text(`renamed channel "${param.channel}" to "${param.newName}"`);
|
|
5022
5435
|
});
|
|
5023
5436
|
const channelsSetDeliveryHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
@@ -5034,12 +5447,12 @@ modes:
|
|
|
5034
5447
|
tap=all clients (TUI dashboard, debugging) always receive regardless of mode.
|
|
5035
5448
|
`), (c) => {
|
|
5036
5449
|
const param = c.req.valid("param");
|
|
5037
|
-
c.
|
|
5450
|
+
c.env.funnel.channels.setDelivery(param.channel, param.mode);
|
|
5038
5451
|
return c.text(`channel "${param.channel}" delivery set to ${param.mode}`);
|
|
5039
5452
|
});
|
|
5040
5453
|
const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({}), `funnel channels <name> — show channel details`), (c) => {
|
|
5041
5454
|
const param = c.req.valid("param");
|
|
5042
|
-
const channel = c.
|
|
5455
|
+
const channel = c.env.funnel.channels.get(param.channel);
|
|
5043
5456
|
if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
|
|
5044
5457
|
const connectorLines = channel.connectors.length ? channel.connectors.map((c) => ` - ${c.name} (${c.type}, id: ${c.id})`) : [" (none)"];
|
|
5045
5458
|
const lines = [
|
|
@@ -5051,9 +5464,16 @@ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.objec
|
|
|
5051
5464
|
];
|
|
5052
5465
|
return c.text(lines.join("\n"));
|
|
5053
5466
|
});
|
|
5054
|
-
const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
5467
|
+
const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
|
|
5468
|
+
"true",
|
|
5469
|
+
"false",
|
|
5470
|
+
""
|
|
5471
|
+
]).optional() }), `funnel channels — manage subscription boxes
|
|
5472
|
+
|
|
5473
|
+
usage: funnel channels [--json]
|
|
5055
5474
|
|
|
5056
|
-
|
|
5475
|
+
options:
|
|
5476
|
+
--json output as JSON array (machine-readable, useful for Claude)
|
|
5057
5477
|
|
|
5058
5478
|
subcommands:
|
|
5059
5479
|
(none) list
|
|
@@ -5064,19 +5484,132 @@ subcommands:
|
|
|
5064
5484
|
<name> connectors add <c> --type=... add a connector
|
|
5065
5485
|
|
|
5066
5486
|
examples:
|
|
5487
|
+
funnel channels
|
|
5488
|
+
funnel channels --json
|
|
5067
5489
|
funnel channels add prod-inbox
|
|
5068
5490
|
funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
|
|
5069
5491
|
funnel channels prod-inbox`), (c) => {
|
|
5070
|
-
const
|
|
5492
|
+
const query = c.req.valid("query");
|
|
5493
|
+
const channels = c.env.funnel.channels.list();
|
|
5494
|
+
if (query.json === "true" || query.json === "") return c.json(channels.map((ch) => ({
|
|
5495
|
+
id: ch.id,
|
|
5496
|
+
name: ch.name,
|
|
5497
|
+
delivery: ch.delivery,
|
|
5498
|
+
connectors: ch.connectors.map((conn) => ({
|
|
5499
|
+
id: conn.id,
|
|
5500
|
+
name: conn.name,
|
|
5501
|
+
type: conn.type
|
|
5502
|
+
}))
|
|
5503
|
+
})));
|
|
5071
5504
|
if (channels.length === 0) return c.text("no channels");
|
|
5072
5505
|
const lines = channels.map((ch) => {
|
|
5073
|
-
const names = ch.connectors.map((
|
|
5506
|
+
const names = ch.connectors.map((conn) => conn.name);
|
|
5074
5507
|
const connectors = names.length > 0 ? names.join(", ") : "(none)";
|
|
5075
5508
|
return `${ch.name} [${connectors}]`;
|
|
5076
5509
|
});
|
|
5077
5510
|
return c.text(lines.join("\n"));
|
|
5078
5511
|
});
|
|
5079
5512
|
//#endregion
|
|
5513
|
+
//#region lib/cli/routes/channels.validate.ts
|
|
5514
|
+
const help$5 = `funnel channels <channel> validate — check connector configuration
|
|
5515
|
+
|
|
5516
|
+
usage: funnel channels <channel> validate [--json]
|
|
5517
|
+
|
|
5518
|
+
options:
|
|
5519
|
+
--json output as JSON
|
|
5520
|
+
|
|
5521
|
+
Checks that each connector has the required tokens and fields set.
|
|
5522
|
+
Does not make any network calls — static config check only.
|
|
5523
|
+
|
|
5524
|
+
examples:
|
|
5525
|
+
funnel channels open-karte validate
|
|
5526
|
+
funnel channels open-karte validate --json`;
|
|
5527
|
+
const channelsValidateHelpHandler = factory.createHandlers((c) => c.text(help$5));
|
|
5528
|
+
//#endregion
|
|
5529
|
+
//#region lib/cli/routes/channels.$channel.validate.ts
|
|
5530
|
+
const validateConnector = (connector) => {
|
|
5531
|
+
const issues = [];
|
|
5532
|
+
if (connector.type === "slack") {
|
|
5533
|
+
if (!(connector.botToken || connector.botTokenEnv)) issues.push({
|
|
5534
|
+
connector: connector.name,
|
|
5535
|
+
field: "botToken",
|
|
5536
|
+
message: "missing botToken (xoxb-...) or botTokenEnv"
|
|
5537
|
+
});
|
|
5538
|
+
if (!(connector.appToken || connector.appTokenEnv)) issues.push({
|
|
5539
|
+
connector: connector.name,
|
|
5540
|
+
field: "appToken",
|
|
5541
|
+
message: "missing appToken (xapp-...) or appTokenEnv"
|
|
5542
|
+
});
|
|
5543
|
+
if (connector.botToken && typeof connector.botToken === "string" && !connector.botToken.startsWith("xoxb-")) issues.push({
|
|
5544
|
+
connector: connector.name,
|
|
5545
|
+
field: "botToken",
|
|
5546
|
+
message: `botToken must start with xoxb- (got: ${connector.botToken.slice(0, 8)}...)`
|
|
5547
|
+
});
|
|
5548
|
+
if (connector.appToken && typeof connector.appToken === "string" && !connector.appToken.startsWith("xapp-")) issues.push({
|
|
5549
|
+
connector: connector.name,
|
|
5550
|
+
field: "appToken",
|
|
5551
|
+
message: `appToken must start with xapp- (got: ${connector.appToken.slice(0, 8)}...)`
|
|
5552
|
+
});
|
|
5553
|
+
}
|
|
5554
|
+
if (connector.type === "gh") {
|
|
5555
|
+
if (!(connector.token || connector.tokenEnv)) issues.push({
|
|
5556
|
+
connector: connector.name,
|
|
5557
|
+
field: "token",
|
|
5558
|
+
message: "missing token or tokenEnv for GitHub connector"
|
|
5559
|
+
});
|
|
5560
|
+
if (!connector.repo) issues.push({
|
|
5561
|
+
connector: connector.name,
|
|
5562
|
+
field: "repo",
|
|
5563
|
+
message: "missing repo (expected owner/repo format)"
|
|
5564
|
+
});
|
|
5565
|
+
}
|
|
5566
|
+
if (connector.type === "discord") {
|
|
5567
|
+
if (!(connector.botToken || connector.botTokenEnv)) issues.push({
|
|
5568
|
+
connector: connector.name,
|
|
5569
|
+
field: "botToken",
|
|
5570
|
+
message: "missing botToken or botTokenEnv for Discord connector"
|
|
5571
|
+
});
|
|
5572
|
+
}
|
|
5573
|
+
return issues;
|
|
5574
|
+
};
|
|
5575
|
+
const channelsValidateHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ json: z.enum([
|
|
5576
|
+
"true",
|
|
5577
|
+
"false",
|
|
5578
|
+
""
|
|
5579
|
+
]).optional() })), (c) => {
|
|
5580
|
+
const param = c.req.valid("param");
|
|
5581
|
+
const query = c.req.valid("query");
|
|
5582
|
+
const funnel = c.env.funnel;
|
|
5583
|
+
const isJson = query.json === "true" || query.json === "";
|
|
5584
|
+
const channel = funnel.channels.get(param.channel);
|
|
5585
|
+
if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
|
|
5586
|
+
if (channel.connectors.length === 0) {
|
|
5587
|
+
if (isJson) return c.json({
|
|
5588
|
+
channel: channel.name,
|
|
5589
|
+
valid: false,
|
|
5590
|
+
issues: [{
|
|
5591
|
+
connector: "(none)",
|
|
5592
|
+
field: "connectors",
|
|
5593
|
+
message: "no connectors configured"
|
|
5594
|
+
}]
|
|
5595
|
+
});
|
|
5596
|
+
return c.text(`⚠ ${channel.name}: no connectors configured`);
|
|
5597
|
+
}
|
|
5598
|
+
const allIssues = [];
|
|
5599
|
+
for (const connector of channel.connectors) {
|
|
5600
|
+
const issues = validateConnector(connector);
|
|
5601
|
+
allIssues.push(...issues);
|
|
5602
|
+
}
|
|
5603
|
+
if (isJson) return c.json({
|
|
5604
|
+
channel: channel.name,
|
|
5605
|
+
valid: allIssues.length === 0,
|
|
5606
|
+
issues: allIssues
|
|
5607
|
+
});
|
|
5608
|
+
if (allIssues.length === 0) return c.text(`✓ ${channel.name}: all connectors valid`);
|
|
5609
|
+
const lines = allIssues.map((issue) => `✗ ${channel.name}/${issue.connector}: ${issue.message}`);
|
|
5610
|
+
return c.text(lines.join("\n"));
|
|
5611
|
+
});
|
|
5612
|
+
//#endregion
|
|
5080
5613
|
//#region lib/cli/routes/claude.ts
|
|
5081
5614
|
const claudeHelp = `funnel claude — launch Claude Code
|
|
5082
5615
|
|
|
@@ -5108,7 +5641,7 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
5108
5641
|
channel: z.string().optional()
|
|
5109
5642
|
}).passthrough(), claudeHelp), async (c) => {
|
|
5110
5643
|
const query = c.req.valid("query");
|
|
5111
|
-
const funnel = c.
|
|
5644
|
+
const funnel = c.env.funnel;
|
|
5112
5645
|
const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
|
|
5113
5646
|
if (query.channel && !query.profile) {
|
|
5114
5647
|
const exitCode = await funnel.claude.launch({
|
|
@@ -5161,12 +5694,697 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
5161
5694
|
process.exit(exitCode);
|
|
5162
5695
|
});
|
|
5163
5696
|
//#endregion
|
|
5697
|
+
//#region lib/cli/routes/debug-row.ts
|
|
5698
|
+
const stringOrNull = (value) => typeof value === "string" && value.length > 0 ? value : null;
|
|
5699
|
+
const numberOrNull = (value) => typeof value === "number" ? value : null;
|
|
5700
|
+
const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
|
|
5701
|
+
/**
|
|
5702
|
+
* Parse a payload string as a JSON object. Returns null for non-strings,
|
|
5703
|
+
* malformed JSON, or any non-object JSON (arrays, primitives) — the callers
|
|
5704
|
+
* only ever want the object form.
|
|
5705
|
+
*/
|
|
5706
|
+
const parsePayloadObject = (payload) => {
|
|
5707
|
+
if (payload === null) return null;
|
|
5708
|
+
try {
|
|
5709
|
+
const parsed = JSON.parse(payload);
|
|
5710
|
+
if (isStringKeyedObject(parsed)) return parsed;
|
|
5711
|
+
} catch {
|
|
5712
|
+
return null;
|
|
5713
|
+
}
|
|
5714
|
+
return null;
|
|
5715
|
+
};
|
|
5716
|
+
const isStringKeyedObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5717
|
+
const truncate = (text, max) => text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
5718
|
+
/**
|
|
5719
|
+
* A short human preview of a payload: the `text` field when the payload is a
|
|
5720
|
+
* JSON object that has one, otherwise the raw payload, both truncated.
|
|
5721
|
+
*/
|
|
5722
|
+
const previewOf = (payload) => {
|
|
5723
|
+
if (typeof payload !== "string" || payload.length === 0) return null;
|
|
5724
|
+
const parsed = parsePayloadObject(payload);
|
|
5725
|
+
if (parsed !== null && "text" in parsed) return truncate(String(parsed.text), 60);
|
|
5726
|
+
return truncate(payload, 60);
|
|
5727
|
+
};
|
|
5728
|
+
/** Narrow one processed-table row into a `DebugEvent`. */
|
|
5729
|
+
const toDebugEvent = (row) => {
|
|
5730
|
+
const payload = stringOrNull(row.payload);
|
|
5731
|
+
return {
|
|
5732
|
+
seq: numberOrNull(row.seq),
|
|
5733
|
+
ts: numberOrNull(row.ts),
|
|
5734
|
+
type: stringOr(row.type, "?"),
|
|
5735
|
+
outcome: stringOr(row.outcome, "?"),
|
|
5736
|
+
eventId: stringOrNull(row.event_id),
|
|
5737
|
+
payload,
|
|
5738
|
+
payloadParsed: parsePayloadObject(payload),
|
|
5739
|
+
preview: previewOf(row.payload)
|
|
5740
|
+
};
|
|
5741
|
+
};
|
|
5742
|
+
/** Narrow one connection-table row into a `DebugConnectionError`. */
|
|
5743
|
+
const toDebugConnectionError = (row) => ({
|
|
5744
|
+
seq: numberOrNull(row.seq),
|
|
5745
|
+
ts: numberOrNull(row.ts),
|
|
5746
|
+
type: stringOr(row.type, "?"),
|
|
5747
|
+
status: stringOr(row.status, "?"),
|
|
5748
|
+
detail: stringOrNull(row.detail)
|
|
5749
|
+
});
|
|
5750
|
+
/**
|
|
5751
|
+
* Open a reader, run one query, and always close — the shared shape behind
|
|
5752
|
+
* every diagnostic lookup. Returns the rows or the reader's `Error`.
|
|
5753
|
+
*/
|
|
5754
|
+
const queryRows = (reader, sql, params) => {
|
|
5755
|
+
try {
|
|
5756
|
+
return reader.query(sql, params);
|
|
5757
|
+
} finally {
|
|
5758
|
+
reader.close();
|
|
5759
|
+
}
|
|
5760
|
+
};
|
|
5761
|
+
//#endregion
|
|
5762
|
+
//#region lib/cli/routes/debug.ts
|
|
5763
|
+
const debugHelp = `funnel debug — diagnose why Claude is not receiving events
|
|
5764
|
+
|
|
5765
|
+
usage: funnel debug [subcommand] [--channel <name>] [--all] [--json]
|
|
5766
|
+
|
|
5767
|
+
subcommands:
|
|
5768
|
+
(none) full diagnosis (gateway + listener + Claude + last 5 events)
|
|
5769
|
+
events last N processed events with outcome and preview
|
|
5770
|
+
dropped events filtered out (skip:*) with payload detail
|
|
5771
|
+
errors listener connection errors (auth-failed, error)
|
|
5772
|
+
|
|
5773
|
+
options:
|
|
5774
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5775
|
+
--all diagnose all channels at once (JSON output)
|
|
5776
|
+
--limit <N> number of recent events to include (default: 5; events/dropped/errors default: 20)
|
|
5777
|
+
--json output as JSON (machine-readable, useful for Claude)
|
|
5778
|
+
|
|
5779
|
+
when a listener is dead the diagnosis includes rootCause — the most recent
|
|
5780
|
+
connection error detail pulled from the connection log automatically.
|
|
5781
|
+
|
|
5782
|
+
use --json when asking Claude to analyse the output — it returns structured
|
|
5783
|
+
data that Claude can parse without guessing at text formatting.
|
|
5784
|
+
|
|
5785
|
+
examples:
|
|
5786
|
+
funnel debug
|
|
5787
|
+
funnel debug --all --json
|
|
5788
|
+
funnel debug --channel open-karte
|
|
5789
|
+
funnel debug --channel open-karte --json
|
|
5790
|
+
funnel debug events --channel open-karte --limit 50
|
|
5791
|
+
funnel debug dropped --channel open-karte --json
|
|
5792
|
+
funnel debug errors`;
|
|
5793
|
+
const debugEventsHelp = `funnel debug events — last N processed events with outcome and preview
|
|
5794
|
+
|
|
5795
|
+
usage: funnel debug events [--channel <name>] [--limit <N>] [--json]
|
|
5796
|
+
|
|
5797
|
+
options:
|
|
5798
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5799
|
+
--limit <N> number of rows (default: 20)
|
|
5800
|
+
--json output as JSON
|
|
5801
|
+
|
|
5802
|
+
examples:
|
|
5803
|
+
funnel debug events
|
|
5804
|
+
funnel debug events --channel open-karte --limit 50
|
|
5805
|
+
funnel debug events --json`;
|
|
5806
|
+
const debugDroppedHelp = `funnel debug dropped — events filtered out (skip:*)
|
|
5807
|
+
|
|
5808
|
+
usage: funnel debug dropped [--channel <name>] [--limit <N>] [--json]
|
|
5809
|
+
|
|
5810
|
+
options:
|
|
5811
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5812
|
+
--limit <N> number of rows (default: 20)
|
|
5813
|
+
--json output as JSON
|
|
5814
|
+
|
|
5815
|
+
shows why events were skipped: skip:type, skip:subtype, skip:dedup,
|
|
5816
|
+
skip:self-user, skip:self-bot, skip:preprocess
|
|
5817
|
+
|
|
5818
|
+
examples:
|
|
5819
|
+
funnel debug dropped
|
|
5820
|
+
funnel debug dropped --channel open-karte --json`;
|
|
5821
|
+
const debugErrorsHelp = `funnel debug errors — listener connection errors
|
|
5822
|
+
|
|
5823
|
+
usage: funnel debug errors [--channel <name>] [--limit <N>] [--json]
|
|
5824
|
+
|
|
5825
|
+
options:
|
|
5826
|
+
--channel <name> channel to inspect (auto-selected when only one exists)
|
|
5827
|
+
--limit <N> number of rows (default: 20)
|
|
5828
|
+
--json output as JSON
|
|
5829
|
+
|
|
5830
|
+
shows auth-failed and error events from the connection lifecycle log.
|
|
5831
|
+
use this when a listener never connects or keeps disconnecting.
|
|
5832
|
+
|
|
5833
|
+
examples:
|
|
5834
|
+
funnel debug errors
|
|
5835
|
+
funnel debug errors --channel open-karte`;
|
|
5836
|
+
const isGatewayStatusResponse = (value) => {
|
|
5837
|
+
if (value === null || typeof value !== "object") return false;
|
|
5838
|
+
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
5839
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
5840
|
+
return true;
|
|
5841
|
+
};
|
|
5842
|
+
const formatUptime = (ms) => {
|
|
5843
|
+
const sec = Math.floor(ms / 1e3);
|
|
5844
|
+
const min = Math.floor(sec / 60);
|
|
5845
|
+
if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`;
|
|
5846
|
+
if (sec >= 60) return `${min}m ${sec % 60}s`;
|
|
5847
|
+
return `${sec}s`;
|
|
5848
|
+
};
|
|
5849
|
+
const formatTs = (epochMs) => {
|
|
5850
|
+
if (typeof epochMs !== "number") return "?";
|
|
5851
|
+
return new Date(epochMs).toISOString().slice(11, 19);
|
|
5852
|
+
};
|
|
5853
|
+
const buildDiagnosis = (report) => {
|
|
5854
|
+
const rootCause = (report.connectionErrors[report.connectionErrors.length - 1] ?? null)?.detail ?? null;
|
|
5855
|
+
if (!report.gateway.running) return {
|
|
5856
|
+
status: "error",
|
|
5857
|
+
message: "gateway is not running",
|
|
5858
|
+
nextActions: ["fnl gateway start"],
|
|
5859
|
+
rootCause: null
|
|
5860
|
+
};
|
|
5861
|
+
const channel = report.channel;
|
|
5862
|
+
if (!(report.listeners.length > 0)) return {
|
|
5863
|
+
status: "warn",
|
|
5864
|
+
message: "no connectors configured on this channel",
|
|
5865
|
+
nextActions: [`fnl channels ${channel} connectors add <name> --type=slack ...`],
|
|
5866
|
+
rootCause: null
|
|
5867
|
+
};
|
|
5868
|
+
const allDead = report.listeners.every((l) => !l.alive);
|
|
5869
|
+
const someDead = report.listeners.some((l) => !l.alive);
|
|
5870
|
+
if (allDead) return {
|
|
5871
|
+
status: "error",
|
|
5872
|
+
message: "all listeners are dead",
|
|
5873
|
+
nextActions: ["fnl gateway logs", "fnl gateway restart"],
|
|
5874
|
+
rootCause
|
|
5875
|
+
};
|
|
5876
|
+
if (someDead) return {
|
|
5877
|
+
status: "warn",
|
|
5878
|
+
message: "some listeners are dead",
|
|
5879
|
+
nextActions: ["fnl gateway logs"],
|
|
5880
|
+
rootCause
|
|
5881
|
+
};
|
|
5882
|
+
const hasErrors = report.listeners.some((l) => l.errors > 0);
|
|
5883
|
+
if (report.claudeClients === 0) return {
|
|
5884
|
+
status: "warn",
|
|
5885
|
+
message: "no Claude connected to this channel",
|
|
5886
|
+
nextActions: [`fnl claude --channel ${channel}`],
|
|
5887
|
+
rootCause: null
|
|
5888
|
+
};
|
|
5889
|
+
if (hasErrors) return {
|
|
5890
|
+
status: "warn",
|
|
5891
|
+
message: "listeners have errors",
|
|
5892
|
+
nextActions: ["fnl gateway logs"],
|
|
5893
|
+
rootCause
|
|
5894
|
+
};
|
|
5895
|
+
return {
|
|
5896
|
+
status: "ok",
|
|
5897
|
+
message: "everything looks healthy",
|
|
5898
|
+
nextActions: [],
|
|
5899
|
+
rootCause: null
|
|
5900
|
+
};
|
|
5901
|
+
};
|
|
5902
|
+
const renderText = (report) => {
|
|
5903
|
+
const lines = [];
|
|
5904
|
+
lines.push(`= funnel debug: ${report.channel} =`);
|
|
5905
|
+
lines.push("");
|
|
5906
|
+
const gw = report.gateway;
|
|
5907
|
+
if (!gw.running) lines.push("[gateway] ○ not running");
|
|
5908
|
+
else {
|
|
5909
|
+
const uptime = gw.uptimeMs !== null ? ` · up ${formatUptime(gw.uptimeMs)}` : "";
|
|
5910
|
+
lines.push(`[gateway] ● running pid ${gw.pid} · port ${gw.port}${uptime}`);
|
|
5911
|
+
}
|
|
5912
|
+
if (report.listeners.length === 0) lines.push("[listener] - no listener");
|
|
5913
|
+
else for (const listener of report.listeners) {
|
|
5914
|
+
const indicator = listener.alive ? "●" : "○";
|
|
5915
|
+
const state = listener.alive ? "alive " : "dead ";
|
|
5916
|
+
const eventsStr = `${listener.events} events`;
|
|
5917
|
+
const lastStr = listener.lastEventAt ? ` · last ${listener.lastEventAt.slice(11, 19)}` : "";
|
|
5918
|
+
const errStr = listener.errors > 0 ? ` · ⚠ ${listener.errors} errors` : "";
|
|
5919
|
+
lines.push(`[listener] ${indicator} ${state} ${eventsStr}${lastStr}${errStr}`);
|
|
5920
|
+
}
|
|
5921
|
+
const claudeCount = report.claudeClients;
|
|
5922
|
+
if (claudeCount === 0) lines.push("[claude] ○ not connected");
|
|
5923
|
+
else lines.push(`[claude] ● connected (${claudeCount} WS client${claudeCount > 1 ? "s" : ""})`);
|
|
5924
|
+
if (report.recentEvents.length === 0) lines.push("[events] no events recorded");
|
|
5925
|
+
else {
|
|
5926
|
+
lines.push(`[events] last ${report.recentEvents.length} event${report.recentEvents.length > 1 ? "s" : ""}:`);
|
|
5927
|
+
for (const event of report.recentEvents) {
|
|
5928
|
+
const time = formatTs(event.ts);
|
|
5929
|
+
const type = event.type.padEnd(8);
|
|
5930
|
+
const outcome = event.outcome.padEnd(20);
|
|
5931
|
+
const preview = event.preview ? ` "${event.preview}"` : "";
|
|
5932
|
+
const seq = event.seq !== null ? ` (seq=${event.seq})` : "";
|
|
5933
|
+
lines.push(` ${time} ${type} ${outcome}${preview}${seq}`);
|
|
5934
|
+
}
|
|
5935
|
+
}
|
|
5936
|
+
if (report.connectionErrors.length > 0) {
|
|
5937
|
+
lines.push("[conn errs] recent connection errors:");
|
|
5938
|
+
for (const err of report.connectionErrors) {
|
|
5939
|
+
const time = formatTs(err.ts);
|
|
5940
|
+
const status = err.status.padEnd(14);
|
|
5941
|
+
const detail = err.detail ? ` "${err.detail}"` : "";
|
|
5942
|
+
lines.push(` ${time} ${err.type.padEnd(8)} ${status}${detail}`);
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5945
|
+
lines.push("");
|
|
5946
|
+
const diag = report.diagnosis;
|
|
5947
|
+
const icon = diag.status === "ok" ? "✓" : diag.status === "warn" ? "⚠" : "✗";
|
|
5948
|
+
lines.push(`diagnosis: ${icon} ${diag.message}`);
|
|
5949
|
+
if (diag.rootCause) lines.push(` root cause: ${diag.rootCause}`);
|
|
5950
|
+
for (const action of diag.nextActions) lines.push(` → ${action}`);
|
|
5951
|
+
return lines.join("\n");
|
|
5952
|
+
};
|
|
5953
|
+
const resolveStoreOrNull = () => {
|
|
5954
|
+
const tmpDir = funnelTmpDir();
|
|
5955
|
+
const rawPath = join(tmpDir, "connector-raw.db");
|
|
5956
|
+
const processedPath = join(tmpDir, "connector-processed.db");
|
|
5957
|
+
const connectionPath = join(tmpDir, "connector-connection.db");
|
|
5958
|
+
if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return null;
|
|
5959
|
+
return {
|
|
5960
|
+
rawPath,
|
|
5961
|
+
processedPath,
|
|
5962
|
+
connectionPath
|
|
5963
|
+
};
|
|
5964
|
+
};
|
|
5965
|
+
/**
|
|
5966
|
+
* Resolve the connector name for a connector id on a channel, used to attribute
|
|
5967
|
+
* a replayed event back to its source connector. Returns undefined when the id
|
|
5968
|
+
* is null or no longer present (connectors can be removed after an event was
|
|
5969
|
+
* logged).
|
|
5970
|
+
*/
|
|
5971
|
+
const connectorOf = (channel, connectorId) => {
|
|
5972
|
+
if (connectorId === null) return void 0;
|
|
5973
|
+
return channel.connectors?.find((connector) => connector.id === connectorId)?.name;
|
|
5974
|
+
};
|
|
5975
|
+
const resolveChannelId = (channels, channelName) => {
|
|
5976
|
+
if (channelName) {
|
|
5977
|
+
const match = channels.find((ch) => ch.name === channelName);
|
|
5978
|
+
if (match) return {
|
|
5979
|
+
found: true,
|
|
5980
|
+
channel: match
|
|
5981
|
+
};
|
|
5982
|
+
return {
|
|
5983
|
+
found: false,
|
|
5984
|
+
reason: "not-found",
|
|
5985
|
+
name: channelName
|
|
5986
|
+
};
|
|
5987
|
+
}
|
|
5988
|
+
if (channels.length === 1 && channels[0]) return {
|
|
5989
|
+
found: true,
|
|
5990
|
+
channel: channels[0]
|
|
5991
|
+
};
|
|
5992
|
+
if (channels.length === 0) return {
|
|
5993
|
+
found: false,
|
|
5994
|
+
reason: "none"
|
|
5995
|
+
};
|
|
5996
|
+
return {
|
|
5997
|
+
found: false,
|
|
5998
|
+
reason: "ambiguous",
|
|
5999
|
+
names: channels.map((ch) => ch.name)
|
|
6000
|
+
};
|
|
6001
|
+
};
|
|
6002
|
+
const debugEventsHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6003
|
+
channel: z.string().optional(),
|
|
6004
|
+
limit: z.string().optional(),
|
|
6005
|
+
json: z.enum([
|
|
6006
|
+
"true",
|
|
6007
|
+
"false",
|
|
6008
|
+
""
|
|
6009
|
+
]).optional()
|
|
6010
|
+
}), debugEventsHelp), async (c) => {
|
|
6011
|
+
const query = c.req.valid("query");
|
|
6012
|
+
const channels = c.env.funnel.channels.list();
|
|
6013
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6014
|
+
const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
|
|
6015
|
+
const store = resolveStoreOrNull();
|
|
6016
|
+
if (!store) {
|
|
6017
|
+
if (isJson) return c.json([]);
|
|
6018
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
6019
|
+
}
|
|
6020
|
+
const resolved = resolveChannelId(channels, query.channel);
|
|
6021
|
+
if (!resolved.found) {
|
|
6022
|
+
if (resolved.reason === "not-found") {
|
|
6023
|
+
if (isJson) return c.json({
|
|
6024
|
+
error: `channel not found: ${resolved.name}`,
|
|
6025
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6026
|
+
});
|
|
6027
|
+
return c.text(`channel not found: ${resolved.name}`);
|
|
6028
|
+
}
|
|
6029
|
+
if (resolved.reason === "ambiguous") {
|
|
6030
|
+
if (isJson) return c.json({
|
|
6031
|
+
error: "multiple channels — specify one with --channel",
|
|
6032
|
+
channels: resolved.names
|
|
6033
|
+
});
|
|
6034
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6035
|
+
}
|
|
6036
|
+
if (isJson) return c.json([]);
|
|
6037
|
+
return c.text("no channels configured");
|
|
6038
|
+
}
|
|
6039
|
+
const channel = resolved.channel;
|
|
6040
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
6041
|
+
const rows = channel ? queryRows(reader, "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [channel.id, limit]) : queryRows(reader, "SELECT seq, ts, type, outcome, payload FROM processed ORDER BY seq DESC LIMIT ?", [limit]);
|
|
6042
|
+
if (rows instanceof Error) return c.text(`error: ${rows.message}`);
|
|
6043
|
+
const events = rows.reverse().map(toDebugEvent);
|
|
6044
|
+
if (isJson) return c.json(events);
|
|
6045
|
+
if (events.length === 0) return c.text("no events recorded");
|
|
6046
|
+
const lines = events.map((ev) => {
|
|
6047
|
+
const time = formatTs(ev.ts);
|
|
6048
|
+
const type = ev.type.padEnd(8);
|
|
6049
|
+
const outcome = ev.outcome.padEnd(20);
|
|
6050
|
+
const preview = ev.preview ? ` "${ev.preview}"` : "";
|
|
6051
|
+
return `${time} ${type} ${outcome}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${preview}`;
|
|
6052
|
+
});
|
|
6053
|
+
return c.text(lines.join("\n"));
|
|
6054
|
+
});
|
|
6055
|
+
const debugDroppedHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6056
|
+
channel: z.string().optional(),
|
|
6057
|
+
limit: z.string().optional(),
|
|
6058
|
+
json: z.enum([
|
|
6059
|
+
"true",
|
|
6060
|
+
"false",
|
|
6061
|
+
""
|
|
6062
|
+
]).optional()
|
|
6063
|
+
}), debugDroppedHelp), async (c) => {
|
|
6064
|
+
const query = c.req.valid("query");
|
|
6065
|
+
const channels = c.env.funnel.channels.list();
|
|
6066
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6067
|
+
const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
|
|
6068
|
+
const store = resolveStoreOrNull();
|
|
6069
|
+
if (!store) {
|
|
6070
|
+
if (isJson) return c.json([]);
|
|
6071
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
6072
|
+
}
|
|
6073
|
+
const resolvedDropped = resolveChannelId(channels, query.channel);
|
|
6074
|
+
if (!resolvedDropped.found) {
|
|
6075
|
+
if (resolvedDropped.reason === "not-found") {
|
|
6076
|
+
if (isJson) return c.json({
|
|
6077
|
+
error: `channel not found: ${resolvedDropped.name}`,
|
|
6078
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6079
|
+
});
|
|
6080
|
+
return c.text(`channel not found: ${resolvedDropped.name}`);
|
|
6081
|
+
}
|
|
6082
|
+
if (resolvedDropped.reason === "ambiguous") {
|
|
6083
|
+
if (isJson) return c.json({
|
|
6084
|
+
error: "multiple channels — specify one with --channel",
|
|
6085
|
+
channels: resolvedDropped.names
|
|
6086
|
+
});
|
|
6087
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolvedDropped.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6088
|
+
}
|
|
6089
|
+
if (isJson) return c.json([]);
|
|
6090
|
+
return c.text("no channels configured");
|
|
6091
|
+
}
|
|
6092
|
+
const channel = resolvedDropped.channel;
|
|
6093
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
6094
|
+
const rows = channel ? queryRows(reader, "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]) : queryRows(reader, "SELECT seq, ts, type, outcome, payload, event_id FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT ?", [limit]);
|
|
6095
|
+
if (rows instanceof Error) return c.text(`error: ${rows.message}`);
|
|
6096
|
+
const events = rows.reverse().map(toDebugEvent);
|
|
6097
|
+
if (isJson) return c.json(events);
|
|
6098
|
+
if (events.length === 0) return c.text("no dropped events recorded");
|
|
6099
|
+
const lines = events.map((ev) => {
|
|
6100
|
+
return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.outcome.padEnd(20)}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${ev.eventId ? ` event_id=${ev.eventId.slice(0, 8)}` : ""}${ev.preview ? ` "${ev.preview}"` : ""}`;
|
|
6101
|
+
});
|
|
6102
|
+
return c.text(lines.join("\n"));
|
|
6103
|
+
});
|
|
6104
|
+
const debugErrorsHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6105
|
+
channel: z.string().optional(),
|
|
6106
|
+
limit: z.string().optional(),
|
|
6107
|
+
json: z.enum([
|
|
6108
|
+
"true",
|
|
6109
|
+
"false",
|
|
6110
|
+
""
|
|
6111
|
+
]).optional()
|
|
6112
|
+
}), debugErrorsHelp), async (c) => {
|
|
6113
|
+
const query = c.req.valid("query");
|
|
6114
|
+
const channels = c.env.funnel.channels.list();
|
|
6115
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6116
|
+
const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
|
|
6117
|
+
const store = resolveStoreOrNull();
|
|
6118
|
+
if (!store) {
|
|
6119
|
+
if (isJson) return c.json([]);
|
|
6120
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
6121
|
+
}
|
|
6122
|
+
const resolvedErrors = resolveChannelId(channels, query.channel);
|
|
6123
|
+
if (!resolvedErrors.found) {
|
|
6124
|
+
if (resolvedErrors.reason === "not-found") {
|
|
6125
|
+
if (isJson) return c.json({
|
|
6126
|
+
error: `channel not found: ${resolvedErrors.name}`,
|
|
6127
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6128
|
+
});
|
|
6129
|
+
return c.text(`channel not found: ${resolvedErrors.name}`);
|
|
6130
|
+
}
|
|
6131
|
+
if (resolvedErrors.reason === "ambiguous") {
|
|
6132
|
+
if (isJson) return c.json({
|
|
6133
|
+
error: "multiple channels — specify one with --channel",
|
|
6134
|
+
channels: resolvedErrors.names
|
|
6135
|
+
});
|
|
6136
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolvedErrors.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6137
|
+
}
|
|
6138
|
+
if (isJson) return c.json([]);
|
|
6139
|
+
return c.text("no channels configured");
|
|
6140
|
+
}
|
|
6141
|
+
const channel = resolvedErrors.channel;
|
|
6142
|
+
const reader = new ConnectorDiagnosticSqlReader(store);
|
|
6143
|
+
const rows = channel ? queryRows(reader, "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]) : queryRows(reader, "SELECT seq, ts, type, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [limit]);
|
|
6144
|
+
if (rows instanceof Error) return c.text(`error: ${rows.message}`);
|
|
6145
|
+
const errors = rows.reverse().map(toDebugConnectionError);
|
|
6146
|
+
if (isJson) return c.json(errors);
|
|
6147
|
+
if (errors.length === 0) return c.text("no connection errors recorded");
|
|
6148
|
+
const lines = errors.map((ev) => {
|
|
6149
|
+
return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.status.padEnd(16)}${ev.detail ? ` "${ev.detail}"` : ""}`;
|
|
6150
|
+
});
|
|
6151
|
+
return c.text(lines.join("\n"));
|
|
6152
|
+
});
|
|
6153
|
+
const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNull, store, limit = 5) => {
|
|
6154
|
+
const targetChannelName = targetChannel.name;
|
|
6155
|
+
const baseReport = {
|
|
6156
|
+
channel: targetChannelName,
|
|
6157
|
+
channelId: targetChannel.id,
|
|
6158
|
+
gateway: {
|
|
6159
|
+
running: gatewayStatus.running,
|
|
6160
|
+
pid: gatewayStatus.pid,
|
|
6161
|
+
port: gatewayStatus.running ? gatewayStatus.port : null,
|
|
6162
|
+
uptimeMs: gatewayBodyOrNull?.uptimeMs ?? null
|
|
6163
|
+
},
|
|
6164
|
+
listeners: [],
|
|
6165
|
+
claudeClients: 0,
|
|
6166
|
+
recentEvents: [],
|
|
6167
|
+
connectionErrors: []
|
|
6168
|
+
};
|
|
6169
|
+
if (gatewayBodyOrNull) {
|
|
6170
|
+
baseReport.listeners = gatewayBodyOrNull.listeners.filter((l) => l.channelName === targetChannelName).map((l) => ({
|
|
6171
|
+
name: l.name,
|
|
6172
|
+
type: l.type,
|
|
6173
|
+
alive: l.alive,
|
|
6174
|
+
events: l.events,
|
|
6175
|
+
errors: l.errors,
|
|
6176
|
+
lastEventAt: l.lastEventAt
|
|
6177
|
+
}));
|
|
6178
|
+
baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => !cl.tapAll && cl.channelName === targetChannelName).length;
|
|
6179
|
+
}
|
|
6180
|
+
if (store) {
|
|
6181
|
+
const evRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [targetChannel.id, limit]);
|
|
6182
|
+
if (!(evRows instanceof Error)) baseReport.recentEvents = evRows.reverse().map(toDebugEvent);
|
|
6183
|
+
const hasDeadListeners = baseReport.listeners.some((l) => !l.alive);
|
|
6184
|
+
const hasListenerErrors = baseReport.listeners.some((l) => l.errors > 0);
|
|
6185
|
+
if (hasDeadListeners || hasListenerErrors) {
|
|
6186
|
+
const errRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [targetChannel.id]);
|
|
6187
|
+
if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map(toDebugConnectionError);
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
return {
|
|
6191
|
+
...baseReport,
|
|
6192
|
+
diagnosis: buildDiagnosis(baseReport)
|
|
6193
|
+
};
|
|
6194
|
+
};
|
|
6195
|
+
const debugHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6196
|
+
channel: z.string().optional(),
|
|
6197
|
+
all: z.enum([
|
|
6198
|
+
"true",
|
|
6199
|
+
"false",
|
|
6200
|
+
""
|
|
6201
|
+
]).optional(),
|
|
6202
|
+
json: z.enum([
|
|
6203
|
+
"true",
|
|
6204
|
+
"false",
|
|
6205
|
+
""
|
|
6206
|
+
]).optional(),
|
|
6207
|
+
limit: z.string().optional()
|
|
6208
|
+
}), debugHelp), async (c) => {
|
|
6209
|
+
const query = c.req.valid("query");
|
|
6210
|
+
const funnel = c.env.funnel;
|
|
6211
|
+
const channels = funnel.channels.list();
|
|
6212
|
+
const gatewayStatus = funnel.gateway.getStatus();
|
|
6213
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6214
|
+
const isAll = query.all === "true" || query.all === "";
|
|
6215
|
+
const eventLimit = query.limit ? Math.max(1, Number(query.limit)) : 5;
|
|
6216
|
+
if (channels.length === 0) {
|
|
6217
|
+
if (isJson) return c.json({
|
|
6218
|
+
error: "no channels configured",
|
|
6219
|
+
nextAction: "fnl channels add <name>"
|
|
6220
|
+
});
|
|
6221
|
+
return c.text("no channels configured — run: fnl channels add <name>");
|
|
6222
|
+
}
|
|
6223
|
+
const token = funnel.gatewayToken.read();
|
|
6224
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
6225
|
+
let gatewayBodyOrNull = null;
|
|
6226
|
+
if (gatewayStatus.running) {
|
|
6227
|
+
const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`, { headers }).catch(() => null);
|
|
6228
|
+
if (res && res.ok) {
|
|
6229
|
+
const body = await res.json();
|
|
6230
|
+
if (isGatewayStatusResponse(body)) gatewayBodyOrNull = body;
|
|
6231
|
+
}
|
|
6232
|
+
}
|
|
6233
|
+
const store = resolveStoreOrNull();
|
|
6234
|
+
if (isAll) {
|
|
6235
|
+
const reports = await Promise.all(channels.map((ch) => buildChannelReport(ch, gatewayStatus, gatewayBodyOrNull, store, eventLimit)));
|
|
6236
|
+
const errorChannels = reports.filter((r) => r.diagnosis.status === "error").map((r) => r.channel);
|
|
6237
|
+
const warnChannels = reports.filter((r) => r.diagnosis.status === "warn").map((r) => r.channel);
|
|
6238
|
+
const okChannels = reports.filter((r) => r.diagnosis.status === "ok").map((r) => r.channel);
|
|
6239
|
+
const uniqueActions = [...new Set(reports.flatMap((r) => r.diagnosis.nextActions))];
|
|
6240
|
+
return c.json({
|
|
6241
|
+
summary: {
|
|
6242
|
+
total: reports.length,
|
|
6243
|
+
ok: okChannels.length,
|
|
6244
|
+
warn: warnChannels.length,
|
|
6245
|
+
error: errorChannels.length,
|
|
6246
|
+
criticalChannels: errorChannels,
|
|
6247
|
+
warnChannels,
|
|
6248
|
+
suggestedActions: uniqueActions
|
|
6249
|
+
},
|
|
6250
|
+
channels: reports
|
|
6251
|
+
});
|
|
6252
|
+
}
|
|
6253
|
+
let targetChannel = null;
|
|
6254
|
+
if (query.channel) {
|
|
6255
|
+
targetChannel = channels.find((ch) => ch.name === query.channel) ?? null;
|
|
6256
|
+
if (!targetChannel) {
|
|
6257
|
+
if (isJson) return c.json({
|
|
6258
|
+
error: `channel not found: ${query.channel}`,
|
|
6259
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6260
|
+
});
|
|
6261
|
+
return c.text(`channel not found: ${query.channel}`);
|
|
6262
|
+
}
|
|
6263
|
+
} else if (channels.length === 1 && channels[0]) targetChannel = channels[0];
|
|
6264
|
+
else {
|
|
6265
|
+
const names = channels.map((ch) => ch.name);
|
|
6266
|
+
if (isJson) return c.json({
|
|
6267
|
+
error: "multiple channels — specify one with --channel or use --all",
|
|
6268
|
+
channels: names,
|
|
6269
|
+
hint: "use --all for all channels at once"
|
|
6270
|
+
});
|
|
6271
|
+
return c.text(`multiple channels — specify one with --channel or use --all:\n${names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6272
|
+
}
|
|
6273
|
+
const report = await buildChannelReport(targetChannel, gatewayStatus, gatewayBodyOrNull, store, eventLimit);
|
|
6274
|
+
if (isJson) return c.json(report);
|
|
6275
|
+
return c.text(renderText(report));
|
|
6276
|
+
});
|
|
6277
|
+
const debugReplayHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6278
|
+
channel: z.string().optional(),
|
|
6279
|
+
seq: z.string().optional(),
|
|
6280
|
+
json: z.enum([
|
|
6281
|
+
"true",
|
|
6282
|
+
"false",
|
|
6283
|
+
""
|
|
6284
|
+
]).optional()
|
|
6285
|
+
}), `funnel debug replay — re-publish a past event into a channel
|
|
6286
|
+
|
|
6287
|
+
usage: funnel debug replay --channel <name> [--seq <N>] [--json]
|
|
6288
|
+
|
|
6289
|
+
options:
|
|
6290
|
+
--channel <name> channel to replay into (required when multiple channels exist)
|
|
6291
|
+
--seq <N> replay the event at this processed-table seq (default: most recent emitted)
|
|
6292
|
+
--json output result as JSON
|
|
6293
|
+
|
|
6294
|
+
Re-sends a past event from the diagnostic store through the publisher path,
|
|
6295
|
+
so subscribers receive it again. Useful to verify that Claude handles an event
|
|
6296
|
+
correctly without waiting for a real external trigger.
|
|
6297
|
+
|
|
6298
|
+
Gateway must be running. The event is injected via POST /channels/<name>/publish.
|
|
6299
|
+
|
|
6300
|
+
examples:
|
|
6301
|
+
fnl debug replay --channel open-karte
|
|
6302
|
+
fnl debug replay --channel open-karte --seq 412
|
|
6303
|
+
fnl debug replay --channel open-karte --json`), async (c) => {
|
|
6304
|
+
const query = c.req.valid("query");
|
|
6305
|
+
const funnel = c.env.funnel;
|
|
6306
|
+
const channels = funnel.channels.list();
|
|
6307
|
+
const isJson = query.json === "true" || query.json === "";
|
|
6308
|
+
const resolved = resolveChannelId(channels, query.channel);
|
|
6309
|
+
if (!resolved.found) {
|
|
6310
|
+
if (resolved.reason === "not-found") {
|
|
6311
|
+
if (isJson) return c.json({
|
|
6312
|
+
error: `channel not found: ${resolved.name}`,
|
|
6313
|
+
availableChannels: channels.map((ch) => ch.name)
|
|
6314
|
+
});
|
|
6315
|
+
return c.text(`channel not found: ${resolved.name}`);
|
|
6316
|
+
}
|
|
6317
|
+
if (resolved.reason === "ambiguous") {
|
|
6318
|
+
if (isJson) return c.json({
|
|
6319
|
+
error: "multiple channels — specify one with --channel",
|
|
6320
|
+
channels: resolved.names
|
|
6321
|
+
});
|
|
6322
|
+
return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
|
|
6323
|
+
}
|
|
6324
|
+
if (isJson) return c.json({ error: "no channels configured" });
|
|
6325
|
+
return c.text("no channels configured");
|
|
6326
|
+
}
|
|
6327
|
+
const targetChannel = resolved.channel;
|
|
6328
|
+
const store = resolveStoreOrNull();
|
|
6329
|
+
if (!store) {
|
|
6330
|
+
if (isJson) return c.json({ error: "no diagnostic store yet (start the gateway first)" });
|
|
6331
|
+
return c.text("no diagnostic store yet (start the gateway first)");
|
|
6332
|
+
}
|
|
6333
|
+
const rows = query.seq ? queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, event_id, type, payload, connector_id, channel_id FROM processed WHERE channel_id = ? AND seq = ? LIMIT 1", [targetChannel.id, Number(query.seq)]) : queryRows(new ConnectorDiagnosticSqlReader(store), "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]);
|
|
6334
|
+
if (rows instanceof Error) {
|
|
6335
|
+
if (isJson) return c.json({ error: rows.message });
|
|
6336
|
+
return c.text(`error: ${rows.message}`);
|
|
6337
|
+
}
|
|
6338
|
+
const firstRow = rows[0];
|
|
6339
|
+
if (!firstRow) {
|
|
6340
|
+
if (isJson) return c.json({ error: "no matching event found" });
|
|
6341
|
+
return c.text("no matching event found");
|
|
6342
|
+
}
|
|
6343
|
+
const seq = typeof firstRow.seq === "number" ? firstRow.seq : null;
|
|
6344
|
+
const eventId = typeof firstRow.event_id === "string" ? firstRow.event_id : null;
|
|
6345
|
+
const connectorId = typeof firstRow.connector_id === "string" ? firstRow.connector_id : null;
|
|
6346
|
+
let content = typeof firstRow.payload === "string" ? firstRow.payload : null;
|
|
6347
|
+
if ((!content || content.length === 0) && eventId) {
|
|
6348
|
+
const rawRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT payload FROM raw WHERE event_id = ? LIMIT 1", [eventId]);
|
|
6349
|
+
const rawRow = rawRows instanceof Error ? null : rawRows[0];
|
|
6350
|
+
if (rawRow) content = typeof rawRow.payload === "string" ? rawRow.payload : null;
|
|
6351
|
+
}
|
|
6352
|
+
if (!content) {
|
|
6353
|
+
if (isJson) return c.json({ error: "event has no payload to replay" });
|
|
6354
|
+
return c.text("event has no payload to replay");
|
|
6355
|
+
}
|
|
6356
|
+
const connectorName = connectorOf(targetChannel, connectorId);
|
|
6357
|
+
const result = await funnel.publisher.publish(targetChannel.name, {
|
|
6358
|
+
content,
|
|
6359
|
+
connector: connectorName
|
|
6360
|
+
});
|
|
6361
|
+
if (result.state === "offline") {
|
|
6362
|
+
if (isJson) return c.json({
|
|
6363
|
+
error: "gateway daemon is not running",
|
|
6364
|
+
nextAction: "fnl gateway start"
|
|
6365
|
+
});
|
|
6366
|
+
return c.text("error: gateway daemon is not running — run: fnl gateway start");
|
|
6367
|
+
}
|
|
6368
|
+
if (result.state === "error") {
|
|
6369
|
+
if (isJson) return c.json({ error: result.reason });
|
|
6370
|
+
return c.text(`error: ${result.reason}`);
|
|
6371
|
+
}
|
|
6372
|
+
const preview = previewOf(content);
|
|
6373
|
+
if (isJson) return c.json({
|
|
6374
|
+
replayed: true,
|
|
6375
|
+
seq,
|
|
6376
|
+
offset: result.offset,
|
|
6377
|
+
preview
|
|
6378
|
+
});
|
|
6379
|
+
return c.text(`replayed seq=${seq ?? "?"} → offset=${result.offset}${preview ? ` "${preview}"` : ""}`);
|
|
6380
|
+
});
|
|
6381
|
+
//#endregion
|
|
5164
6382
|
//#region lib/cli/routes/gateway.ts
|
|
5165
6383
|
const groupHelp$1 = `funnel gateway — manage the funnel daemon
|
|
5166
6384
|
|
|
5167
|
-
The gateway daemon hosts the WebSocket /ws (used by Claude MCP)
|
|
5168
|
-
|
|
5169
|
-
|
|
6385
|
+
The gateway daemon hosts the WebSocket /ws (used by Claude MCP) and the
|
|
6386
|
+
listener supervisor that runs every connector. One daemon, one port (9743
|
|
6387
|
+
for the CLI, 9742 for programmatic use), one PID file.
|
|
5170
6388
|
|
|
5171
6389
|
usage: funnel gateway [subcommand]
|
|
5172
6390
|
|
|
@@ -5177,20 +6395,49 @@ subcommands:
|
|
|
5177
6395
|
restart stop then start
|
|
5178
6396
|
run start in foreground (for developers)
|
|
5179
6397
|
logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
|
|
5180
|
-
sql
|
|
6398
|
+
sql query inbound connector traffic (raw + processed verdict)
|
|
5181
6399
|
listeners list running connector listeners (alive / dead)
|
|
5182
6400
|
|
|
5183
6401
|
examples:
|
|
5184
|
-
funnel gateway
|
|
5185
|
-
funnel gateway restart
|
|
6402
|
+
funnel gateway check status
|
|
6403
|
+
funnel gateway restart restart after config changes
|
|
6404
|
+
funnel gateway logs stream the daemon log
|
|
6405
|
+
funnel gateway sql --preset recent inspect last 20 inbound events
|
|
6406
|
+
|
|
6407
|
+
see also: fnl debug --channel <name> (higher-level diagnosis with next-action hints)`;
|
|
5186
6408
|
const renderGatewayStatus = async (c) => {
|
|
5187
|
-
const status = c.
|
|
6409
|
+
const status = c.env.funnel.gateway.getStatus();
|
|
5188
6410
|
if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
|
|
5189
|
-
const res = await fetch(`http://127.0.0.1:${status.port}/
|
|
6411
|
+
const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
|
|
5190
6412
|
if (!res) return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`);
|
|
5191
|
-
const
|
|
5192
|
-
const
|
|
5193
|
-
|
|
6413
|
+
const data = await res.json();
|
|
6414
|
+
const lines = [];
|
|
6415
|
+
lines.push(`funnel gateway: running (pid ${data.pid})`);
|
|
6416
|
+
lines.push(` port: ${status.port}`);
|
|
6417
|
+
const uptimeSec = Math.floor(data.uptimeMs / 1e3);
|
|
6418
|
+
const uptimeMin = Math.floor(uptimeSec / 60);
|
|
6419
|
+
const uptimeStr = uptimeMin >= 60 ? `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? `${uptimeMin}m ${uptimeSec % 60}s` : `${uptimeSec}s`;
|
|
6420
|
+
lines.push(` uptime: ${uptimeStr}`);
|
|
6421
|
+
lines.push(` events: ${data.broadcaster.eventsBroadcast} broadcast`);
|
|
6422
|
+
if (data.listeners.length === 0) lines.push(` listeners: none`);
|
|
6423
|
+
else {
|
|
6424
|
+
lines.push(` listeners:`);
|
|
6425
|
+
for (const l of data.listeners) {
|
|
6426
|
+
const indicator = l.alive ? "●" : "○";
|
|
6427
|
+
const eventsStr = l.events > 0 ? ` (${l.events} events)` : "";
|
|
6428
|
+
const errStr = l.errors > 0 ? ` ⚠ ${l.errors} errors` : "";
|
|
6429
|
+
lines.push(` ${indicator} ${l.channelName}/${l.name} [${l.type}]${eventsStr}${errStr}`);
|
|
6430
|
+
}
|
|
6431
|
+
}
|
|
6432
|
+
if (data.clients.length === 0) lines.push(` clients: none`);
|
|
6433
|
+
else {
|
|
6434
|
+
lines.push(` clients: ${data.clients.length}`);
|
|
6435
|
+
for (const cl of data.clients) {
|
|
6436
|
+
const connectors = cl.connectors.length > 0 ? ` → ${cl.connectors.join(", ")}` : "";
|
|
6437
|
+
lines.push(` · ${cl.channel}${connectors}`);
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
return c.text(lines.join("\n"));
|
|
5194
6441
|
};
|
|
5195
6442
|
const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
|
|
5196
6443
|
const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners — show running connector listeners
|
|
@@ -5201,7 +6448,7 @@ Reads /listeners from the running gateway daemon and prints the live registry.
|
|
|
5201
6448
|
|
|
5202
6449
|
examples:
|
|
5203
6450
|
funnel gateway listeners`), async (c) => {
|
|
5204
|
-
const result = await c.
|
|
6451
|
+
const result = await c.env.funnel.listeners.list();
|
|
5205
6452
|
if (result.state === "offline") throw new HTTPException(503, { message: "funnel gateway: not running" });
|
|
5206
6453
|
if (result.state === "error") throw new HTTPException(503, { message: `funnel gateway: ${result.reason}` });
|
|
5207
6454
|
if (result.listeners.length === 0) return c.text("funnel gateway: no running listeners");
|
|
@@ -5212,24 +6459,31 @@ examples:
|
|
|
5212
6459
|
});
|
|
5213
6460
|
//#endregion
|
|
5214
6461
|
//#region lib/cli/routes/gateway.logs.ts
|
|
5215
|
-
const logsHelp = `funnel gateway logs — tail diagnostic
|
|
6462
|
+
const logsHelp = `funnel gateway logs — tail the daemon diagnostic log
|
|
5216
6463
|
|
|
5217
|
-
usage: funnel gateway logs [-n <N>]
|
|
6464
|
+
usage: funnel gateway logs [-n <N>] [--format <plain|json>]
|
|
5218
6465
|
|
|
5219
6466
|
options:
|
|
5220
6467
|
-n <N> number of trailing lines to show (default: 20)
|
|
6468
|
+
--format <plain|json> output format (default: plain)
|
|
5221
6469
|
|
|
5222
|
-
|
|
5223
|
-
lifecycle,
|
|
5224
|
-
|
|
6470
|
+
Streams ${join(funnelTmpDir(), "funnel.log")} — the daemon's diagnostic stream covering
|
|
6471
|
+
gateway lifecycle, listener start/stop/error, and WebSocket connect/disconnect.
|
|
6472
|
+
Exit with Ctrl-C.
|
|
5225
6473
|
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
6474
|
+
plain format: HH:MM:SS LEVEL message key=value ...
|
|
6475
|
+
json format: raw JSON lines (pipe to jq for filtering)
|
|
6476
|
+
|
|
6477
|
+
This log does NOT contain inbound Slack/connector events. For those, use:
|
|
6478
|
+
fnl gateway sql --preset recent last 20 processed events
|
|
6479
|
+
fnl debug --channel <name> per-channel diagnosis with outcome summary
|
|
5229
6480
|
|
|
5230
6481
|
examples:
|
|
5231
6482
|
funnel gateway logs
|
|
5232
|
-
funnel gateway logs -n 100
|
|
6483
|
+
funnel gateway logs -n 100
|
|
6484
|
+
funnel gateway logs --format json | jq 'select(.level == "error")'
|
|
6485
|
+
|
|
6486
|
+
see also: fnl debug, fnl gateway sql`;
|
|
5233
6487
|
const logger = new NodeFunnelLogger();
|
|
5234
6488
|
const tryParseJson = (line) => {
|
|
5235
6489
|
try {
|
|
@@ -5245,11 +6499,27 @@ const isLogEntry = (value) => {
|
|
|
5245
6499
|
if (!("message" in value) || typeof value.message !== "string") return false;
|
|
5246
6500
|
return true;
|
|
5247
6501
|
};
|
|
5248
|
-
const
|
|
6502
|
+
const formatMetaValue = (value) => {
|
|
6503
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
6504
|
+
return str.includes(" ") ? `"${str}"` : str;
|
|
6505
|
+
};
|
|
6506
|
+
const formatMeta = (meta) => {
|
|
6507
|
+
if (meta === null || typeof meta !== "object") return "";
|
|
6508
|
+
const pairs = Object.entries(meta).map(([k, v]) => `${k}=${formatMetaValue(v)}`).join(" ");
|
|
6509
|
+
return pairs ? ` ${pairs}` : "";
|
|
6510
|
+
};
|
|
6511
|
+
const formatPlain = (entry) => {
|
|
6512
|
+
return `${entry.time.slice(11, 19)} ${entry.level.toUpperCase().padEnd(5)} ${entry.message.padEnd(30)}${formatMeta(entry.meta)}\n`;
|
|
6513
|
+
};
|
|
6514
|
+
const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6515
|
+
n: z.string().optional(),
|
|
6516
|
+
format: z.enum(["plain", "json"]).optional()
|
|
6517
|
+
}), logsHelp), async (c) => {
|
|
5249
6518
|
const query = c.req.valid("query");
|
|
5250
6519
|
const path = logger.file;
|
|
5251
6520
|
if (!path || !existsSync(path)) return c.text("no logs");
|
|
5252
6521
|
const lineCount = query.n ? Number(query.n) : 20;
|
|
6522
|
+
const format = query.format ?? "plain";
|
|
5253
6523
|
const tail = Bun.spawn([
|
|
5254
6524
|
"tail",
|
|
5255
6525
|
"-f",
|
|
@@ -5268,7 +6538,6 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5268
6538
|
const reader = tail.stdout.getReader();
|
|
5269
6539
|
const decoder = new TextDecoder();
|
|
5270
6540
|
let buffer = "";
|
|
5271
|
-
logger.info("gateway.logs tail start", { file: path });
|
|
5272
6541
|
while (true) {
|
|
5273
6542
|
const result = await reader.read();
|
|
5274
6543
|
if (result.done) break;
|
|
@@ -5282,13 +6551,8 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5282
6551
|
process.stdout.write(`${line}\n`);
|
|
5283
6552
|
continue;
|
|
5284
6553
|
}
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
level: parsed.level,
|
|
5288
|
-
message: parsed.message,
|
|
5289
|
-
...parsed.meta ? { meta: parsed.meta } : {}
|
|
5290
|
-
};
|
|
5291
|
-
process.stdout.write(`---\n${stringify(output)}`);
|
|
6554
|
+
if (format === "json") process.stdout.write(`${JSON.stringify(parsed)}\n`);
|
|
6555
|
+
else process.stdout.write(formatPlain(parsed));
|
|
5292
6556
|
}
|
|
5293
6557
|
}
|
|
5294
6558
|
await tail.exited;
|
|
@@ -5296,47 +6560,78 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
|
|
|
5296
6560
|
});
|
|
5297
6561
|
//#endregion
|
|
5298
6562
|
//#region lib/cli/routes/gateway.sql.ts
|
|
6563
|
+
const PRESETS = {
|
|
6564
|
+
recent: "SELECT seq, ts, type, outcome FROM processed ORDER BY seq DESC LIMIT 20",
|
|
6565
|
+
skipped: "SELECT seq, ts, type, outcome, payload FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT 20",
|
|
6566
|
+
errors: "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 20",
|
|
6567
|
+
summary: "SELECT outcome, COUNT(*) AS count FROM processed GROUP BY outcome ORDER BY count DESC",
|
|
6568
|
+
"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"
|
|
6569
|
+
};
|
|
5299
6570
|
const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
|
|
5300
6571
|
|
|
5301
|
-
usage: funnel gateway sql --
|
|
6572
|
+
usage: funnel gateway sql --preset <name> [--channel <name|id>] [--limit <N>]
|
|
6573
|
+
funnel gateway sql --query "<SELECT ...>"
|
|
6574
|
+
|
|
6575
|
+
options:
|
|
6576
|
+
--preset <name> run a named preset (quickest starting point)
|
|
6577
|
+
--channel <name|id> filter preset results by channel name or id
|
|
6578
|
+
--limit <N> override the row limit for presets (default: 20)
|
|
6579
|
+
--query "<SQL>" run a custom SELECT; output is JSON
|
|
5302
6580
|
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
6581
|
+
quick-start presets (--preset <name>):
|
|
6582
|
+
recent last N processed events — type, outcome, preview
|
|
6583
|
+
skipped last N events filtered out (skip:*) — see why events were dropped
|
|
6584
|
+
errors listener auth-failed or error events — start here for connection failures
|
|
6585
|
+
summary outcome counts grouped by type — high-level health snapshot (no limit)
|
|
6586
|
+
trace-dedup raw payload of events dropped as duplicates
|
|
5306
6587
|
|
|
5307
|
-
|
|
5308
|
-
raw every inbound event, untouched, before any filtering
|
|
5309
|
-
processed the verdict for that event after the per-type processor ran
|
|
5310
|
-
connection the listener lifecycle (so you can tell events never arrived)
|
|
6588
|
+
Output is always JSON — pipe to jq or pass directly to Claude.
|
|
5311
6589
|
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
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
|
|
6590
|
+
three SQL views (for --query):
|
|
6591
|
+
raw every inbound event before filtering (payload = original JSON)
|
|
6592
|
+
processed filter verdict per event (outcome: emitted | skip:<reason>)
|
|
6593
|
+
connection listener lifecycle (status: connected | auth-failed | error | ...)
|
|
5328
6594
|
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
6595
|
+
common join: SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome = 'skip:dedup'
|
|
6596
|
+
|
|
6597
|
+
skip reasons: skip:type skip:subtype skip:dedup skip:self-user skip:self-bot skip:preprocess
|
|
5332
6598
|
|
|
5333
6599
|
examples:
|
|
5334
|
-
funnel gateway sql --
|
|
6600
|
+
funnel gateway sql --preset recent
|
|
6601
|
+
funnel gateway sql --preset recent --limit 100
|
|
6602
|
+
funnel gateway sql --preset skipped --channel open-karte
|
|
6603
|
+
funnel gateway sql --preset errors
|
|
6604
|
+
funnel gateway sql --preset summary
|
|
5335
6605
|
funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
6606
|
+
|
|
6607
|
+
tip: for a higher-level view without writing SQL, use: fnl debug --channel <name> --json
|
|
6608
|
+
|
|
6609
|
+
see also: fnl debug, fnl gateway logs`;
|
|
6610
|
+
const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6611
|
+
query: z.string().optional(),
|
|
6612
|
+
preset: z.enum(Object.keys(PRESETS)).optional(),
|
|
6613
|
+
channel: z.string().optional(),
|
|
6614
|
+
limit: z.string().optional()
|
|
6615
|
+
}), sqlHelp), async (c) => {
|
|
6616
|
+
const query = c.req.valid("query");
|
|
6617
|
+
const funnel = c.env.funnel;
|
|
6618
|
+
let sql = null;
|
|
6619
|
+
let params = [];
|
|
6620
|
+
let resolvedChannelId = null;
|
|
6621
|
+
if (query.channel) resolvedChannelId = funnel.channels.list().find((ch) => ch.id === query.channel || ch.name === query.channel)?.id ?? query.channel;
|
|
6622
|
+
if (query.preset) {
|
|
6623
|
+
const base = PRESETS[query.preset] ?? null;
|
|
6624
|
+
if (!base) return c.text(sqlHelp);
|
|
6625
|
+
let applied = base;
|
|
6626
|
+
if (query.limit) {
|
|
6627
|
+
const n = Math.max(1, Number(query.limit));
|
|
6628
|
+
applied = applied.replace(/LIMIT \d+/, `LIMIT ${n}`);
|
|
6629
|
+
}
|
|
6630
|
+
if (resolvedChannelId) {
|
|
6631
|
+
sql = applied.replace(/FROM (raw|processed|connection)\b/, "FROM $1 WHERE channel_id = ?");
|
|
6632
|
+
params = [resolvedChannelId];
|
|
6633
|
+
} else sql = applied;
|
|
6634
|
+
} else if (query.query) sql = query.query;
|
|
5340
6635
|
if (!sql) return c.text(sqlHelp);
|
|
5341
6636
|
const tmpDir = funnelTmpDir();
|
|
5342
6637
|
const rawPath = join(tmpDir, "connector-raw.db");
|
|
@@ -5350,7 +6645,7 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
|
|
|
5350
6645
|
});
|
|
5351
6646
|
const rows = (() => {
|
|
5352
6647
|
try {
|
|
5353
|
-
return reader.query(sql);
|
|
6648
|
+
return reader.query(sql, params);
|
|
5354
6649
|
} finally {
|
|
5355
6650
|
reader.close();
|
|
5356
6651
|
}
|
|
@@ -5369,7 +6664,7 @@ examples:
|
|
|
5369
6664
|
funnel gateway restart
|
|
5370
6665
|
funnel gateway restart --no-caffeine`), async (c) => {
|
|
5371
6666
|
const query = c.req.valid("query");
|
|
5372
|
-
const result = await c.
|
|
6667
|
+
const result = await c.env.funnel.gateway.restart({ caffeinate: query["no-caffeine"] !== "true" });
|
|
5373
6668
|
const lines = [];
|
|
5374
6669
|
if (result.wasRunning) lines.push(result.stopped ? "funnel gateway: stopped" : "funnel gateway: failed to stop");
|
|
5375
6670
|
if (result.stopped) lines.push(result.started ? "funnel gateway: started" : "funnel gateway: failed to start");
|
|
@@ -5390,7 +6685,7 @@ examples:
|
|
|
5390
6685
|
funnel gateway run
|
|
5391
6686
|
funnel gateway run --no-caffeine`), async (c) => {
|
|
5392
6687
|
const query = c.req.valid("query");
|
|
5393
|
-
const funnel = c.
|
|
6688
|
+
const funnel = c.env.funnel;
|
|
5394
6689
|
const gatewayScript = resolveDaemonScript();
|
|
5395
6690
|
const command = query["no-caffeine"] !== "true" && process.platform === "darwin" ? [
|
|
5396
6691
|
"caffeinate",
|
|
@@ -5420,7 +6715,7 @@ examples:
|
|
|
5420
6715
|
funnel gateway start --no-caffeine`;
|
|
5421
6716
|
const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.object({ "no-caffeine": z.string().optional() }), startHelp), async (c) => {
|
|
5422
6717
|
const query = c.req.valid("query");
|
|
5423
|
-
const funnel = c.
|
|
6718
|
+
const funnel = c.env.funnel;
|
|
5424
6719
|
if (funnel.gateway.isRunning()) {
|
|
5425
6720
|
const status = funnel.gateway.getStatus();
|
|
5426
6721
|
return c.text(`funnel gateway: already running (pid ${status.pid})`);
|
|
@@ -5428,15 +6723,41 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
|
|
|
5428
6723
|
if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
|
|
5429
6724
|
return c.text("funnel gateway: started");
|
|
5430
6725
|
});
|
|
5431
|
-
const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
6726
|
+
const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
|
|
6727
|
+
"true",
|
|
6728
|
+
"false",
|
|
6729
|
+
""
|
|
6730
|
+
]).optional() }), `funnel gateway status — show gateway running status
|
|
6731
|
+
|
|
6732
|
+
usage: funnel gateway status [--json]
|
|
5432
6733
|
|
|
5433
|
-
|
|
6734
|
+
options:
|
|
6735
|
+
--json output as JSON
|
|
5434
6736
|
|
|
5435
|
-
When running, prints PID, port,
|
|
6737
|
+
When running, prints PID, port, uptime, listeners (alive/dead), and WS clients.
|
|
6738
|
+
When not running, exits with 503.
|
|
5436
6739
|
|
|
5437
6740
|
examples:
|
|
5438
6741
|
funnel gateway status
|
|
5439
|
-
funnel gateway`),
|
|
6742
|
+
funnel gateway status --json`), async (c) => {
|
|
6743
|
+
const query = c.req.valid("query");
|
|
6744
|
+
if (!(query.json === "true" || query.json === "")) return renderGatewayStatus(c);
|
|
6745
|
+
const status = c.env.funnel.gateway.getStatus();
|
|
6746
|
+
if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
|
|
6747
|
+
const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
|
|
6748
|
+
if (!res) return c.json({
|
|
6749
|
+
running: true,
|
|
6750
|
+
pid: status.pid,
|
|
6751
|
+
port: status.port,
|
|
6752
|
+
error: "health check failed"
|
|
6753
|
+
});
|
|
6754
|
+
const data = await res.json();
|
|
6755
|
+
return c.json({
|
|
6756
|
+
running: true,
|
|
6757
|
+
port: status.port,
|
|
6758
|
+
...data
|
|
6759
|
+
});
|
|
6760
|
+
});
|
|
5440
6761
|
const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
|
|
5441
6762
|
|
|
5442
6763
|
usage: funnel gateway stop
|
|
@@ -5445,12 +6766,29 @@ Terminates the process whose PID is stored in ~/.funnel/gateway.pid.
|
|
|
5445
6766
|
|
|
5446
6767
|
examples:
|
|
5447
6768
|
funnel gateway stop`), async (c) => {
|
|
5448
|
-
const funnel = c.
|
|
6769
|
+
const funnel = c.env.funnel;
|
|
5449
6770
|
if (!funnel.gateway.isRunning()) return c.text("funnel gateway: no running process");
|
|
5450
6771
|
if (!await funnel.gateway.stop()) throw new HTTPException(500, { message: "funnel gateway: failed to stop" });
|
|
5451
6772
|
return c.text("funnel gateway: stopped");
|
|
5452
6773
|
});
|
|
5453
6774
|
//#endregion
|
|
6775
|
+
//#region lib/cli/routes/profiles.add.ts
|
|
6776
|
+
const help$4 = `funnel profiles add — add a profile
|
|
6777
|
+
|
|
6778
|
+
usage: funnel profiles add <name> --path <path> --channel <channel-name> [recipe]
|
|
6779
|
+
|
|
6780
|
+
options:
|
|
6781
|
+
--path working directory passed to claude as cwd
|
|
6782
|
+
--channel channel name (resolved to channel id internally)
|
|
6783
|
+
--agent sub-agent name, prepended to the launch argv as --agent <name>
|
|
6784
|
+
--options extra launch argv as one whitespace-split string (e.g. "--brief")
|
|
6785
|
+
--env env vars layered under the process, as "KEY=VAL,KEY2=VAL2"
|
|
6786
|
+
--no-resume start a fresh claude session every launch (default resumes)
|
|
6787
|
+
|
|
6788
|
+
The launch recipe (--agent / --options / --env / --resume) lives on the
|
|
6789
|
+
profile; the channel only declares transport (connectors / delivery).`;
|
|
6790
|
+
const profilesAddHelpHandler = factory.createHandlers((c) => c.text(help$4));
|
|
6791
|
+
//#endregion
|
|
5454
6792
|
//#region lib/cli/routes/parse-profile-recipe.ts
|
|
5455
6793
|
/**
|
|
5456
6794
|
* Turns the single-string CLI flags (`--agent`, `--options "<argv>"`,
|
|
@@ -5487,20 +6825,6 @@ const parseProfileRecipe = (query) => {
|
|
|
5487
6825
|
};
|
|
5488
6826
|
//#endregion
|
|
5489
6827
|
//#region lib/cli/routes/profiles.add.$profile.ts
|
|
5490
|
-
const addHelp = `funnel profiles add — add a profile
|
|
5491
|
-
|
|
5492
|
-
usage: funnel profiles add <name> --path <path> --channel <channel-name> [recipe]
|
|
5493
|
-
|
|
5494
|
-
options:
|
|
5495
|
-
--path working directory passed to claude as cwd
|
|
5496
|
-
--channel channel name (resolved to channel id internally)
|
|
5497
|
-
--agent sub-agent name, prepended to the launch argv as --agent <name>
|
|
5498
|
-
--options extra launch argv as one whitespace-split string (e.g. "--brief")
|
|
5499
|
-
--env env vars layered under the process, as "KEY=VAL,KEY2=VAL2"
|
|
5500
|
-
--no-resume start a fresh claude session every launch (default resumes)
|
|
5501
|
-
|
|
5502
|
-
The launch recipe (--agent / --options / --env / --resume) lives on the
|
|
5503
|
-
profile; the channel only declares transport (connectors / delivery).`;
|
|
5504
6828
|
const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({
|
|
5505
6829
|
path: z.string(),
|
|
5506
6830
|
channel: z.string(),
|
|
@@ -5509,10 +6833,10 @@ const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object
|
|
|
5509
6833
|
env: z.string().optional(),
|
|
5510
6834
|
resume: z.string().optional(),
|
|
5511
6835
|
"no-resume": z.string().optional()
|
|
5512
|
-
})
|
|
6836
|
+
})), (c) => {
|
|
5513
6837
|
const param = c.req.valid("param");
|
|
5514
6838
|
const query = c.req.valid("query");
|
|
5515
|
-
const funnel = c.
|
|
6839
|
+
const funnel = c.env.funnel;
|
|
5516
6840
|
const channel = funnel.channels.get(query.channel);
|
|
5517
6841
|
if (!channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
|
|
5518
6842
|
const recipe = parseProfileRecipe(query);
|
|
@@ -5532,22 +6856,33 @@ usage: funnel profiles <name> as-default
|
|
|
5532
6856
|
|
|
5533
6857
|
the first profile in the list is treated as the default for fnl claude.`), (c) => {
|
|
5534
6858
|
const param = c.req.valid("param");
|
|
5535
|
-
c.
|
|
6859
|
+
c.env.funnel.profiles.asDefault(param.profile);
|
|
5536
6860
|
return c.text(`profile "${param.profile}" is now the default`);
|
|
5537
6861
|
});
|
|
5538
6862
|
//#endregion
|
|
5539
|
-
//#region lib/cli/routes/profiles
|
|
5540
|
-
const
|
|
6863
|
+
//#region lib/cli/routes/profiles.rename.ts
|
|
6864
|
+
const help$3 = `funnel profiles rename — rename a profile
|
|
5541
6865
|
|
|
5542
6866
|
usage:
|
|
5543
6867
|
funnel profiles rename <old> <new>
|
|
5544
6868
|
funnel profiles <old> rename <new>`;
|
|
6869
|
+
const profilesRenameHelpHandler = factory.createHandlers((c) => c.text(help$3));
|
|
6870
|
+
//#endregion
|
|
6871
|
+
//#region lib/cli/routes/profiles.$profile.rename.ts
|
|
6872
|
+
const help$2 = `funnel profiles rename — rename a profile
|
|
6873
|
+
|
|
6874
|
+
usage:
|
|
6875
|
+
funnel profiles rename <old> <new>
|
|
6876
|
+
funnel profiles <old> rename <new>`;
|
|
6877
|
+
const profilesProfileRenameHelpHandler = factory.createHandlers((c) => c.text(help$2));
|
|
6878
|
+
//#endregion
|
|
6879
|
+
//#region lib/cli/routes/profiles.$profile.rename.$newName.ts
|
|
5545
6880
|
const profilesRenameHandler = factory.createHandlers(zValidator$1("param", z.object({
|
|
5546
6881
|
profile: z.string(),
|
|
5547
6882
|
newName: z.string()
|
|
5548
|
-
})), zValidator$1("query", z.object({})
|
|
6883
|
+
})), zValidator$1("query", z.object({})), (c) => {
|
|
5549
6884
|
const param = c.req.valid("param");
|
|
5550
|
-
c.
|
|
6885
|
+
c.env.funnel.profiles.rename(param.profile, param.newName);
|
|
5551
6886
|
return c.text(`renamed profile "${param.profile}" to "${param.newName}"`);
|
|
5552
6887
|
});
|
|
5553
6888
|
//#endregion
|
|
@@ -5559,7 +6894,7 @@ usage: funnel profiles <name> run [additional claude args...]
|
|
|
5559
6894
|
const RESERVED_KEYS = [];
|
|
5560
6895
|
const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({}).passthrough(), launchHelp), async (c) => {
|
|
5561
6896
|
const param = c.req.valid("param");
|
|
5562
|
-
const funnel = c.
|
|
6897
|
+
const funnel = c.env.funnel;
|
|
5563
6898
|
const profile = funnel.profiles.get(param.profile);
|
|
5564
6899
|
if (!profile) throw new HTTPException(404, { message: `profile "${param.profile}" not found` });
|
|
5565
6900
|
const exitCode = await funnel.claude.launch({
|
|
@@ -5574,18 +6909,21 @@ const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.obj
|
|
|
5574
6909
|
process.exit(exitCode);
|
|
5575
6910
|
});
|
|
5576
6911
|
//#endregion
|
|
5577
|
-
//#region lib/cli/routes/profiles.remove
|
|
5578
|
-
const
|
|
6912
|
+
//#region lib/cli/routes/profiles.remove.ts
|
|
6913
|
+
const help$1 = `funnel profiles remove — remove a profile
|
|
5579
6914
|
|
|
5580
6915
|
usage: funnel profiles remove <name>`;
|
|
5581
|
-
const
|
|
6916
|
+
const profilesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$1));
|
|
6917
|
+
//#endregion
|
|
6918
|
+
//#region lib/cli/routes/profiles.remove.$profile.ts
|
|
6919
|
+
const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({})), (c) => {
|
|
5582
6920
|
const param = c.req.valid("param");
|
|
5583
|
-
c.
|
|
6921
|
+
c.env.funnel.profiles.remove(param.profile);
|
|
5584
6922
|
return c.text(`removed profile "${param.profile}"`);
|
|
5585
6923
|
});
|
|
5586
6924
|
//#endregion
|
|
5587
|
-
//#region lib/cli/routes/profiles.set
|
|
5588
|
-
const
|
|
6925
|
+
//#region lib/cli/routes/profiles.set.ts
|
|
6926
|
+
const help = `funnel profiles <name> set — update a profile
|
|
5589
6927
|
|
|
5590
6928
|
usage: funnel profiles <name> set [--path <path>] [--channel <channel-name>] [recipe]
|
|
5591
6929
|
|
|
@@ -5599,6 +6937,9 @@ options:
|
|
|
5599
6937
|
|
|
5600
6938
|
Only the flags you pass are changed; --agent and --options together replace
|
|
5601
6939
|
the profile's whole options list.`;
|
|
6940
|
+
const profilesSetHelpHandler = factory.createHandlers((c) => c.text(help));
|
|
6941
|
+
//#endregion
|
|
6942
|
+
//#region lib/cli/routes/profiles.set.$profile.ts
|
|
5602
6943
|
const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({
|
|
5603
6944
|
path: z.string().optional(),
|
|
5604
6945
|
channel: z.string().optional(),
|
|
@@ -5607,10 +6948,10 @@ const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object
|
|
|
5607
6948
|
env: z.string().optional(),
|
|
5608
6949
|
resume: z.string().optional(),
|
|
5609
6950
|
"no-resume": z.string().optional()
|
|
5610
|
-
})
|
|
6951
|
+
})), (c) => {
|
|
5611
6952
|
const param = c.req.valid("param");
|
|
5612
6953
|
const query = c.req.valid("query");
|
|
5613
|
-
const funnel = c.
|
|
6954
|
+
const funnel = c.env.funnel;
|
|
5614
6955
|
const channel = query.channel !== void 0 ? funnel.channels.get(query.channel) : null;
|
|
5615
6956
|
if (query.channel !== void 0 && !channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
|
|
5616
6957
|
const recipe = parseProfileRecipe(query);
|
|
@@ -5645,7 +6986,7 @@ examples:
|
|
|
5645
6986
|
funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
|
|
5646
6987
|
funnel profiles cto as-default
|
|
5647
6988
|
funnel profiles cto run`), (c) => {
|
|
5648
|
-
const profiles = c.
|
|
6989
|
+
const profiles = c.env.funnel.profiles.list();
|
|
5649
6990
|
if (profiles.length === 0) return c.text("no profiles");
|
|
5650
6991
|
const lines = profiles.map((profile, index) => {
|
|
5651
6992
|
const tag = index === 0 ? " (default)" : "";
|
|
@@ -5675,29 +7016,77 @@ can validate and autocomplete the config:
|
|
|
5675
7016
|
});
|
|
5676
7017
|
//#endregion
|
|
5677
7018
|
//#region lib/cli/routes/status.ts
|
|
5678
|
-
const statusHelp = `funnel status —
|
|
7019
|
+
const statusHelp = `funnel status — overall health at a glance
|
|
7020
|
+
|
|
7021
|
+
usage: funnel status [--watch] [--interval <N>]
|
|
7022
|
+
|
|
7023
|
+
options:
|
|
7024
|
+
--watch continuously refresh (Ctrl+C to stop)
|
|
7025
|
+
--interval <N> polling interval in seconds (used with --watch, default: 3)
|
|
7026
|
+
|
|
7027
|
+
Shows gateway running state (pid, port, uptime), per-channel listener health
|
|
7028
|
+
(● alive / ○ dead), and whether Claude is connected to each channel as a
|
|
7029
|
+
WebSocket client. Use this as the first step when debugging missing events.
|
|
5679
7030
|
|
|
5680
|
-
|
|
7031
|
+
examples:
|
|
7032
|
+
funnel status
|
|
7033
|
+
funnel status --watch
|
|
7034
|
+
funnel status --watch --interval 5
|
|
5681
7035
|
|
|
5682
|
-
|
|
5683
|
-
and active MCP WebSocket clients.`;
|
|
7036
|
+
see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
|
|
5684
7037
|
const isGatewayStatus = (value) => {
|
|
5685
7038
|
if (value === null || typeof value !== "object") return false;
|
|
5686
7039
|
if (!("clients" in value) || !Array.isArray(value.clients)) return false;
|
|
5687
|
-
|
|
7040
|
+
if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
|
|
7041
|
+
return true;
|
|
5688
7042
|
};
|
|
5689
|
-
const
|
|
5690
|
-
const funnel = c.var.funnel;
|
|
7043
|
+
const buildStatusLines = async (funnel) => {
|
|
5691
7044
|
const channels = funnel.channels.list();
|
|
5692
7045
|
const profiles = funnel.profiles.list();
|
|
5693
7046
|
const gatewayStatus = funnel.gateway.getStatus();
|
|
5694
7047
|
const lines = [];
|
|
5695
7048
|
lines.push("= funnel status =");
|
|
5696
7049
|
lines.push("");
|
|
7050
|
+
let gatewayData = null;
|
|
7051
|
+
if (!gatewayStatus.running) lines.push("gateway: not running");
|
|
7052
|
+
else {
|
|
7053
|
+
const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
|
|
7054
|
+
let uptimeStr = "";
|
|
7055
|
+
if (res && res.ok) {
|
|
7056
|
+
const body = await res.json();
|
|
7057
|
+
if (isGatewayStatus(body)) {
|
|
7058
|
+
gatewayData = body;
|
|
7059
|
+
const uptimeSec = Math.floor(body.uptimeMs / 1e3);
|
|
7060
|
+
const uptimeMin = Math.floor(uptimeSec / 60);
|
|
7061
|
+
uptimeStr = uptimeMin >= 60 ? ` · ${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? ` · ${uptimeMin}m ${uptimeSec % 60}s` : ` · ${uptimeSec}s`;
|
|
7062
|
+
}
|
|
7063
|
+
}
|
|
7064
|
+
lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})${uptimeStr}`);
|
|
7065
|
+
}
|
|
7066
|
+
lines.push("");
|
|
7067
|
+
const clientsByChannel = /* @__PURE__ */ new Map();
|
|
7068
|
+
const listenerAliveByChannel = /* @__PURE__ */ new Map();
|
|
7069
|
+
if (gatewayData) {
|
|
7070
|
+
for (const client of gatewayData.clients) {
|
|
7071
|
+
if (client.tapAll) continue;
|
|
7072
|
+
const key = client.channelName ?? client.channel;
|
|
7073
|
+
clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
|
|
7074
|
+
}
|
|
7075
|
+
for (const listener of gatewayData.listeners) {
|
|
7076
|
+
const current = listenerAliveByChannel.get(listener.channelName);
|
|
7077
|
+
listenerAliveByChannel.set(listener.channelName, current === void 0 ? listener.alive : current && listener.alive);
|
|
7078
|
+
}
|
|
7079
|
+
}
|
|
7080
|
+
const maxNameLen = Math.max(...channels.map((ch) => ch.name.length), 0);
|
|
5697
7081
|
lines.push(`channels: ${channels.length}`);
|
|
5698
7082
|
for (const ch of channels) {
|
|
5699
|
-
const
|
|
5700
|
-
|
|
7083
|
+
const connectorLabel = ch.connectors.length > 0 ? ch.connectors.map((conn) => conn.type).join(", ") : "no connectors";
|
|
7084
|
+
const isAlive = listenerAliveByChannel.get(ch.name);
|
|
7085
|
+
const indicator = gatewayData === null ? "-" : isAlive === true ? "●" : isAlive === false ? "○" : "-";
|
|
7086
|
+
const claudeCount = clientsByChannel.get(ch.name) ?? 0;
|
|
7087
|
+
const claudeLabel = gatewayData === null ? "" : claudeCount === 0 ? " no Claude" : claudeCount === 1 ? " Claude connected (1 client)" : ` Claude connected (${claudeCount} clients)`;
|
|
7088
|
+
const paddedName = ch.name.padEnd(maxNameLen);
|
|
7089
|
+
lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
|
|
5701
7090
|
}
|
|
5702
7091
|
lines.push("");
|
|
5703
7092
|
lines.push(`profiles: ${profiles.length}`);
|
|
@@ -5707,23 +7096,41 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}),
|
|
|
5707
7096
|
const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
|
|
5708
7097
|
lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
|
|
5709
7098
|
}
|
|
5710
|
-
lines
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
7099
|
+
return lines;
|
|
7100
|
+
};
|
|
7101
|
+
const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
7102
|
+
watch: z.enum([
|
|
7103
|
+
"true",
|
|
7104
|
+
"false",
|
|
7105
|
+
""
|
|
7106
|
+
]).optional(),
|
|
7107
|
+
interval: z.string().optional()
|
|
7108
|
+
}), statusHelp), async (c) => {
|
|
7109
|
+
const query = c.req.valid("query");
|
|
7110
|
+
const funnel = c.env.funnel;
|
|
7111
|
+
const isWatch = query.watch === "true" || query.watch === "";
|
|
7112
|
+
const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
|
|
7113
|
+
if (!isWatch) {
|
|
7114
|
+
const lines = await buildStatusLines(funnel);
|
|
7115
|
+
return c.text(lines.join("\n"));
|
|
7116
|
+
}
|
|
7117
|
+
const render = async () => {
|
|
7118
|
+
const lines = await buildStatusLines(funnel);
|
|
7119
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
7120
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
7121
|
+
process.stdout.write(lines.join("\n"));
|
|
7122
|
+
process.stdout.write(`\n\n refreshing every ${intervalSec}s · ${ts} · Ctrl+C to stop\n`);
|
|
7123
|
+
};
|
|
7124
|
+
process.on("SIGINT", () => {
|
|
7125
|
+
process.stdout.write("\n");
|
|
7126
|
+
process.exit(0);
|
|
7127
|
+
});
|
|
7128
|
+
await render();
|
|
7129
|
+
const timer = setInterval(render, intervalSec * 1e3);
|
|
7130
|
+
await new Promise(() => {
|
|
7131
|
+
process.on("exit", () => clearInterval(timer));
|
|
7132
|
+
});
|
|
7133
|
+
return c.text("");
|
|
5727
7134
|
});
|
|
5728
7135
|
//#endregion
|
|
5729
7136
|
//#region lib/cli/routes/update.ts
|
|
@@ -5745,30 +7152,9 @@ const updateHandler = factory.createHandlers(zValidator$1("query", z.object({}),
|
|
|
5745
7152
|
});
|
|
5746
7153
|
//#endregion
|
|
5747
7154
|
//#region lib/cli/routes/index.ts
|
|
5748
|
-
const
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
* uses against a custom Funnel (e.g. one with sandboxed boundaries).
|
|
5753
|
-
*
|
|
5754
|
-
* All CLI verbs (`add` / `remove` / `set` / `rename` / `as-default` / `request`) map to POST in
|
|
5755
|
-
* to-request.ts and stay in the URL as a literal segment. Read paths (list / show / launch) keep GET.
|
|
5756
|
-
* Help shortcuts at parameterless URLs return the help text directly so `funnel <verb>` (no args) is
|
|
5757
|
-
* informative instead of 404.
|
|
5758
|
-
*/
|
|
5759
|
-
const createCliApp = (funnel) => {
|
|
5760
|
-
const base = factory.createApp();
|
|
5761
|
-
base.use((c, next) => {
|
|
5762
|
-
c.set("funnel", funnel);
|
|
5763
|
-
return next();
|
|
5764
|
-
});
|
|
5765
|
-
base.onError((error, c) => {
|
|
5766
|
-
if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
|
|
5767
|
-
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
5768
|
-
});
|
|
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);
|
|
5770
|
-
};
|
|
5771
|
-
/** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
|
|
5772
|
-
const app = createCliApp(new Funnel());
|
|
7155
|
+
const routes = factory.createApp().onError((error, c) => {
|
|
7156
|
+
if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
|
|
7157
|
+
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
7158
|
+
}).get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...channelsAddHelpHandler).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...channelsRemoveHelpHandler).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...channelsRenameHelpHandler).post("/channels/:channel/rename", ...channelsChannelRenameHelpHandler).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...channelsPublishHelpHandler).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel/validate", ...channelsValidateHandler).get("/channels/validate", ...channelsValidateHelpHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...channelsConnectorsAddHelpHandler).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...channelsConnectorsRemoveHelpHandler).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...channelsConnectorsSetHelpHandler).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...channelsConnectorsRenameHelpHandler).post("/channels/:channel/connectors/:connector/rename", ...channelsConnectorRenameHelpHandler).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...channelsConnectorSchedulesAddHelpHandler).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...channelsConnectorSchedulesRemoveHelpHandler).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...profilesAddHelpHandler).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...profilesSetHelpHandler).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...profilesRemoveHelpHandler).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...profilesRenameHelpHandler).post("/profiles/:profile/rename", ...profilesProfileRenameHelpHandler).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/sql", ...gatewaySqlHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/debug", ...debugHandler).get("/debug/events", ...debugEventsHandler).get("/debug/dropped", ...debugDroppedHandler).get("/debug/errors", ...debugErrorsHandler).get("/debug/replay", ...debugReplayHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
|
|
5773
7159
|
//#endregion
|
|
5774
|
-
export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema,
|
|
7160
|
+
export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
|