@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
|
@@ -1,31 +1,65 @@
|
|
|
1
|
-
import { join
|
|
2
|
-
import { FunnelFileSystem } from "@/
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { FunnelFileSystem } from "@/engine/fs/file-system"
|
|
3
|
+
import { resolveDaemonScript } from "@/gateway/resolve-daemon-script"
|
|
4
|
+
import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
|
|
5
|
+
import { FunnelProcessRunner } from "@/engine/process/process-runner"
|
|
6
|
+
import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
|
|
7
|
+
import { FUNNEL_DIR } from "@/engine/settings/settings-store"
|
|
8
|
+
import { FunnelClock } from "@/engine/time/clock"
|
|
9
|
+
import { NodeFunnelClock } from "@/engine/time/node-clock"
|
|
7
10
|
|
|
8
11
|
const DEFAULT_PORT = 9742
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
12
|
+
const DEFAULT_TMP_DIR = "/tmp/funnel"
|
|
13
|
+
const STARTUP_TIMEOUT_MS = 5000
|
|
14
|
+
const SIGTERM_TIMEOUT_MS = 2000
|
|
15
|
+
const POLL_INTERVAL_MS = 100
|
|
16
|
+
const SIGKILL_GRACE_MS = 200
|
|
13
17
|
|
|
14
18
|
type Deps = {
|
|
15
19
|
process?: FunnelProcessRunner
|
|
16
20
|
fs?: FunnelFileSystem
|
|
21
|
+
clock?: FunnelClock
|
|
22
|
+
dir?: string
|
|
23
|
+
tmpDir?: string
|
|
24
|
+
port?: number
|
|
25
|
+
sleep?: (ms: number) => Promise<void>
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
const defaultProcess = new NodeFunnelProcessRunner()
|
|
20
29
|
const defaultFs = new NodeFunnelFileSystem()
|
|
21
|
-
|
|
30
|
+
const defaultClock = new NodeFunnelClock()
|
|
31
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
32
|
+
new Promise((r) => {
|
|
33
|
+
setTimeout(r, ms)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Manages the gateway daemon as a separate process via PID file.
|
|
38
|
+
* Use `start()` to spawn `bun daemon.ts` in the background and `stop()` to
|
|
39
|
+
* terminate it. For an in-process gateway, use `Funnel.gatewayServer` instead.
|
|
40
|
+
*/
|
|
22
41
|
export class FunnelGateway {
|
|
23
42
|
private readonly process: FunnelProcessRunner
|
|
24
43
|
private readonly fs: FunnelFileSystem
|
|
44
|
+
private readonly clock: FunnelClock
|
|
45
|
+
private readonly pidFile: string
|
|
46
|
+
private readonly logDir: string
|
|
47
|
+
private readonly gatewayLog: string
|
|
48
|
+
private readonly tmpDir: string
|
|
49
|
+
private readonly port: number
|
|
50
|
+
private readonly sleep: (ms: number) => Promise<void>
|
|
25
51
|
|
|
26
52
|
constructor(deps: Deps = {}) {
|
|
27
53
|
this.process = deps.process ?? defaultProcess
|
|
28
54
|
this.fs = deps.fs ?? defaultFs
|
|
55
|
+
this.clock = deps.clock ?? defaultClock
|
|
56
|
+
const baseDir = deps.dir ?? FUNNEL_DIR
|
|
57
|
+
this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR
|
|
58
|
+
this.pidFile = join(baseDir, "gateway.pid")
|
|
59
|
+
this.logDir = join(this.tmpDir, "events")
|
|
60
|
+
this.gatewayLog = join(this.tmpDir, "gateway.log")
|
|
61
|
+
this.port = deps.port ?? DEFAULT_PORT
|
|
62
|
+
this.sleep = deps.sleep ?? defaultSleep
|
|
29
63
|
Object.freeze(this)
|
|
30
64
|
}
|
|
31
65
|
|
|
@@ -41,20 +75,25 @@ export class FunnelGateway {
|
|
|
41
75
|
const pid = this.readPid()
|
|
42
76
|
const running = pid !== null && this.isProcessAlive(pid)
|
|
43
77
|
|
|
44
|
-
return { running, pid: running ? pid : null, port:
|
|
78
|
+
return { running, pid: running ? pid : null, port: this.port }
|
|
45
79
|
}
|
|
46
80
|
|
|
47
81
|
async start(options: { caffeinate?: boolean } = {}): Promise<boolean> {
|
|
48
82
|
if (this.isRunning()) return true
|
|
49
83
|
|
|
50
|
-
this.fs.mkdirSync(
|
|
84
|
+
this.fs.mkdirSync(this.tmpDir, { recursive: true })
|
|
51
85
|
|
|
52
|
-
const gatewayScript =
|
|
86
|
+
const gatewayScript = resolveDaemonScript()
|
|
53
87
|
const command = this.buildStartCommand(gatewayScript, options)
|
|
54
88
|
|
|
55
89
|
this.process.detach(["bash", "-c", command])
|
|
56
90
|
|
|
57
|
-
|
|
91
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS
|
|
92
|
+
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
if (this.isRunning()) return true
|
|
95
|
+
await this.sleep(POLL_INTERVAL_MS)
|
|
96
|
+
}
|
|
58
97
|
|
|
59
98
|
return this.isRunning()
|
|
60
99
|
}
|
|
@@ -63,7 +102,7 @@ export class FunnelGateway {
|
|
|
63
102
|
const useCaffeinate = options.caffeinate !== false && globalThis.process.platform === "darwin"
|
|
64
103
|
const prefix = useCaffeinate ? "caffeinate -i " : ""
|
|
65
104
|
|
|
66
|
-
return `nohup ${prefix}bun ${gatewayScript} >> ${
|
|
105
|
+
return `nohup ${prefix}bun ${gatewayScript} >> ${this.gatewayLog} 2>&1 &`
|
|
67
106
|
}
|
|
68
107
|
|
|
69
108
|
async stop(): Promise<boolean> {
|
|
@@ -77,29 +116,29 @@ export class FunnelGateway {
|
|
|
77
116
|
}
|
|
78
117
|
|
|
79
118
|
try {
|
|
80
|
-
|
|
119
|
+
this.process.kill(pid, "SIGTERM")
|
|
81
120
|
} catch {
|
|
82
121
|
return false
|
|
83
122
|
}
|
|
84
123
|
|
|
85
|
-
const deadline =
|
|
124
|
+
const deadline = this.clock.millis() + SIGTERM_TIMEOUT_MS
|
|
86
125
|
|
|
87
|
-
while (
|
|
126
|
+
while (this.clock.millis() < deadline) {
|
|
88
127
|
if (!this.isProcessAlive(pid)) {
|
|
89
128
|
this.removePid()
|
|
90
129
|
return true
|
|
91
130
|
}
|
|
92
131
|
|
|
93
|
-
await
|
|
132
|
+
await this.sleep(POLL_INTERVAL_MS)
|
|
94
133
|
}
|
|
95
134
|
|
|
96
135
|
try {
|
|
97
|
-
|
|
136
|
+
this.process.kill(pid, "SIGKILL")
|
|
98
137
|
} catch {
|
|
99
138
|
// ignore
|
|
100
139
|
}
|
|
101
140
|
|
|
102
|
-
await
|
|
141
|
+
await this.sleep(SIGKILL_GRACE_MS)
|
|
103
142
|
this.removePid()
|
|
104
143
|
|
|
105
144
|
return !this.isProcessAlive(pid)
|
|
@@ -126,18 +165,22 @@ export class FunnelGateway {
|
|
|
126
165
|
}
|
|
127
166
|
|
|
128
167
|
getLogDir(): string {
|
|
129
|
-
return
|
|
168
|
+
return this.logDir
|
|
130
169
|
}
|
|
131
170
|
|
|
132
171
|
getGatewayLog(): string {
|
|
133
|
-
return
|
|
172
|
+
return this.gatewayLog
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getPort(): number {
|
|
176
|
+
return this.port
|
|
134
177
|
}
|
|
135
178
|
|
|
136
179
|
private readPid(): number | null {
|
|
137
|
-
if (!this.fs.existsSync(
|
|
180
|
+
if (!this.fs.existsSync(this.pidFile)) return null
|
|
138
181
|
|
|
139
182
|
try {
|
|
140
|
-
const content = this.fs.readFileSync(
|
|
183
|
+
const content = this.fs.readFileSync(this.pidFile).trim()
|
|
141
184
|
const pid = Number(content)
|
|
142
185
|
|
|
143
186
|
if (!pid || pid <= 0) return null
|
|
@@ -149,7 +192,7 @@ export class FunnelGateway {
|
|
|
149
192
|
}
|
|
150
193
|
|
|
151
194
|
private removePid(): void {
|
|
152
|
-
this.fs.unlink(
|
|
195
|
+
this.fs.unlink(this.pidFile)
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
private isProcessAlive(pid: number): boolean {
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
2
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
3
|
+
import { FunnelProcessRunner } from "@/engine/process/process-runner"
|
|
4
|
+
import { NodeFunnelProcessRunner } from "@/engine/process/node-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,339 @@
|
|
|
1
|
+
import type { ConnectorConfig } from "@/connectors/connector-config-schema"
|
|
2
|
+
import type { FunnelConnectorListener } from "@/connectors/connector-listener"
|
|
3
|
+
import type { ChannelConnectorView } from "@/engine/channels/channels"
|
|
4
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
5
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
6
|
+
|
|
7
|
+
type ConnectorRegistry = {
|
|
8
|
+
listAllConnectors(): ChannelConnectorView[]
|
|
9
|
+
createListener(
|
|
10
|
+
channelName: string,
|
|
11
|
+
connectorName: string,
|
|
12
|
+
): { config: ConnectorConfig; channelId: string; listener: FunnelConnectorListener } | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type SupervisorNotify = (
|
|
16
|
+
channelName: string,
|
|
17
|
+
connectorName: string,
|
|
18
|
+
content: string,
|
|
19
|
+
meta?: Record<string, string>,
|
|
20
|
+
) => Promise<void>
|
|
21
|
+
|
|
22
|
+
type RunningEntry = {
|
|
23
|
+
config: ConnectorConfig
|
|
24
|
+
channelName: string
|
|
25
|
+
channelId: string
|
|
26
|
+
listener: FunnelConnectorListener
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ListenerStats = {
|
|
30
|
+
events: number
|
|
31
|
+
errors: number
|
|
32
|
+
failureCount: number
|
|
33
|
+
lastEventAt: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Deps = {
|
|
37
|
+
channels: ConnectorRegistry
|
|
38
|
+
notify: SupervisorNotify
|
|
39
|
+
logger?: FunnelLogger
|
|
40
|
+
healthCheckIntervalMs?: number
|
|
41
|
+
maxBackoffMs?: number
|
|
42
|
+
sleep?: (ms: number) => Promise<void>
|
|
43
|
+
now?: () => number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
47
|
+
const DEFAULT_HEALTH_INTERVAL_MS = 30_000
|
|
48
|
+
const DEFAULT_MAX_BACKOFF_MS = 60_000
|
|
49
|
+
|
|
50
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
51
|
+
new Promise((r) => {
|
|
52
|
+
setTimeout(r, ms)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
type ListenerEntryStatus = {
|
|
56
|
+
channelName: string
|
|
57
|
+
channelId: string
|
|
58
|
+
name: string
|
|
59
|
+
type: ConnectorConfig["type"]
|
|
60
|
+
alive: boolean
|
|
61
|
+
events: number
|
|
62
|
+
errors: number
|
|
63
|
+
failureCount: number
|
|
64
|
+
lastEventAt: string | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Owns the running listener instances and their lifecycle.
|
|
69
|
+
*
|
|
70
|
+
* Lives in the gateway process and is the only place that calls
|
|
71
|
+
* `listener.start()` / `listener.stop()`. Each entry is keyed by
|
|
72
|
+
* `${channelName}/${connectorName}` so the same connector name can exist in
|
|
73
|
+
* multiple channels without colliding.
|
|
74
|
+
*
|
|
75
|
+
* Periodically polls each running listener's `isAlive()` and auto-restarts
|
|
76
|
+
* dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
|
|
77
|
+
* the backoff counter on successful restart.
|
|
78
|
+
*/
|
|
79
|
+
export class FunnelListenerSupervisor {
|
|
80
|
+
private readonly channels: ConnectorRegistry
|
|
81
|
+
private readonly notify: SupervisorNotify
|
|
82
|
+
private readonly logger: FunnelLogger
|
|
83
|
+
private readonly running = new Map<string, RunningEntry>()
|
|
84
|
+
private readonly failureCounts = new Map<string, number>()
|
|
85
|
+
private readonly stats = new Map<string, ListenerStats>()
|
|
86
|
+
private readonly healthCheckIntervalMs: number
|
|
87
|
+
private readonly maxBackoffMs: number
|
|
88
|
+
private readonly sleep: (ms: number) => Promise<void>
|
|
89
|
+
private readonly now: () => number
|
|
90
|
+
private healthCheckTimer: ReturnType<typeof setInterval> | null = null
|
|
91
|
+
private healthCheckInFlight = false
|
|
92
|
+
|
|
93
|
+
constructor(deps: Deps) {
|
|
94
|
+
this.channels = deps.channels
|
|
95
|
+
this.notify = deps.notify
|
|
96
|
+
this.logger = deps.logger ?? defaultLogger
|
|
97
|
+
this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
|
|
98
|
+
this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS
|
|
99
|
+
this.sleep = deps.sleep ?? defaultSleep
|
|
100
|
+
this.now = deps.now ?? (() => Date.now())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static keyOf(channelName: string, connectorName: string): string {
|
|
104
|
+
return `${channelName}/${connectorName}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
isRunning(channelName: string, connectorName: string): boolean {
|
|
108
|
+
return this.running.has(FunnelListenerSupervisor.keyOf(channelName, connectorName))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
list(): ListenerEntryStatus[] {
|
|
112
|
+
return [...this.running.entries()].map(([key, entry]) => {
|
|
113
|
+
const stats = this.stats.get(key)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
channelName: entry.channelName,
|
|
117
|
+
channelId: entry.channelId,
|
|
118
|
+
name: entry.config.name,
|
|
119
|
+
type: entry.config.type,
|
|
120
|
+
alive: entry.listener.isAlive(),
|
|
121
|
+
events: stats?.events ?? 0,
|
|
122
|
+
errors: stats?.errors ?? 0,
|
|
123
|
+
failureCount: this.failureCounts.get(key) ?? 0,
|
|
124
|
+
lastEventAt: stats?.lastEventAt ?? null,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async start(
|
|
130
|
+
channelName: string,
|
|
131
|
+
connectorName: string,
|
|
132
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
133
|
+
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName)
|
|
134
|
+
|
|
135
|
+
if (this.running.has(key)) {
|
|
136
|
+
return { ok: true, reason: "already running" }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const created = this.channels.createListener(channelName, connectorName)
|
|
140
|
+
|
|
141
|
+
if (!created) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reason: `connector "${connectorName}" not found in channel "${channelName}"`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const bind = async (content: string, meta?: Record<string, string>) => {
|
|
149
|
+
try {
|
|
150
|
+
await this.notify(channelName, connectorName, content, meta)
|
|
151
|
+
this.recordEvent(key)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.recordError(key)
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await created.listener.start(bind)
|
|
160
|
+
this.running.set(key, {
|
|
161
|
+
config: created.config,
|
|
162
|
+
channelName,
|
|
163
|
+
channelId: created.channelId,
|
|
164
|
+
listener: created.listener,
|
|
165
|
+
})
|
|
166
|
+
this.ensureStats(key)
|
|
167
|
+
this.logger.info(`${created.config.type} listener started`, {
|
|
168
|
+
channel: channelName,
|
|
169
|
+
connector: connectorName,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return { ok: true }
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.logger.error(`${created.config.type} listener failed to start`, {
|
|
175
|
+
channel: channelName,
|
|
176
|
+
connector: connectorName,
|
|
177
|
+
error: error instanceof Error ? error.message : String(error),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async stop(
|
|
188
|
+
channelName: string,
|
|
189
|
+
connectorName: string,
|
|
190
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
191
|
+
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName)
|
|
192
|
+
const entry = this.running.get(key)
|
|
193
|
+
|
|
194
|
+
if (!entry) return { ok: true, reason: "not running" }
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await entry.listener.stop()
|
|
198
|
+
this.running.delete(key)
|
|
199
|
+
this.failureCounts.delete(key)
|
|
200
|
+
this.logger.info(`${entry.config.type} listener stopped`, {
|
|
201
|
+
channel: channelName,
|
|
202
|
+
connector: connectorName,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return { ok: true }
|
|
206
|
+
} catch (error) {
|
|
207
|
+
this.logger.error(`${entry.config.type} listener failed to stop`, {
|
|
208
|
+
channel: channelName,
|
|
209
|
+
connector: connectorName,
|
|
210
|
+
error: error instanceof Error ? error.message : String(error),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async restart(
|
|
221
|
+
channelName: string,
|
|
222
|
+
connectorName: string,
|
|
223
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
224
|
+
const stopped = await this.stop(channelName, connectorName)
|
|
225
|
+
|
|
226
|
+
if (!stopped.ok) return stopped
|
|
227
|
+
|
|
228
|
+
return await this.start(channelName, connectorName)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async startAll(): Promise<void> {
|
|
232
|
+
const all = this.channels.listAllConnectors()
|
|
233
|
+
|
|
234
|
+
for (const view of all) {
|
|
235
|
+
await this.start(view.channelName, view.name)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.startHealthCheck()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async stopAll(): Promise<void> {
|
|
242
|
+
this.stopHealthCheck()
|
|
243
|
+
|
|
244
|
+
for (const [, entry] of [...this.running.entries()]) {
|
|
245
|
+
await this.stop(entry.channelName, entry.config.name)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private ensureStats(key: string): ListenerStats {
|
|
250
|
+
const existing = this.stats.get(key)
|
|
251
|
+
|
|
252
|
+
if (existing) return existing
|
|
253
|
+
|
|
254
|
+
const fresh: ListenerStats = { events: 0, errors: 0, failureCount: 0, lastEventAt: null }
|
|
255
|
+
|
|
256
|
+
this.stats.set(key, fresh)
|
|
257
|
+
|
|
258
|
+
return fresh
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private recordEvent(key: string): void {
|
|
262
|
+
const stats = this.ensureStats(key)
|
|
263
|
+
|
|
264
|
+
stats.events += 1
|
|
265
|
+
stats.lastEventAt = new Date(this.now()).toISOString()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private recordError(key: string): void {
|
|
269
|
+
this.ensureStats(key).errors += 1
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private startHealthCheck(): void {
|
|
273
|
+
if (this.healthCheckTimer) return
|
|
274
|
+
|
|
275
|
+
this.healthCheckTimer = setInterval(() => {
|
|
276
|
+
void this.runHealthCheck()
|
|
277
|
+
}, this.healthCheckIntervalMs)
|
|
278
|
+
|
|
279
|
+
this.healthCheckTimer.unref()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private stopHealthCheck(): void {
|
|
283
|
+
if (!this.healthCheckTimer) return
|
|
284
|
+
|
|
285
|
+
clearInterval(this.healthCheckTimer)
|
|
286
|
+
this.healthCheckTimer = null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async runHealthCheck(): Promise<void> {
|
|
290
|
+
if (this.healthCheckInFlight) return
|
|
291
|
+
|
|
292
|
+
this.healthCheckInFlight = true
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
for (const [key, entry] of [...this.running.entries()]) {
|
|
296
|
+
if (entry.listener.isAlive()) {
|
|
297
|
+
this.failureCounts.delete(key)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await this.recoverDead(entry.channelName, entry.config.name, entry.config.type)
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
this.healthCheckInFlight = false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async recoverDead(
|
|
309
|
+
channelName: string,
|
|
310
|
+
connectorName: string,
|
|
311
|
+
type: ConnectorConfig["type"],
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const key = FunnelListenerSupervisor.keyOf(channelName, connectorName)
|
|
314
|
+
const failureCount = this.failureCounts.get(key) ?? 0
|
|
315
|
+
const backoffMs = Math.min(1000 * 2 ** failureCount, this.maxBackoffMs)
|
|
316
|
+
|
|
317
|
+
this.logger.warn(`${type} listener unhealthy, restarting`, {
|
|
318
|
+
channel: channelName,
|
|
319
|
+
connector: connectorName,
|
|
320
|
+
attempt: failureCount + 1,
|
|
321
|
+
backoffMs,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
await this.stop(channelName, connectorName)
|
|
325
|
+
await this.sleep(backoffMs)
|
|
326
|
+
|
|
327
|
+
const result = await this.start(channelName, connectorName)
|
|
328
|
+
|
|
329
|
+
if (result.ok) {
|
|
330
|
+
this.failureCounts.delete(key)
|
|
331
|
+
this.logger.info(`${type} listener recovered`, {
|
|
332
|
+
channel: channelName,
|
|
333
|
+
connector: connectorName,
|
|
334
|
+
})
|
|
335
|
+
} else {
|
|
336
|
+
this.failureCounts.set(key, failureCount + 1)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
type Deps = {
|
|
4
|
+
port: number
|
|
5
|
+
isDaemonRunning: () => boolean
|
|
6
|
+
/** Returns the daemon's gateway token, or null if unavailable. Sent as `Authorization: Bearer`. */
|
|
7
|
+
getToken?: () => string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const listenerEntrySchema = z.object({
|
|
11
|
+
channelName: z.string(),
|
|
12
|
+
channelId: z.string(),
|
|
13
|
+
name: z.string(),
|
|
14
|
+
type: z.string(),
|
|
15
|
+
alive: z.boolean(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const listenersResponseSchema = z.object({
|
|
19
|
+
listeners: z.array(listenerEntrySchema),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const opErrorBodySchema = z.object({
|
|
23
|
+
reason: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export type ListenerEntry = z.infer<typeof listenerEntrySchema>
|
|
27
|
+
|
|
28
|
+
export type ListenerOpResult =
|
|
29
|
+
| { state: "ok" }
|
|
30
|
+
| { state: "offline" }
|
|
31
|
+
| { state: "error"; reason: string }
|
|
32
|
+
|
|
33
|
+
export type ListListenersResult =
|
|
34
|
+
| { state: "ok"; listeners: ListenerEntry[] }
|
|
35
|
+
| { state: "offline" }
|
|
36
|
+
| { state: "error"; reason: string }
|
|
37
|
+
|
|
38
|
+
const OFFLINE: ListenerOpResult = { state: "offline" }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* HTTP client for listener operations on a running gateway daemon.
|
|
42
|
+
*
|
|
43
|
+
* Returns `{ state: "offline" }` when the daemon isn't running so callers
|
|
44
|
+
* (CLI hot-reload paths) can treat that as a no-op without parsing strings.
|
|
45
|
+
* Pair this with `FunnelGateway` (process control) for the full picture.
|
|
46
|
+
*/
|
|
47
|
+
export class FunnelListenersClient {
|
|
48
|
+
private readonly port: number
|
|
49
|
+
private readonly isDaemonRunning: () => boolean
|
|
50
|
+
private readonly getToken: () => string | null
|
|
51
|
+
|
|
52
|
+
constructor(deps: Deps) {
|
|
53
|
+
this.port = deps.port
|
|
54
|
+
this.isDaemonRunning = deps.isDaemonRunning
|
|
55
|
+
this.getToken = deps.getToken ?? (() => null)
|
|
56
|
+
Object.freeze(this)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async list(): Promise<ListListenersResult> {
|
|
60
|
+
if (!this.isDaemonRunning()) return { state: "offline" }
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`http://localhost:${this.port}/listeners`, {
|
|
64
|
+
headers: this.authHeaders(),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (!res.ok) return { state: "error", reason: `HTTP ${res.status}` }
|
|
68
|
+
|
|
69
|
+
const parsed = listenersResponseSchema.safeParse(await res.json())
|
|
70
|
+
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
return { state: "error", reason: "malformed daemon response" }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { state: "ok", listeners: parsed.data.listeners }
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return { state: "error", reason: error instanceof Error ? error.message : String(error) }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async start(channelName: string, connectorName: string): Promise<ListenerOpResult> {
|
|
82
|
+
if (!this.isDaemonRunning()) return OFFLINE
|
|
83
|
+
|
|
84
|
+
return await this.call("POST", `/listeners/${this.path(channelName, connectorName)}/start`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async stop(channelName: string, connectorName: string): Promise<ListenerOpResult> {
|
|
88
|
+
if (!this.isDaemonRunning()) return OFFLINE
|
|
89
|
+
|
|
90
|
+
return await this.call("DELETE", `/listeners/${this.path(channelName, connectorName)}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async restart(channelName: string, connectorName: string): Promise<ListenerOpResult> {
|
|
94
|
+
if (!this.isDaemonRunning()) return OFFLINE
|
|
95
|
+
|
|
96
|
+
return await this.call("POST", `/listeners/${this.path(channelName, connectorName)}/restart`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private path(channelName: string, connectorName: string): string {
|
|
100
|
+
return `${encodeURIComponent(channelName)}/${encodeURIComponent(connectorName)}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private authHeaders(): Record<string, string> {
|
|
104
|
+
const token = this.getToken()
|
|
105
|
+
|
|
106
|
+
return token ? { authorization: `Bearer ${token}` } : {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async call(method: "POST" | "DELETE", path: string): Promise<ListenerOpResult> {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`http://localhost:${this.port}${path}`, {
|
|
112
|
+
method,
|
|
113
|
+
headers: this.authHeaders(),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
const parsed = opErrorBodySchema.safeParse(await res.json().catch(() => null))
|
|
118
|
+
const reason = parsed.success ? parsed.data.reason : undefined
|
|
119
|
+
|
|
120
|
+
return { state: "error", reason: reason ?? `HTTP ${res.status}` }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { state: "ok" }
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { state: "error", reason: error instanceof Error ? error.message : String(error) }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|