@interactive-inc/claude-funnel 0.4.0 → 0.7.1
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 +105 -5
- package/lib/api.ts +54 -0
- package/lib/funnel.ts +120 -33
- package/lib/modules/channels/funnel-channels.ts +5 -0
- package/lib/modules/claude/funnel-claude.ts +21 -9
- package/lib/modules/connectors/funnel-connector-stores.ts +32 -4
- package/lib/modules/connectors/funnel-connectors.ts +6 -0
- package/lib/modules/connectors/funnel-discord-listener.ts +9 -3
- package/lib/modules/connectors/funnel-discord-store.ts +5 -1
- package/lib/modules/connectors/funnel-gh-listener.ts +14 -5
- package/lib/modules/connectors/funnel-gh-store.ts +18 -1
- package/lib/modules/connectors/funnel-schedule-listener.ts +8 -2
- package/lib/modules/connectors/funnel-schedule-store.ts +21 -4
- package/lib/modules/connectors/funnel-slack-listener.ts +8 -2
- package/lib/modules/connectors/funnel-slack-store.ts +5 -1
- package/lib/modules/connectors/migrate-legacy-connectors.ts +5 -1
- package/lib/modules/fs/funnel-file-system.ts +5 -0
- package/lib/modules/gateway/daemon.ts +10 -143
- package/lib/modules/gateway/funnel-gateway-server.ts +241 -0
- package/lib/modules/gateway/funnel-gateway.ts +49 -20
- package/lib/modules/gateway/kill-competing-slack-gateways.ts +5 -1
- package/lib/modules/id/funnel-id-generator.ts +7 -0
- package/lib/modules/id/memory-funnel-id-generator.ts +20 -0
- package/lib/modules/id/node-funnel-id-generator.ts +7 -0
- package/lib/modules/logger/funnel-logger.ts +11 -0
- package/lib/modules/logger/memory-funnel-logger.ts +28 -0
- package/lib/modules/logger/node-funnel-logger.ts +49 -0
- package/lib/modules/logger/noop-funnel-logger.ts +9 -0
- package/lib/modules/mcp/funnel-mcp.ts +5 -0
- package/lib/modules/process/funnel-process-runner.ts +5 -0
- package/lib/modules/profiles/funnel-profiles.ts +5 -0
- package/lib/modules/repos/funnel-repositories.ts +5 -0
- package/lib/modules/schedule/funnel-schedule.ts +5 -0
- package/lib/modules/time/funnel-clock.ts +15 -0
- package/lib/modules/time/memory-funnel-clock.ts +26 -0
- package/lib/modules/time/node-funnel-clock.ts +7 -0
- package/lib/routes/gateway/logs.ts +3 -1
- package/package.json +16 -5
- package/lib/modules/logger.ts +0 -26
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from "bun"
|
|
2
|
+
import { Hono } from "hono"
|
|
3
|
+
import type { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
|
|
4
|
+
import type { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
5
|
+
import { FunnelBroadcaster } from "@/modules/gateway/funnel-broadcaster"
|
|
6
|
+
import { FunnelEventLogger } from "@/modules/gateway/funnel-event-logger"
|
|
7
|
+
import { killCompetingSlackGateways } from "@/modules/gateway/kill-competing-slack-gateways"
|
|
8
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
9
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
10
|
+
import type { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
11
|
+
import type { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
|
|
12
|
+
import type { FunnelClock } from "@/modules/time/funnel-clock"
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PORT = 9742
|
|
15
|
+
const DEFAULT_LOG_DIR = "/tmp/funnel/events"
|
|
16
|
+
|
|
17
|
+
type Deps = {
|
|
18
|
+
connectors: FunnelConnectors
|
|
19
|
+
settings: FunnelSettingsReader
|
|
20
|
+
port?: number
|
|
21
|
+
logDir?: string
|
|
22
|
+
fs?: FunnelFileSystem
|
|
23
|
+
process?: FunnelProcessRunner
|
|
24
|
+
clock?: FunnelClock
|
|
25
|
+
logger?: FunnelLogger
|
|
26
|
+
selfPid?: number
|
|
27
|
+
killCompetingSlack?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type WsData = { channel: string; connectors: string[] }
|
|
31
|
+
|
|
32
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots all
|
|
36
|
+
* connector listeners, fans events out via FunnelBroadcaster, and persists
|
|
37
|
+
* them via FunnelEventLogger. Useful for embedding the gateway in a custom
|
|
38
|
+
* host or driving it from tests.
|
|
39
|
+
*/
|
|
40
|
+
export class FunnelGatewayServer {
|
|
41
|
+
private readonly connectors: FunnelConnectors
|
|
42
|
+
private readonly settings: FunnelSettingsReader
|
|
43
|
+
private readonly port: number
|
|
44
|
+
private readonly logDir: string
|
|
45
|
+
private readonly fs?: FunnelFileSystem
|
|
46
|
+
private readonly process?: FunnelProcessRunner
|
|
47
|
+
private readonly logger: FunnelLogger
|
|
48
|
+
private readonly selfPid: number
|
|
49
|
+
private readonly killCompetingSlack: boolean
|
|
50
|
+
private readonly broadcaster: FunnelBroadcaster
|
|
51
|
+
private readonly eventLogger: FunnelEventLogger
|
|
52
|
+
private server: Server<WsData> | null = null
|
|
53
|
+
|
|
54
|
+
constructor(deps: Deps) {
|
|
55
|
+
this.connectors = deps.connectors
|
|
56
|
+
this.settings = deps.settings
|
|
57
|
+
this.port = deps.port ?? DEFAULT_PORT
|
|
58
|
+
this.logDir = deps.logDir ?? DEFAULT_LOG_DIR
|
|
59
|
+
this.fs = deps.fs
|
|
60
|
+
this.process = deps.process
|
|
61
|
+
this.logger = deps.logger ?? defaultLogger
|
|
62
|
+
this.selfPid = deps.selfPid ?? globalThis.process.pid
|
|
63
|
+
this.killCompetingSlack = deps.killCompetingSlack ?? true
|
|
64
|
+
this.broadcaster = new FunnelBroadcaster()
|
|
65
|
+
this.eventLogger = new FunnelEventLogger({
|
|
66
|
+
logDir: this.logDir,
|
|
67
|
+
fs: this.fs,
|
|
68
|
+
now: deps.clock ? () => deps.clock!.millis() : undefined,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async start(): Promise<Server<WsData>> {
|
|
73
|
+
if (this.server) return this.server
|
|
74
|
+
|
|
75
|
+
const app = this.buildApp()
|
|
76
|
+
|
|
77
|
+
this.server = Bun.serve<WsData>({
|
|
78
|
+
port: this.port,
|
|
79
|
+
fetch: (request, server) => {
|
|
80
|
+
const url = new URL(request.url)
|
|
81
|
+
|
|
82
|
+
if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
|
|
83
|
+
const channelName = url.searchParams.get("channel") ?? ""
|
|
84
|
+
const connectors = channelName ? this.resolveConnectors(channelName) : []
|
|
85
|
+
const data: WsData = { channel: channelName, connectors }
|
|
86
|
+
const upgraded = server.upgrade(request, { data })
|
|
87
|
+
|
|
88
|
+
if (upgraded) return undefined
|
|
89
|
+
|
|
90
|
+
return new Response("WebSocket upgrade failed", { status: 400 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return app.fetch(request)
|
|
94
|
+
},
|
|
95
|
+
websocket: {
|
|
96
|
+
open: (ws: ServerWebSocket<WsData>) => {
|
|
97
|
+
const data = ws.data
|
|
98
|
+
|
|
99
|
+
this.broadcaster.addClient(ws, data)
|
|
100
|
+
this.eventLogger.log("channel connected", {
|
|
101
|
+
event_type: "system",
|
|
102
|
+
action: "channel_connect",
|
|
103
|
+
channel: data.channel,
|
|
104
|
+
connectors: data.connectors.join(","),
|
|
105
|
+
total: String(this.broadcaster.getClientCount()),
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
close: (ws: ServerWebSocket<WsData>) => {
|
|
109
|
+
this.broadcaster.removeClient(ws)
|
|
110
|
+
this.eventLogger.log("channel disconnected", {
|
|
111
|
+
event_type: "system",
|
|
112
|
+
action: "channel_disconnect",
|
|
113
|
+
total: String(this.broadcaster.getClientCount()),
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
message() {
|
|
117
|
+
// future: client → gateway messages
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
this.eventLogger.log("gateway started", {
|
|
123
|
+
event_type: "system",
|
|
124
|
+
action: "gateway_start",
|
|
125
|
+
port: String(this.port),
|
|
126
|
+
pid: String(this.selfPid),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
this.logger.info("funnel gateway listening", {
|
|
130
|
+
url: `http://localhost:${this.port}`,
|
|
131
|
+
websocket: `ws://localhost:${this.port}/ws`,
|
|
132
|
+
health: `http://localhost:${this.port}/health`,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await this.bootListeners()
|
|
136
|
+
|
|
137
|
+
return this.server
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
stop(): void {
|
|
141
|
+
if (!this.server) return
|
|
142
|
+
|
|
143
|
+
this.server.stop()
|
|
144
|
+
this.server = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getStatus(): { clients: number; channels: { channel: string; connectors: string[] }[] } {
|
|
148
|
+
return {
|
|
149
|
+
clients: this.broadcaster.getClientCount(),
|
|
150
|
+
channels: this.broadcaster.listChannels(),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getBroadcaster(): FunnelBroadcaster {
|
|
155
|
+
return this.broadcaster
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private buildApp(): Hono {
|
|
159
|
+
const app = new Hono()
|
|
160
|
+
|
|
161
|
+
app.get("/health", (c) =>
|
|
162
|
+
c.json({
|
|
163
|
+
ok: true,
|
|
164
|
+
pid: this.selfPid,
|
|
165
|
+
clients: this.broadcaster.getClientCount(),
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
app.get("/status", (c) =>
|
|
170
|
+
c.json({
|
|
171
|
+
ok: true,
|
|
172
|
+
clients: this.broadcaster.listChannels(),
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return app
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private resolveConnectors(channelName: string): string[] {
|
|
180
|
+
const settings = this.settings.read()
|
|
181
|
+
const channel = settings?.channels.find((c) => c.name === channelName)
|
|
182
|
+
|
|
183
|
+
return channel?.connectors ?? []
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async bootListeners(): Promise<void> {
|
|
187
|
+
const allConnectors = this.connectors.list()
|
|
188
|
+
|
|
189
|
+
if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
|
|
190
|
+
const killed = await killCompetingSlackGateways({
|
|
191
|
+
selfPid: this.selfPid,
|
|
192
|
+
process: this.process,
|
|
193
|
+
logger: this.logger,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if (killed.length > 0) {
|
|
197
|
+
this.eventLogger.log("killed competing Slack gateway processes", {
|
|
198
|
+
event_type: "system",
|
|
199
|
+
action: "kill_competing",
|
|
200
|
+
pids: killed.join(","),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const { config, listener } of this.connectors.createListeners()) {
|
|
206
|
+
const bind = (content: string, meta?: Record<string, string>) =>
|
|
207
|
+
this.notify(config.name, content, meta)
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await listener.start(bind)
|
|
211
|
+
|
|
212
|
+
this.eventLogger.log(`${config.type} listener started: ${config.name}`, {
|
|
213
|
+
event_type: "system",
|
|
214
|
+
action: `${config.type}_connect`,
|
|
215
|
+
connector: config.name,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
this.logger.info(`${config.type} listener started`, { connector: config.name })
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.logger.error(`${config.type} listener failed`, {
|
|
221
|
+
connector: config.name,
|
|
222
|
+
error: error instanceof Error ? error.message : String(error),
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.logger.info(`event logs: ${this.logDir}`)
|
|
228
|
+
this.logger.info("funnel gateway running")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async notify(
|
|
232
|
+
connectorName: string,
|
|
233
|
+
content: string,
|
|
234
|
+
meta?: Record<string, string>,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const withConnector: Record<string, string> = { ...meta, connector: connectorName }
|
|
237
|
+
|
|
238
|
+
this.eventLogger.log(content, withConnector)
|
|
239
|
+
this.broadcaster.broadcast(content, withConnector)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -4,28 +4,57 @@ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
|
|
|
4
4
|
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
5
5
|
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
6
6
|
import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
|
|
7
|
+
import { FunnelClock } from "@/modules/time/funnel-clock"
|
|
8
|
+
import { NodeFunnelClock } from "@/modules/time/node-funnel-clock"
|
|
7
9
|
|
|
8
10
|
const DEFAULT_PORT = 9742
|
|
9
|
-
const
|
|
10
|
-
const LOG_DIR = "/tmp/funnel/events"
|
|
11
|
-
const GATEWAY_LOG = "/tmp/funnel/gateway.log"
|
|
12
|
-
const TMP_DIR = "/tmp/funnel"
|
|
11
|
+
const DEFAULT_TMP_DIR = "/tmp/funnel"
|
|
13
12
|
|
|
14
13
|
type Deps = {
|
|
15
14
|
process?: FunnelProcessRunner
|
|
16
15
|
fs?: FunnelFileSystem
|
|
16
|
+
clock?: FunnelClock
|
|
17
|
+
dir?: string
|
|
18
|
+
tmpDir?: string
|
|
19
|
+
port?: number
|
|
20
|
+
sleep?: (ms: number) => Promise<void>
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const defaultProcess = new NodeFunnelProcessRunner()
|
|
20
24
|
const defaultFs = new NodeFunnelFileSystem()
|
|
21
|
-
|
|
25
|
+
const defaultClock = new NodeFunnelClock()
|
|
26
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
27
|
+
new Promise((r) => {
|
|
28
|
+
setTimeout(r, ms)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Manages the gateway daemon as a separate process via PID file.
|
|
33
|
+
* Use `start()` to spawn `bun daemon.ts` in the background and `stop()` to
|
|
34
|
+
* terminate it. For an in-process gateway, use `Funnel.gatewayServer` instead.
|
|
35
|
+
*/
|
|
22
36
|
export class FunnelGateway {
|
|
23
37
|
private readonly process: FunnelProcessRunner
|
|
24
38
|
private readonly fs: FunnelFileSystem
|
|
39
|
+
private readonly clock: FunnelClock
|
|
40
|
+
private readonly pidFile: string
|
|
41
|
+
private readonly logDir: string
|
|
42
|
+
private readonly gatewayLog: string
|
|
43
|
+
private readonly tmpDir: string
|
|
44
|
+
private readonly port: number
|
|
45
|
+
private readonly sleep: (ms: number) => Promise<void>
|
|
25
46
|
|
|
26
47
|
constructor(deps: Deps = {}) {
|
|
27
48
|
this.process = deps.process ?? defaultProcess
|
|
28
49
|
this.fs = deps.fs ?? defaultFs
|
|
50
|
+
this.clock = deps.clock ?? defaultClock
|
|
51
|
+
const baseDir = deps.dir ?? FUNNEL_DIR
|
|
52
|
+
this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR
|
|
53
|
+
this.pidFile = join(baseDir, "gateway.pid")
|
|
54
|
+
this.logDir = join(this.tmpDir, "events")
|
|
55
|
+
this.gatewayLog = join(this.tmpDir, "gateway.log")
|
|
56
|
+
this.port = deps.port ?? DEFAULT_PORT
|
|
57
|
+
this.sleep = deps.sleep ?? defaultSleep
|
|
29
58
|
Object.freeze(this)
|
|
30
59
|
}
|
|
31
60
|
|
|
@@ -41,20 +70,20 @@ export class FunnelGateway {
|
|
|
41
70
|
const pid = this.readPid()
|
|
42
71
|
const running = pid !== null && this.isProcessAlive(pid)
|
|
43
72
|
|
|
44
|
-
return { running, pid: running ? pid : null, port:
|
|
73
|
+
return { running, pid: running ? pid : null, port: this.port }
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
async start(options: { caffeinate?: boolean } = {}): Promise<boolean> {
|
|
48
77
|
if (this.isRunning()) return true
|
|
49
78
|
|
|
50
|
-
this.fs.mkdirSync(
|
|
79
|
+
this.fs.mkdirSync(this.tmpDir, { recursive: true })
|
|
51
80
|
|
|
52
81
|
const gatewayScript = resolve(import.meta.dir, "./daemon.ts")
|
|
53
82
|
const command = this.buildStartCommand(gatewayScript, options)
|
|
54
83
|
|
|
55
84
|
this.process.detach(["bash", "-c", command])
|
|
56
85
|
|
|
57
|
-
await
|
|
86
|
+
await this.sleep(800)
|
|
58
87
|
|
|
59
88
|
return this.isRunning()
|
|
60
89
|
}
|
|
@@ -63,7 +92,7 @@ export class FunnelGateway {
|
|
|
63
92
|
const useCaffeinate = options.caffeinate !== false && globalThis.process.platform === "darwin"
|
|
64
93
|
const prefix = useCaffeinate ? "caffeinate -i " : ""
|
|
65
94
|
|
|
66
|
-
return `nohup ${prefix}bun ${gatewayScript} >> ${
|
|
95
|
+
return `nohup ${prefix}bun ${gatewayScript} >> ${this.gatewayLog} 2>&1 &`
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
async stop(): Promise<boolean> {
|
|
@@ -77,29 +106,29 @@ export class FunnelGateway {
|
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
try {
|
|
80
|
-
|
|
109
|
+
this.process.kill(pid, "SIGTERM")
|
|
81
110
|
} catch {
|
|
82
111
|
return false
|
|
83
112
|
}
|
|
84
113
|
|
|
85
|
-
const deadline =
|
|
114
|
+
const deadline = this.clock.millis() + 2000
|
|
86
115
|
|
|
87
|
-
while (
|
|
116
|
+
while (this.clock.millis() < deadline) {
|
|
88
117
|
if (!this.isProcessAlive(pid)) {
|
|
89
118
|
this.removePid()
|
|
90
119
|
return true
|
|
91
120
|
}
|
|
92
121
|
|
|
93
|
-
await
|
|
122
|
+
await this.sleep(100)
|
|
94
123
|
}
|
|
95
124
|
|
|
96
125
|
try {
|
|
97
|
-
|
|
126
|
+
this.process.kill(pid, "SIGKILL")
|
|
98
127
|
} catch {
|
|
99
128
|
// ignore
|
|
100
129
|
}
|
|
101
130
|
|
|
102
|
-
await
|
|
131
|
+
await this.sleep(200)
|
|
103
132
|
this.removePid()
|
|
104
133
|
|
|
105
134
|
return !this.isProcessAlive(pid)
|
|
@@ -126,18 +155,18 @@ export class FunnelGateway {
|
|
|
126
155
|
}
|
|
127
156
|
|
|
128
157
|
getLogDir(): string {
|
|
129
|
-
return
|
|
158
|
+
return this.logDir
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
getGatewayLog(): string {
|
|
133
|
-
return
|
|
162
|
+
return this.gatewayLog
|
|
134
163
|
}
|
|
135
164
|
|
|
136
165
|
private readPid(): number | null {
|
|
137
|
-
if (!this.fs.existsSync(
|
|
166
|
+
if (!this.fs.existsSync(this.pidFile)) return null
|
|
138
167
|
|
|
139
168
|
try {
|
|
140
|
-
const content = this.fs.readFileSync(
|
|
169
|
+
const content = this.fs.readFileSync(this.pidFile).trim()
|
|
141
170
|
const pid = Number(content)
|
|
142
171
|
|
|
143
172
|
if (!pid || pid <= 0) return null
|
|
@@ -149,7 +178,7 @@ export class FunnelGateway {
|
|
|
149
178
|
}
|
|
150
179
|
|
|
151
180
|
private removePid(): void {
|
|
152
|
-
this.fs.unlink(
|
|
181
|
+
this.fs.unlink(this.pidFile)
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
private isProcessAlive(pid: number): boolean {
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
2
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
2
3
|
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
3
4
|
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
4
5
|
|
|
5
6
|
type Props = {
|
|
6
7
|
selfPid: number
|
|
7
8
|
process?: FunnelProcessRunner
|
|
9
|
+
logger?: FunnelLogger
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
const defaultProcess = new NodeFunnelProcessRunner()
|
|
13
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
11
14
|
|
|
12
15
|
const isBun = (args: string): boolean => {
|
|
13
16
|
return args.includes("bun ") || /\/bun(\s|$)/.test(args)
|
|
@@ -19,6 +22,7 @@ const looksLikeSlackGateway = (args: string): boolean => {
|
|
|
19
22
|
|
|
20
23
|
export const killCompetingSlackGateways = async (props: Props): Promise<number[]> => {
|
|
21
24
|
const runner = props.process ?? defaultProcess
|
|
25
|
+
const logger = props.logger ?? defaultLogger
|
|
22
26
|
const result = await runner.run(["ps", "-e", "-o", "pid=,args="])
|
|
23
27
|
|
|
24
28
|
if (result.exitCode !== 0) return []
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
prefix?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MemoryFunnelIdGenerator extends FunnelIdGenerator {
|
|
8
|
+
private counter = 0
|
|
9
|
+
private readonly prefix: string
|
|
10
|
+
|
|
11
|
+
constructor(props: Props = {}) {
|
|
12
|
+
super()
|
|
13
|
+
this.prefix = props.prefix ?? "id"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
generate(): string {
|
|
17
|
+
this.counter++
|
|
18
|
+
return `${this.prefix}-${this.counter}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logger with three levels and an optional log-file path.
|
|
3
|
+
* Defaults to NodeFunnelLogger (appends to /tmp/funnel/funnel.log);
|
|
4
|
+
* MemoryFunnelLogger captures entries in memory and NoopFunnelLogger silences output.
|
|
5
|
+
*/
|
|
6
|
+
export abstract class FunnelLogger {
|
|
7
|
+
abstract info(message: string, meta?: Record<string, unknown>): void
|
|
8
|
+
abstract warn(message: string, meta?: Record<string, unknown>): void
|
|
9
|
+
abstract error(message: string, meta?: Record<string, unknown>): void
|
|
10
|
+
abstract readonly file: string | null
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
2
|
+
|
|
3
|
+
export type LogEntry = {
|
|
4
|
+
level: "info" | "warn" | "error"
|
|
5
|
+
message: string
|
|
6
|
+
meta?: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class MemoryFunnelLogger extends FunnelLogger {
|
|
10
|
+
readonly file = null
|
|
11
|
+
readonly entries: LogEntry[] = []
|
|
12
|
+
|
|
13
|
+
info(message: string, meta?: Record<string, unknown>): void {
|
|
14
|
+
this.entries.push({ level: "info", message, meta })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
warn(message: string, meta?: Record<string, unknown>): void {
|
|
18
|
+
this.entries.push({ level: "warn", message, meta })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
error(message: string, meta?: Record<string, unknown>): void {
|
|
22
|
+
this.entries.push({ level: "error", message, meta })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
clear(): void {
|
|
26
|
+
this.entries.length = 0
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LOG_FILE = join("/tmp/funnel", "funnel.log")
|
|
6
|
+
|
|
7
|
+
type Level = "info" | "warn" | "error"
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
file?: string
|
|
11
|
+
now?: () => Date
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class NodeFunnelLogger extends FunnelLogger {
|
|
15
|
+
readonly file: string
|
|
16
|
+
private readonly now: () => Date
|
|
17
|
+
|
|
18
|
+
constructor(props: Props = {}) {
|
|
19
|
+
super()
|
|
20
|
+
this.file = props.file ?? DEFAULT_LOG_FILE
|
|
21
|
+
this.now = props.now ?? (() => new Date())
|
|
22
|
+
Object.freeze(this)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
info(message: string, meta?: Record<string, unknown>): void {
|
|
26
|
+
this.write("info", message, meta)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
warn(message: string, meta?: Record<string, unknown>): void {
|
|
30
|
+
this.write("warn", message, meta)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
error(message: string, meta?: Record<string, unknown>): void {
|
|
34
|
+
this.write("error", message, meta)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private write(level: Level, message: string, meta?: Record<string, unknown>): void {
|
|
38
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
39
|
+
|
|
40
|
+
const entry = {
|
|
41
|
+
time: this.now().toISOString(),
|
|
42
|
+
level,
|
|
43
|
+
message,
|
|
44
|
+
...(meta ? { meta } : {}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
appendFileSync(this.file, `${JSON.stringify(entry)}\n`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -20,6 +20,11 @@ type Deps = {
|
|
|
20
20
|
|
|
21
21
|
const defaultFs = new NodeFunnelFileSystem()
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Installs/uninstalls the funnel MCP entry into a target repository's
|
|
25
|
+
* `.mcp.json`. Detects an existing entry by command match so renaming is
|
|
26
|
+
* preserved across re-installs.
|
|
27
|
+
*/
|
|
23
28
|
export class FunnelMcp {
|
|
24
29
|
private readonly fs: FunnelFileSystem
|
|
25
30
|
|
|
@@ -19,6 +19,11 @@ export type DetachOptions = {
|
|
|
19
19
|
env?: Record<string, string>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Process boundary covering one-shot runs, sync runs, foreground attach, and
|
|
24
|
+
* detached background spawns. Default is NodeFunnelProcessRunner (Bun.spawn);
|
|
25
|
+
* MemoryFunnelProcessRunner records calls and lets tests stub responses.
|
|
26
|
+
*/
|
|
22
27
|
export abstract class FunnelProcessRunner {
|
|
23
28
|
abstract run(command: string[], options?: RunOptions): Promise<RunResult>
|
|
24
29
|
abstract runSync(command: string[]): RunResult
|
|
@@ -5,6 +5,11 @@ type Deps = {
|
|
|
5
5
|
store: FunnelSettingsReader
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Named launch presets for `fnl claude` (channel + repo + sub-agent + env files).
|
|
10
|
+
* Implements ProfileChannelChecker / ProfileChannelRefUpdater so FunnelChannels
|
|
11
|
+
* can validate references without depending on the FunnelProfiles concrete type.
|
|
12
|
+
*/
|
|
8
13
|
export class FunnelProfiles {
|
|
9
14
|
private readonly store: FunnelSettingsReader
|
|
10
15
|
|
|
@@ -7,6 +7,11 @@ type Deps = {
|
|
|
7
7
|
mcp: FunnelMcp
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Repository registry. Each entry has a name + filesystem path; add/remove
|
|
12
|
+
* also writes/removes the funnel MCP entry in `<path>/.mcp.json` so Claude Code
|
|
13
|
+
* picks it up automatically when launched in that directory.
|
|
14
|
+
*/
|
|
10
15
|
export class FunnelRepositories {
|
|
11
16
|
private readonly store: FunnelSettingsReader
|
|
12
17
|
private readonly mcp: FunnelMcp
|
|
@@ -5,6 +5,11 @@ type Deps = {
|
|
|
5
5
|
store: FunnelScheduleStore
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Cron entry CRUD for a schedule connector. The schedule connector itself
|
|
10
|
+
* is created via `connectors.add({ type: "schedule", ... })`; this class
|
|
11
|
+
* manages the JSONL entries inside it.
|
|
12
|
+
*/
|
|
8
13
|
export class FunnelSchedule {
|
|
9
14
|
private readonly store: FunnelScheduleStore
|
|
10
15
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time boundary. Default NodeFunnelClock returns `new Date()`; MemoryFunnelClock
|
|
3
|
+
* is settable and `advance(ms)`-able for deterministic schedule / timeout tests.
|
|
4
|
+
*/
|
|
5
|
+
export abstract class FunnelClock {
|
|
6
|
+
abstract now(): Date
|
|
7
|
+
|
|
8
|
+
millis(): number {
|
|
9
|
+
return this.now().getTime()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
iso(): string {
|
|
13
|
+
return this.now().toISOString()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FunnelClock } from "@/modules/time/funnel-clock"
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
start?: Date
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MemoryFunnelClock extends FunnelClock {
|
|
8
|
+
private current: Date
|
|
9
|
+
|
|
10
|
+
constructor(props: Props = {}) {
|
|
11
|
+
super()
|
|
12
|
+
this.current = props.start ?? new Date(0)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
now(): Date {
|
|
16
|
+
return new Date(this.current.getTime())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
set(date: Date): void {
|
|
20
|
+
this.current = date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
advance(ms: number): void {
|
|
24
|
+
this.current = new Date(this.current.getTime() + ms)
|
|
25
|
+
}
|
|
26
|
+
}
|