@interactive-inc/claude-funnel 0.4.1 → 0.8.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 +233 -111
- package/dist/bin.js +1417 -0
- package/dist/gateway/daemon.js +513 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/lib/bin.ts +78 -0
- package/lib/{modules → cli}/router/to-request.ts +13 -20
- package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
- package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
- package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
- package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
- package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
- package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
- package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
- package/lib/cli/routes/channels.$channel.ts +34 -0
- package/lib/cli/routes/channels.add.$channel.ts +33 -0
- package/lib/cli/routes/channels.remove.$channel.ts +20 -0
- package/lib/cli/routes/channels.ts +39 -0
- package/lib/cli/routes/claude.ts +69 -0
- package/lib/cli/routes/gateway.listeners.ts +41 -0
- package/lib/cli/routes/gateway.logs.ts +123 -0
- package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
- package/lib/cli/routes/gateway.run.ts +41 -0
- package/lib/cli/routes/gateway.start.ts +50 -0
- package/lib/cli/routes/gateway.status.ts +19 -0
- package/lib/cli/routes/gateway.stop.ts +32 -0
- package/lib/cli/routes/gateway.ts +55 -0
- package/lib/cli/routes/index.ts +202 -0
- package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
- package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
- package/lib/cli/routes/profiles.$profile.run.ts +36 -0
- package/lib/cli/routes/profiles.add.$profile.ts +46 -0
- package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
- package/lib/cli/routes/profiles.set.$profile.ts +46 -0
- package/lib/cli/routes/profiles.ts +40 -0
- package/lib/cli/routes/status.ts +93 -0
- package/lib/cli/routes/update.ts +27 -0
- package/lib/connectors/connector-config-schema.ts +16 -0
- package/lib/connectors/connector-factory.ts +94 -0
- package/lib/connectors/connector-listener.ts +20 -0
- package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
- package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
- package/lib/connectors/discord-listener.ts +111 -0
- package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
- package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
- package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
- package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
- package/lib/connectors/schedule-connector-schema.ts +33 -0
- package/lib/connectors/schedule-listener.ts +207 -0
- package/lib/connectors/schedule-state-store.ts +54 -0
- package/lib/connectors/slack-adapter.ts +36 -0
- package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
- package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
- package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
- package/lib/engine/channels/channels.ts +520 -0
- package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
- package/lib/engine/claude/gateway-controller.ts +4 -0
- package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
- package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
- package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
- package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
- package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
- package/lib/engine/id/id-generator.ts +7 -0
- package/lib/engine/id/memory-id-generator.ts +20 -0
- package/lib/engine/id/node-id-generator.ts +7 -0
- package/lib/engine/logger/logger.ts +11 -0
- package/lib/engine/logger/memory-logger.ts +28 -0
- package/lib/engine/logger/node-logger.ts +49 -0
- package/lib/engine/logger/noop-logger.ts +9 -0
- package/lib/engine/mcp/channel-server.ts +204 -0
- package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
- package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
- package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
- package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
- package/lib/engine/profiles/profile-channel-checker.ts +7 -0
- package/lib/engine/profiles/profiles.ts +126 -0
- package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
- package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
- package/lib/engine/settings/settings-schema.ts +46 -0
- package/lib/engine/settings/settings-store.ts +110 -0
- package/lib/engine/time/clock.ts +15 -0
- package/lib/engine/time/memory-clock.ts +26 -0
- package/lib/engine/time/node-clock.ts +7 -0
- package/lib/funnel.ts +148 -56
- package/lib/gateway/auth-middleware.ts +44 -0
- package/lib/gateway/broadcaster.ts +319 -0
- package/lib/gateway/daemon.ts +47 -0
- package/lib/gateway/factory.ts +10 -0
- package/lib/gateway/funnel-event-store.ts +155 -0
- package/lib/gateway/gateway-server.ts +414 -0
- package/lib/gateway/gateway-token.ts +79 -0
- package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
- package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
- package/lib/gateway/listener-supervisor.ts +339 -0
- package/lib/gateway/listeners-client.ts +128 -0
- package/lib/gateway/resolve-daemon-script.ts +26 -0
- package/lib/gateway/routes/channels.connectors.call.ts +39 -0
- package/lib/gateway/routes/health.ts +13 -0
- package/lib/gateway/routes/index.ts +24 -0
- package/lib/gateway/routes/listeners.list.ts +6 -0
- package/lib/gateway/routes/listeners.restart.ts +15 -0
- package/lib/gateway/routes/listeners.start.ts +15 -0
- package/lib/gateway/routes/listeners.stop.ts +15 -0
- package/lib/gateway/routes/route-deps.ts +11 -0
- package/lib/gateway/routes/status.ts +15 -0
- package/lib/gateway/routes/validator.ts +17 -0
- package/lib/index.ts +50 -92
- package/lib/logger/leuco-human-file-writer.ts +65 -0
- package/lib/logger/leuco-human-logger.ts +98 -0
- package/lib/logger/leuco-human-record.ts +16 -0
- package/lib/logger/leuco-human-stdout-writer.ts +26 -0
- package/lib/logger/leuco-human-writer.ts +14 -0
- package/lib/logger/leuco-logger-memory-sink.ts +67 -0
- package/lib/logger/leuco-logger-record.ts +13 -0
- package/lib/logger/leuco-logger-sink.ts +33 -0
- package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
- package/lib/logger/leuco-logger.ts +135 -0
- package/lib/tui/app.tsx +357 -0
- package/lib/tui/components/add-row.tsx +18 -0
- package/lib/tui/components/brand.tsx +27 -0
- package/lib/tui/components/card.tsx +44 -0
- package/lib/tui/components/detail-bar.tsx +46 -0
- package/lib/tui/components/editable-field.tsx +33 -0
- package/lib/tui/components/empty-state.tsx +11 -0
- package/lib/tui/components/gateway-status.tsx +66 -0
- package/lib/tui/components/keymap.tsx +29 -0
- package/lib/tui/components/menu-item.tsx +73 -0
- package/lib/tui/components/menu.tsx +26 -0
- package/lib/tui/components/panel-header.tsx +22 -0
- package/lib/tui/components/readonly-field.tsx +18 -0
- package/lib/tui/components/section-header.tsx +25 -0
- package/lib/tui/components/selection-accent.tsx +32 -0
- package/lib/tui/components/session-item.tsx +33 -0
- package/lib/tui/components/session-list.tsx +33 -0
- package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
- package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
- package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
- package/lib/tui/components/ui/hascii/badge.tsx +51 -0
- package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
- package/lib/tui/components/ui/hascii/button.tsx +194 -0
- package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
- package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
- package/lib/tui/components/ui/hascii/card.tsx +27 -0
- package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
- package/lib/tui/components/ui/hascii/command.tsx +159 -0
- package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
- package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
- package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
- package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
- package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
- package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
- package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
- package/lib/tui/components/ui/hascii/input.tsx +130 -0
- package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
- package/lib/tui/components/ui/hascii/progress.tsx +28 -0
- package/lib/tui/components/ui/hascii/select.tsx +131 -0
- package/lib/tui/components/ui/hascii/separator.tsx +35 -0
- package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
- package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
- package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
- package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
- package/lib/tui/components/ui/hascii/slider.tsx +91 -0
- package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
- package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
- package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
- package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
- package/lib/tui/components/ui/hascii/switch.tsx +66 -0
- package/lib/tui/components/ui/hascii/table.tsx +95 -0
- package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
- package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
- package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
- package/lib/tui/components/ui/hascii/tree.tsx +104 -0
- package/lib/tui/components/view-shell.tsx +44 -0
- package/lib/tui/filter-input.tsx +33 -0
- package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
- package/lib/tui/parse-comma-list.ts +14 -0
- package/lib/tui/profile-launcher.tsx +61 -0
- package/lib/tui/scrollbar-options.ts +19 -0
- package/lib/tui/sidebar.tsx +50 -0
- package/lib/tui/theme.ts +40 -0
- package/lib/tui/tui.tsx +20 -0
- package/lib/tui/types.ts +38 -0
- package/lib/tui/unique-name.ts +18 -0
- package/lib/tui/use-event-stream.ts +133 -0
- package/lib/tui/use-snapshot.ts +99 -0
- package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
- package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
- package/lib/tui/utils/hascii/theme-context.tsx +26 -0
- package/lib/tui/utils/hascii/theme.ts +176 -0
- package/lib/tui/views/channels-view.tsx +108 -0
- package/lib/tui/views/connectors-view.tsx +164 -0
- package/lib/tui/views/events-view.tsx +160 -0
- package/lib/tui/views/listeners-view.tsx +80 -0
- package/lib/tui/views/profiles-view.tsx +152 -0
- package/package.json +51 -34
- package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
- package/lib/modules/channels/funnel-channels.ts +0 -155
- package/lib/modules/connectors/connector-config-schema.ts +0 -16
- package/lib/modules/connectors/connector-existence-checker.ts +0 -3
- package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
- package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
- package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
- package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
- package/lib/modules/connectors/funnel-connectors.ts +0 -145
- package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
- package/lib/modules/connectors/funnel-discord-store.ts +0 -84
- package/lib/modules/connectors/funnel-gh-store.ts +0 -84
- package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
- package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
- package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
- package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
- package/lib/modules/connectors/funnel-slack-store.ts +0 -86
- package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
- package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
- package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
- package/lib/modules/gateway/daemon.ts +0 -207
- package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
- package/lib/modules/gateway/funnel-event-logger.ts +0 -59
- package/lib/modules/logger.ts +0 -26
- package/lib/modules/mcp/channel-server.ts +0 -76
- package/lib/modules/profiles/funnel-profiles.ts +0 -123
- package/lib/modules/profiles/profile-channel-checker.ts +0 -3
- package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
- package/lib/modules/repos/funnel-repositories.ts +0 -107
- package/lib/modules/schedule/funnel-schedule.ts +0 -34
- package/lib/modules/settings/funnel-settings-store.ts +0 -56
- package/lib/modules/settings/settings-schema.ts +0 -33
- package/lib/modules/tui/app.tsx +0 -44
- package/lib/modules/tui/tui.tsx +0 -13
- package/lib/routes/channels/add.help.ts +0 -3
- package/lib/routes/channels/add.ts +0 -21
- package/lib/routes/channels/connectors-attach.help.ts +0 -3
- package/lib/routes/channels/connectors-attach.ts +0 -17
- package/lib/routes/channels/connectors-detach.help.ts +0 -3
- package/lib/routes/channels/connectors-detach.ts +0 -17
- package/lib/routes/channels/group.help.ts +0 -16
- package/lib/routes/channels/group.ts +0 -22
- package/lib/routes/channels/remove.help.ts +0 -3
- package/lib/routes/channels/remove.ts +0 -17
- package/lib/routes/channels/rename.help.ts +0 -5
- package/lib/routes/channels/rename.ts +0 -17
- package/lib/routes/channels/routes.ts +0 -19
- package/lib/routes/channels/show.help.ts +0 -1
- package/lib/routes/channels/show.ts +0 -26
- package/lib/routes/claude/claude.help.ts +0 -16
- package/lib/routes/claude/claude.ts +0 -76
- package/lib/routes/claude/routes.ts +0 -4
- package/lib/routes/connectors/add.help.ts +0 -28
- package/lib/routes/connectors/add.ts +0 -64
- package/lib/routes/connectors/group.help.ts +0 -14
- package/lib/routes/connectors/group.ts +0 -18
- package/lib/routes/connectors/remove.help.ts +0 -3
- package/lib/routes/connectors/remove.ts +0 -17
- package/lib/routes/connectors/rename.help.ts +0 -5
- package/lib/routes/connectors/rename.ts +0 -17
- package/lib/routes/connectors/routes.ts +0 -23
- package/lib/routes/connectors/schedules-add.help.ts +0 -11
- package/lib/routes/connectors/schedules-add.ts +0 -33
- package/lib/routes/connectors/schedules-group.help.ts +0 -1
- package/lib/routes/connectors/schedules-group.ts +0 -38
- package/lib/routes/connectors/schedules-remove.help.ts +0 -3
- package/lib/routes/connectors/schedules-remove.ts +0 -17
- package/lib/routes/connectors/set.help.ts +0 -8
- package/lib/routes/connectors/set.ts +0 -72
- package/lib/routes/connectors/show.help.ts +0 -1
- package/lib/routes/connectors/show.ts +0 -41
- package/lib/routes/gateway/group.help.ts +0 -15
- package/lib/routes/gateway/group.ts +0 -28
- package/lib/routes/gateway/logs.help.ts +0 -13
- package/lib/routes/gateway/logs.ts +0 -100
- package/lib/routes/gateway/restart.help.ts +0 -10
- package/lib/routes/gateway/routes.ts +0 -18
- package/lib/routes/gateway/run.help.ts +0 -12
- package/lib/routes/gateway/run.ts +0 -35
- package/lib/routes/gateway/start.help.ts +0 -15
- package/lib/routes/gateway/start.ts +0 -32
- package/lib/routes/gateway/status.help.ts +0 -9
- package/lib/routes/gateway/status.ts +0 -28
- package/lib/routes/gateway/stop.help.ts +0 -8
- package/lib/routes/gateway/stop.ts +0 -21
- package/lib/routes/profiles/add.help.ts +0 -3
- package/lib/routes/profiles/add.ts +0 -33
- package/lib/routes/profiles/group.help.ts +0 -16
- package/lib/routes/profiles/group.ts +0 -25
- package/lib/routes/profiles/launch.help.ts +0 -4
- package/lib/routes/profiles/launch.ts +0 -36
- package/lib/routes/profiles/remove.help.ts +0 -3
- package/lib/routes/profiles/remove.ts +0 -17
- package/lib/routes/profiles/rename.help.ts +0 -5
- package/lib/routes/profiles/rename.ts +0 -17
- package/lib/routes/profiles/routes.ts +0 -18
- package/lib/routes/profiles/set.help.ts +0 -5
- package/lib/routes/profiles/set.ts +0 -32
- package/lib/routes/repos/add.help.ts +0 -6
- package/lib/routes/repos/add.ts +0 -20
- package/lib/routes/repos/group.help.ts +0 -11
- package/lib/routes/repos/group.ts +0 -18
- package/lib/routes/repos/remove.help.ts +0 -3
- package/lib/routes/repos/remove.ts +0 -17
- package/lib/routes/repos/rename.help.ts +0 -5
- package/lib/routes/repos/rename.ts +0 -17
- package/lib/routes/repos/routes.ts +0 -17
- package/lib/routes/repos/set.help.ts +0 -5
- package/lib/routes/repos/set.ts +0 -21
- package/lib/routes/repos/show.help.ts +0 -1
- package/lib/routes/repos/show.ts +0 -19
- package/lib/routes/request/discord-help.ts +0 -9
- package/lib/routes/request/discord.help.ts +0 -19
- package/lib/routes/request/discord.ts +0 -65
- package/lib/routes/request/group.help.ts +0 -15
- package/lib/routes/request/group.ts +0 -9
- package/lib/routes/request/routes.ts +0 -14
- package/lib/routes/request/slack-help.ts +0 -9
- package/lib/routes/request/slack.help.ts +0 -19
- package/lib/routes/request/slack.ts +0 -61
- package/lib/routes/status/routes.ts +0 -4
- package/lib/routes/status/status.help.ts +0 -6
- package/lib/routes/status/status.ts +0 -77
- package/lib/routes/update/routes.ts +0 -4
- package/lib/routes/update/update.help.ts +0 -5
- package/lib/routes/update/update.ts +0 -21
- package/lib/routes.ts +0 -40
- /package/lib/{factory.ts → cli/factory.ts} +0 -0
- /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
- /package/lib/{modules → cli}/router/validator.ts +0 -0
- /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
- /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
- /package/lib/{modules/http/funnel-http-client.ts → engine/http/http-client.ts} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FunnelClock } from "@/engine/time/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
|
+
}
|
package/lib/funnel.ts
CHANGED
|
@@ -1,95 +1,187 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "@/
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { FunnelConnectorFactory } from "@/connectors/connector-factory"
|
|
3
|
+
import { FunnelChannels } from "@/engine/channels/channels"
|
|
4
|
+
import { FunnelClaude } from "@/engine/claude/claude"
|
|
5
|
+
import type { FunnelFileSystem } from "@/engine/fs/file-system"
|
|
6
|
+
import type { FunnelIdGenerator } from "@/engine/id/id-generator"
|
|
7
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
8
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
9
|
+
import { FunnelMcp } from "@/engine/mcp/mcp"
|
|
10
|
+
import { FunnelProcessRunner } from "@/engine/process/process-runner"
|
|
11
|
+
import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
|
|
12
|
+
import { FunnelProfiles } from "@/engine/profiles/profiles"
|
|
13
|
+
import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
|
|
14
|
+
import { FUNNEL_DIR, FunnelSettingsStore } from "@/engine/settings/settings-store"
|
|
15
|
+
import type { FunnelClock } from "@/engine/time/clock"
|
|
16
|
+
import { FunnelGateway } from "@/gateway/gateway"
|
|
17
|
+
import { FunnelGatewayServer } from "@/gateway/gateway-server"
|
|
18
|
+
import { FunnelGatewayToken } from "@/gateway/gateway-token"
|
|
19
|
+
import { FunnelListenersClient } from "@/gateway/listeners-client"
|
|
15
20
|
|
|
16
21
|
type Props = {
|
|
17
|
-
|
|
22
|
+
/** Settings persistence (channels with nested connectors / profiles). Defaults to a FunnelSettingsStore rooted at `dir`. */
|
|
23
|
+
store?: FunnelSettingsReader
|
|
24
|
+
/** Filesystem boundary. Replace with MemoryFunnelFileSystem to sandbox all disk I/O. */
|
|
18
25
|
fs?: FunnelFileSystem
|
|
26
|
+
/** Process runner used by gateway / claude / gh listener. Replace with MemoryFunnelProcessRunner for tests. */
|
|
27
|
+
process?: FunnelProcessRunner
|
|
28
|
+
/** Logger flowed into every facet. Replace with MemoryFunnelLogger or NoopFunnelLogger to silence/inspect. */
|
|
29
|
+
logger?: FunnelLogger
|
|
30
|
+
/** Clock used by schedule listener, gh poll watermarks, and gateway timeouts. */
|
|
31
|
+
clock?: FunnelClock
|
|
32
|
+
/** ID generator for channel and connector ids. Use MemoryFunnelIdGenerator for deterministic tests. */
|
|
33
|
+
idGenerator?: FunnelIdGenerator
|
|
34
|
+
/** Funnel home directory (settings.json + per-channel/per-connector dirs). Defaults to ~/.funnel. */
|
|
19
35
|
dir?: string
|
|
20
|
-
|
|
36
|
+
/** Temp / runtime directory (gateway logs and PID adjacent files). Defaults to /tmp/funnel. */
|
|
37
|
+
tmpDir?: string
|
|
21
38
|
}
|
|
22
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Facade exposing every funnel facet as a getter.
|
|
42
|
+
*
|
|
43
|
+
* The same `Funnel` is used by the CLI, the TUI, and as a programmable library.
|
|
44
|
+
* All side-effecting boundaries (filesystem, process, logger, clock, id, paths) are
|
|
45
|
+
* injectable via `Props` — passing memory implementations gives a fully sandboxed
|
|
46
|
+
* Funnel that touches no real disk, processes, or wall-clock time.
|
|
47
|
+
*
|
|
48
|
+
* Connectors live nested inside their owning channel (channels[].connectors[]),
|
|
49
|
+
* so connector CRUD is reached via `funnel.channels.addConnector(...)` etc.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const funnel = new Funnel({})
|
|
54
|
+
* const channel = funnel.channels.add({ name: "inbox" })
|
|
55
|
+
* funnel.channels.addConnector("inbox", { type: "slack", name: "ops", botToken, appToken })
|
|
56
|
+
* await funnel.gatewayServer({ port: 9742 }).start()
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
23
59
|
export class Funnel {
|
|
24
|
-
constructor(private readonly props: Props) {
|
|
60
|
+
constructor(private readonly props: Props = {}) {
|
|
25
61
|
Object.freeze(this)
|
|
26
62
|
}
|
|
27
63
|
|
|
28
|
-
|
|
64
|
+
/** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
|
|
65
|
+
get store(): FunnelSettingsReader {
|
|
29
66
|
return (
|
|
30
|
-
this.props.
|
|
31
|
-
|
|
67
|
+
this.props.store ??
|
|
68
|
+
new FunnelSettingsStore({
|
|
69
|
+
path: join(this.props.dir ?? FUNNEL_DIR, "settings.json"),
|
|
70
|
+
fs: this.props.fs,
|
|
71
|
+
})
|
|
32
72
|
)
|
|
33
73
|
}
|
|
34
74
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const channels: FunnelChannels = new FunnelChannels({
|
|
39
|
-
store: this.props.store,
|
|
40
|
-
connectorChecker: { has: (name) => connectors.has(name) },
|
|
41
|
-
profileChecker: profiles,
|
|
42
|
-
profileRefUpdater: profiles,
|
|
43
|
-
})
|
|
44
|
-
const connectors: FunnelConnectors = new FunnelConnectors({
|
|
45
|
-
...stores,
|
|
46
|
-
refUpdater: channels,
|
|
47
|
-
})
|
|
48
|
-
return connectors
|
|
75
|
+
/** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
|
|
76
|
+
get process(): FunnelProcessRunner {
|
|
77
|
+
return this.props.process ?? new NodeFunnelProcessRunner()
|
|
49
78
|
}
|
|
50
79
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
/** Logger boundary. Defaults to NodeFunnelLogger. */
|
|
81
|
+
get logger(): FunnelLogger {
|
|
82
|
+
return this.props.logger ?? new NodeFunnelLogger()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pure factory that constructs per-type listeners and adapters from connector configs. */
|
|
86
|
+
get factory(): FunnelConnectorFactory {
|
|
87
|
+
return new FunnelConnectorFactory({
|
|
88
|
+
fs: this.props.fs,
|
|
89
|
+
process: this.props.process,
|
|
90
|
+
logger: this.props.logger,
|
|
91
|
+
dir: this.props.dir,
|
|
63
92
|
})
|
|
64
|
-
return channels
|
|
65
93
|
}
|
|
66
94
|
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
/** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
|
|
96
|
+
get channels(): FunnelChannels {
|
|
97
|
+
return new FunnelChannels({
|
|
98
|
+
store: this.store,
|
|
99
|
+
factory: this.factory,
|
|
100
|
+
profileChecker: this.profiles,
|
|
101
|
+
clock: this.props.clock,
|
|
102
|
+
idGenerator: this.props.idGenerator,
|
|
103
|
+
})
|
|
69
104
|
}
|
|
70
105
|
|
|
106
|
+
/** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
|
|
71
107
|
get profiles(): FunnelProfiles {
|
|
72
|
-
return new FunnelProfiles({ store: this.
|
|
108
|
+
return new FunnelProfiles({ store: this.store })
|
|
73
109
|
}
|
|
74
110
|
|
|
75
|
-
|
|
76
|
-
|
|
111
|
+
/** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
|
|
112
|
+
get mcp(): FunnelMcp {
|
|
113
|
+
return new FunnelMcp({ fs: this.props.fs })
|
|
77
114
|
}
|
|
78
115
|
|
|
116
|
+
/** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
|
|
79
117
|
get claude(): FunnelClaude {
|
|
80
118
|
return new FunnelClaude({
|
|
81
119
|
channels: this.channels,
|
|
82
|
-
repositories: this.repositories,
|
|
83
120
|
mcp: this.mcp,
|
|
84
121
|
gateway: this.gateway,
|
|
122
|
+
fs: this.props.fs,
|
|
123
|
+
process: this.props.process,
|
|
124
|
+
logger: this.props.logger,
|
|
125
|
+
dir: this.props.dir,
|
|
85
126
|
})
|
|
86
127
|
}
|
|
87
128
|
|
|
129
|
+
/** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
|
|
88
130
|
get gateway(): FunnelGateway {
|
|
89
|
-
return new FunnelGateway(
|
|
131
|
+
return new FunnelGateway({
|
|
132
|
+
fs: this.props.fs,
|
|
133
|
+
process: this.props.process,
|
|
134
|
+
clock: this.props.clock,
|
|
135
|
+
dir: this.props.dir,
|
|
136
|
+
tmpDir: this.props.tmpDir,
|
|
137
|
+
})
|
|
90
138
|
}
|
|
91
139
|
|
|
92
|
-
|
|
93
|
-
|
|
140
|
+
/** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
|
|
141
|
+
get gatewayToken(): FunnelGatewayToken {
|
|
142
|
+
return new FunnelGatewayToken({ fs: this.props.fs, dir: this.props.dir })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* HTTP client for listener operations on the running gateway daemon.
|
|
147
|
+
* Returns `{ state: "offline" }` when the daemon is offline so hot-reload
|
|
148
|
+
* paths stay write-only without parsing strings.
|
|
149
|
+
*/
|
|
150
|
+
get listeners(): FunnelListenersClient {
|
|
151
|
+
const gateway = this.gateway
|
|
152
|
+
const token = this.gatewayToken
|
|
153
|
+
|
|
154
|
+
return new FunnelListenersClient({
|
|
155
|
+
port: gateway.getPort(),
|
|
156
|
+
isDaemonRunning: () => gateway.isRunning(),
|
|
157
|
+
getToken: () => token.read(),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
|
|
163
|
+
* this returns a class that runs `Bun.serve` + listeners inside the current process —
|
|
164
|
+
* useful for tests, embedding, or custom hosts.
|
|
165
|
+
*/
|
|
166
|
+
gatewayServer(
|
|
167
|
+
options: {
|
|
168
|
+
port?: number
|
|
169
|
+
logDir?: string
|
|
170
|
+
killCompetingSlack?: boolean
|
|
171
|
+
/** Override the auth token. Defaults to the persisted gateway.token. Pass "" to disable auth (tests). */
|
|
172
|
+
token?: string
|
|
173
|
+
} = {},
|
|
174
|
+
): FunnelGatewayServer {
|
|
175
|
+
return new FunnelGatewayServer({
|
|
176
|
+
channels: this.channels,
|
|
177
|
+
settings: this.store,
|
|
178
|
+
port: options.port,
|
|
179
|
+
logDir: options.logDir,
|
|
180
|
+
process: this.props.process,
|
|
181
|
+
clock: this.props.clock,
|
|
182
|
+
logger: this.props.logger,
|
|
183
|
+
killCompetingSlack: options.killCompetingSlack,
|
|
184
|
+
token: options.token ?? this.gatewayToken.ensure(),
|
|
185
|
+
})
|
|
94
186
|
}
|
|
95
187
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto"
|
|
2
|
+
import type { MiddlewareHandler } from "hono"
|
|
3
|
+
import type { Env } from "@/gateway/factory"
|
|
4
|
+
|
|
5
|
+
type Deps = {
|
|
6
|
+
expected: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
|
|
11
|
+
* Mounted on the routes that mutate listener state or expose detailed status.
|
|
12
|
+
* `/health` is intentionally left unauthenticated so the daemon manager can
|
|
13
|
+
* probe liveness without needing the token.
|
|
14
|
+
*/
|
|
15
|
+
export const requireBearerToken = (deps: Deps): MiddlewareHandler<Env> => {
|
|
16
|
+
return async (c, next) => {
|
|
17
|
+
const header = c.req.header("authorization") ?? ""
|
|
18
|
+
const match = header.match(/^Bearer\s+(.+)$/i)
|
|
19
|
+
const presented = match?.[1] ?? ""
|
|
20
|
+
|
|
21
|
+
if (!constantTimeEqual(presented, deps.expected)) {
|
|
22
|
+
return c.text("unauthorized", 401)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return await next()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const constantTimeEqual = (a: string, b: string): boolean => {
|
|
30
|
+
const bufA = Buffer.from(a, "utf-8")
|
|
31
|
+
const bufB = Buffer.from(b, "utf-8")
|
|
32
|
+
const maxLen = Math.max(bufA.length, bufB.length, 1)
|
|
33
|
+
const padA = Buffer.alloc(maxLen)
|
|
34
|
+
const padB = Buffer.alloc(maxLen)
|
|
35
|
+
|
|
36
|
+
bufA.copy(padA)
|
|
37
|
+
bufB.copy(padB)
|
|
38
|
+
|
|
39
|
+
// timingSafeEqual on equal-length padded buffers, then AND with length match
|
|
40
|
+
// so a length-only probe still requires the full comparison time.
|
|
41
|
+
const equal = timingSafeEqual(padA, padB)
|
|
42
|
+
|
|
43
|
+
return equal && bufA.length === bufB.length
|
|
44
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun"
|
|
2
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
3
|
+
import { NoopFunnelLogger } from "@/engine/logger/noop-logger"
|
|
4
|
+
|
|
5
|
+
const byteLengthOf = (event: { content: string; meta?: Record<string, string> }): number => {
|
|
6
|
+
let bytes = Buffer.byteLength(event.content, "utf-8")
|
|
7
|
+
|
|
8
|
+
if (event.meta) {
|
|
9
|
+
for (const [k, v] of Object.entries(event.meta)) {
|
|
10
|
+
bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8")
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return bytes
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ClientData = {
|
|
18
|
+
/** Stable channel id (uuid) that the WS client subscribed to. */
|
|
19
|
+
channel: string
|
|
20
|
+
/** Human-facing channel name resolved at upgrade time, kept for log readability. */
|
|
21
|
+
channelName?: string | null
|
|
22
|
+
/** Connector names belonging to that channel; used by tap-all replay filtering. */
|
|
23
|
+
connectors: string[]
|
|
24
|
+
tapAll?: boolean
|
|
25
|
+
/** Routing mode resolved from channel config at upgrade time. Defaults to fanout. */
|
|
26
|
+
delivery?: "fanout" | "exclusive"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type BroadcastEvent = {
|
|
30
|
+
content: string
|
|
31
|
+
meta?: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ReplayableEvent = BroadcastEvent & { offset: number }
|
|
35
|
+
|
|
36
|
+
export type BroadcastSubscriber = (event: ReplayableEvent) => void
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Optional persistent replay source. Wired in by the gateway-server with
|
|
40
|
+
* `FunnelEventStore` (SQLite-backed) so reconnects across daemon restarts
|
|
41
|
+
* can recover events older than the in-memory buffer via an indexed
|
|
42
|
+
* `seq > since` range scan.
|
|
43
|
+
*/
|
|
44
|
+
type ReplaySource = {
|
|
45
|
+
loadSince(since: number): ReplayableEvent[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Deps = {
|
|
49
|
+
logger?: FunnelLogger
|
|
50
|
+
maxBufferedBytes?: number
|
|
51
|
+
now?: () => number
|
|
52
|
+
/** Number of recent events kept in the in-memory replay buffer. */
|
|
53
|
+
replayBufferSize?: number
|
|
54
|
+
/** Hard byte cap on replay buffer payloads. Older events are evicted FIFO until under this cap. */
|
|
55
|
+
replayBufferMaxBytes?: number
|
|
56
|
+
/** Persistent replay source consulted when the in-memory buffer cannot satisfy `since`. */
|
|
57
|
+
persistentReplay?: ReplaySource
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024
|
|
61
|
+
const DEFAULT_REPLAY_BUFFER_SIZE = 200
|
|
62
|
+
const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024
|
|
63
|
+
const defaultLogger = new NoopFunnelLogger()
|
|
64
|
+
|
|
65
|
+
type BroadcasterMetrics = {
|
|
66
|
+
clients: number
|
|
67
|
+
subscribers: number
|
|
68
|
+
eventsBroadcast: number
|
|
69
|
+
droppedSlowClients: number
|
|
70
|
+
lastBroadcastAt: string | null
|
|
71
|
+
/** Latest emitted offset. Clients can `?since=<offset>` to ask for events strictly after this point. */
|
|
72
|
+
latestOffset: number
|
|
73
|
+
/** Oldest offset still held in the replay buffer. Older values cannot be replayed and trigger a full resync. */
|
|
74
|
+
oldestReplayableOffset: number | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* In-process pub/sub for connector events.
|
|
79
|
+
*
|
|
80
|
+
* Two outbound paths:
|
|
81
|
+
* - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
|
|
82
|
+
* - In-process subscribers registered via `subscribe()` (programmable API)
|
|
83
|
+
*
|
|
84
|
+
* Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
|
|
85
|
+
* (default 1 MiB), the client is closed with code 1009 and dropped from the
|
|
86
|
+
* registry to keep one slow consumer from blocking the daemon.
|
|
87
|
+
*
|
|
88
|
+
* Replay: every emitted event gets a strictly increasing `offset`. The latest
|
|
89
|
+
* `replayBufferSize` events are kept in memory; reconnecting WS clients can
|
|
90
|
+
* pass `?since=<offset>` and the broadcaster resends matching events before
|
|
91
|
+
* resuming the live stream. The in-memory ring covers short reconnects;
|
|
92
|
+
* older history is served from the SQLite event store wired in as
|
|
93
|
+
* `persistentReplay`.
|
|
94
|
+
*/
|
|
95
|
+
export class FunnelBroadcaster {
|
|
96
|
+
private readonly clients: Map<ServerWebSocket<unknown>, ClientData> = new Map()
|
|
97
|
+
private readonly subscribers: Set<BroadcastSubscriber> = new Set()
|
|
98
|
+
private readonly logger: FunnelLogger
|
|
99
|
+
private readonly maxBufferedBytes: number
|
|
100
|
+
private readonly now: () => number
|
|
101
|
+
private readonly replayBufferSize: number
|
|
102
|
+
private readonly replayBufferMaxBytes: number
|
|
103
|
+
private readonly replayBuffer: ReplayableEvent[] = []
|
|
104
|
+
private readonly persistentReplay: ReplaySource | null
|
|
105
|
+
private readonly exclusiveCursor = new Map<string, number>()
|
|
106
|
+
private replayBufferBytes = 0
|
|
107
|
+
private eventsBroadcast = 0
|
|
108
|
+
private droppedSlowClients = 0
|
|
109
|
+
private lastBroadcastAt: number | null = null
|
|
110
|
+
private latestOffset = 0
|
|
111
|
+
|
|
112
|
+
constructor(deps: Deps = {}) {
|
|
113
|
+
this.logger = deps.logger ?? defaultLogger
|
|
114
|
+
this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES
|
|
115
|
+
this.now = deps.now ?? (() => Date.now())
|
|
116
|
+
this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE)
|
|
117
|
+
this.replayBufferMaxBytes = Math.max(
|
|
118
|
+
0,
|
|
119
|
+
deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES,
|
|
120
|
+
)
|
|
121
|
+
this.persistentReplay = deps.persistentReplay ?? null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getMetrics(): BroadcasterMetrics {
|
|
125
|
+
return {
|
|
126
|
+
clients: this.clients.size,
|
|
127
|
+
subscribers: this.subscribers.size,
|
|
128
|
+
eventsBroadcast: this.eventsBroadcast,
|
|
129
|
+
droppedSlowClients: this.droppedSlowClients,
|
|
130
|
+
lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
|
|
131
|
+
latestOffset: this.latestOffset,
|
|
132
|
+
oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns events with offset > since, filtered by the connector subscription
|
|
138
|
+
* rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
|
|
139
|
+
*
|
|
140
|
+
* Two-tier lookup:
|
|
141
|
+
* 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
|
|
142
|
+
* 2. If `since` predates the oldest in-memory entry and a persistent replay source
|
|
143
|
+
* is wired in (SQLite), the gap is filled from disk. This covers reconnects across
|
|
144
|
+
* daemon restarts where the in-memory buffer was lost.
|
|
145
|
+
*
|
|
146
|
+
* Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
|
|
147
|
+
*/
|
|
148
|
+
replaySince(since: number, data: ClientData): ReplayableEvent[] {
|
|
149
|
+
const oldestInMemory = this.replayBuffer[0]?.offset
|
|
150
|
+
const needFallback =
|
|
151
|
+
this.persistentReplay && (oldestInMemory === undefined || since < oldestInMemory - 1)
|
|
152
|
+
const fromMemory = this.replayBuffer.filter(
|
|
153
|
+
(event) => event.offset > since && this.matchesClient(event, data),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (!needFallback) return fromMemory
|
|
157
|
+
|
|
158
|
+
const persisted = this.persistentReplay
|
|
159
|
+
? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data))
|
|
160
|
+
: []
|
|
161
|
+
const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY
|
|
162
|
+
const beforeMemory = persisted.filter((event) => event.offset < cutoff)
|
|
163
|
+
|
|
164
|
+
return [...beforeMemory, ...fromMemory]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private matchesClient(event: BroadcastEvent, data: ClientData): boolean {
|
|
168
|
+
if (data.tapAll) return true
|
|
169
|
+
|
|
170
|
+
const channelId = event.meta?.channelId
|
|
171
|
+
|
|
172
|
+
if (channelId && channelId !== data.channel) return false
|
|
173
|
+
|
|
174
|
+
const connector = event.meta?.connector
|
|
175
|
+
|
|
176
|
+
if (!connector) return true
|
|
177
|
+
|
|
178
|
+
return data.connectors.includes(connector)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Returns the list of WS clients that should receive `event`. Tap=all clients always
|
|
183
|
+
* receive (passive observation). For each per-channel group:
|
|
184
|
+
* - fanout → every matching client receives
|
|
185
|
+
* - exclusive → exactly one client receives, picked round-robin per channel
|
|
186
|
+
*/
|
|
187
|
+
private pickRecipients(event: BroadcastEvent): ServerWebSocket<unknown>[] {
|
|
188
|
+
const exclusiveByChannel = new Map<string, ServerWebSocket<unknown>[]>()
|
|
189
|
+
const recipients: ServerWebSocket<unknown>[] = []
|
|
190
|
+
|
|
191
|
+
for (const [ws, data] of this.clients) {
|
|
192
|
+
if (!this.matchesClient(event, data)) continue
|
|
193
|
+
|
|
194
|
+
if (data.tapAll) {
|
|
195
|
+
recipients.push(ws)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (data.delivery === "exclusive") {
|
|
200
|
+
const list = exclusiveByChannel.get(data.channel) ?? []
|
|
201
|
+
|
|
202
|
+
list.push(ws)
|
|
203
|
+
exclusiveByChannel.set(data.channel, list)
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
recipients.push(ws)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const [channel, candidates] of exclusiveByChannel) {
|
|
211
|
+
if (candidates.length === 0) continue
|
|
212
|
+
|
|
213
|
+
const cursor = this.exclusiveCursor.get(channel) ?? 0
|
|
214
|
+
const picked = candidates[cursor % candidates.length]
|
|
215
|
+
|
|
216
|
+
if (picked) recipients.push(picked)
|
|
217
|
+
|
|
218
|
+
this.exclusiveCursor.set(channel, cursor + 1)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return recipients
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
addClient(ws: ServerWebSocket<unknown>, data: ClientData): void {
|
|
225
|
+
this.clients.set(ws, data)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
removeClient(ws: ServerWebSocket<unknown>): void {
|
|
229
|
+
this.clients.delete(ws)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getClientCount(): number {
|
|
233
|
+
return this.clients.size
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
listChannels(): { channel: string; connectors: string[] }[] {
|
|
237
|
+
return [...this.clients.values()].map((d) => ({ ...d }))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
subscribe(handler: BroadcastSubscriber): () => void {
|
|
241
|
+
this.subscribers.add(handler)
|
|
242
|
+
|
|
243
|
+
return () => {
|
|
244
|
+
this.subscribers.delete(handler)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
broadcast(content: string, meta?: Record<string, string>): ReplayableEvent {
|
|
249
|
+
this.latestOffset += 1
|
|
250
|
+
const event: ReplayableEvent = { content, meta, offset: this.latestOffset }
|
|
251
|
+
const payload = JSON.stringify(event)
|
|
252
|
+
const connector = meta?.connector
|
|
253
|
+
|
|
254
|
+
this.eventsBroadcast += 1
|
|
255
|
+
this.lastBroadcastAt = this.now()
|
|
256
|
+
|
|
257
|
+
if (this.replayBufferSize > 0) {
|
|
258
|
+
const eventBytes = byteLengthOf(event)
|
|
259
|
+
|
|
260
|
+
this.replayBuffer.push(event)
|
|
261
|
+
this.replayBufferBytes += eventBytes
|
|
262
|
+
|
|
263
|
+
while (
|
|
264
|
+
(this.replayBuffer.length > this.replayBufferSize ||
|
|
265
|
+
this.replayBufferBytes > this.replayBufferMaxBytes) &&
|
|
266
|
+
this.replayBuffer.length > 0
|
|
267
|
+
) {
|
|
268
|
+
const dropped = this.replayBuffer.shift()
|
|
269
|
+
|
|
270
|
+
if (dropped) this.replayBufferBytes -= byteLengthOf(dropped)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const recipients = this.pickRecipients(event)
|
|
275
|
+
|
|
276
|
+
for (const ws of recipients) {
|
|
277
|
+
const buffered = ws.getBufferedAmount()
|
|
278
|
+
|
|
279
|
+
if (buffered > this.maxBufferedBytes) {
|
|
280
|
+
const data = this.clients.get(ws)
|
|
281
|
+
|
|
282
|
+
this.logger.warn("dropping slow WS client (backpressure)", {
|
|
283
|
+
channel: data?.channel,
|
|
284
|
+
buffered,
|
|
285
|
+
max: this.maxBufferedBytes,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
ws.close(1009, "backpressure")
|
|
290
|
+
} catch {
|
|
291
|
+
// ignore
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.clients.delete(ws)
|
|
295
|
+
this.droppedSlowClients += 1
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
ws.send(payload)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const handler of this.subscribers) {
|
|
303
|
+
try {
|
|
304
|
+
handler(event)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
this.logger.error("broadcast subscriber threw", {
|
|
307
|
+
error: error instanceof Error ? error.message : String(error),
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return event
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Forward-seed the offset counter (used at startup from the persisted event store). */
|
|
316
|
+
seedLatestOffset(offset: number): void {
|
|
317
|
+
if (offset > this.latestOffset) this.latestOffset = offset
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { FUNNEL_DIR } from "@/engine/settings/settings-store"
|
|
5
|
+
import { Funnel } from "@/funnel"
|
|
6
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
7
|
+
|
|
8
|
+
const PORT = Number(process.env.FUNNEL_PORT) || 9742
|
|
9
|
+
const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
|
|
10
|
+
const LOG_DIR = "/tmp/funnel/events"
|
|
11
|
+
|
|
12
|
+
const logger = new NodeFunnelLogger()
|
|
13
|
+
|
|
14
|
+
mkdirSync(FUNNEL_DIR, { recursive: true })
|
|
15
|
+
|
|
16
|
+
if (existsSync(PID_FILE)) {
|
|
17
|
+
const existing = Number(readFileSync(PID_FILE, "utf-8").trim())
|
|
18
|
+
|
|
19
|
+
if (existing > 0) {
|
|
20
|
+
const check = Bun.spawnSync(["ps", "-p", String(existing), "-o", "state="], {
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (check.exitCode === 0 && check.stdout.toString().trim()) {
|
|
26
|
+
logger.error("funnel gateway already running", { pid: existing })
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
writeFileSync(PID_FILE, String(process.pid))
|
|
33
|
+
|
|
34
|
+
process.on("exit", () => {
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(PID_FILE)
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
process.on("SIGINT", () => process.exit(130))
|
|
42
|
+
process.on("SIGTERM", () => process.exit(143))
|
|
43
|
+
|
|
44
|
+
const funnel = new Funnel({ logger })
|
|
45
|
+
const server = funnel.gatewayServer({ port: PORT, logDir: LOG_DIR })
|
|
46
|
+
|
|
47
|
+
await server.start()
|