@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,20 @@
|
|
|
1
|
+
export type NotifyFn = (content: string, meta?: Record<string, string>) => Promise<void>
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Long-lived event source for one connector.
|
|
5
|
+
*
|
|
6
|
+
* `start()` opens the underlying connection (Slack Socket Mode, Discord
|
|
7
|
+
* Gateway, GH polling, schedule tick) and pushes events through `notify`.
|
|
8
|
+
* `stop()` releases the resources so the supervisor can recreate the listener
|
|
9
|
+
* with new config without restarting the whole gateway. `isAlive()` lets the
|
|
10
|
+
* supervisor periodically health-check and auto-restart dead listeners; the
|
|
11
|
+
* default optimistic implementation is fine for poll/tick-based listeners
|
|
12
|
+
* that self-heal.
|
|
13
|
+
*/
|
|
14
|
+
export abstract class FunnelConnectorListener {
|
|
15
|
+
abstract start(notify: NotifyFn): Promise<void>
|
|
16
|
+
abstract stop(): Promise<void>
|
|
17
|
+
isAlive(): boolean {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "@/
|
|
5
|
-
import { FunnelHttpClient } from "@/modules/http/funnel-http-client"
|
|
6
|
-
import { NodeFunnelHttpClient } from "@/modules/http/node-funnel-http-client"
|
|
7
|
-
import type { DiscordConnectorConfig } from "@/modules/connectors/discord-connector-schema"
|
|
1
|
+
import { FunnelConnectorAdapter, type CallInput } from "@/connectors/connector-adapter"
|
|
2
|
+
import { FunnelHttpClient } from "@/engine/http/http-client"
|
|
3
|
+
import { NodeFunnelHttpClient } from "@/engine/http/node-http-client"
|
|
4
|
+
import type { DiscordConnectorConfig } from "@/connectors/discord-connector-schema"
|
|
8
5
|
|
|
9
6
|
const DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
10
7
|
|
|
@@ -29,11 +26,9 @@ export class FunnelDiscordAdapter extends FunnelConnectorAdapter {
|
|
|
29
26
|
async call(input: CallInput): Promise<unknown> {
|
|
30
27
|
const method = (input.method || "GET").toUpperCase()
|
|
31
28
|
const path = input.path.startsWith("/") ? input.path : `/${input.path}`
|
|
29
|
+
const body = input.body
|
|
32
30
|
const hasBody =
|
|
33
|
-
|
|
34
|
-
typeof input.body === "object" &&
|
|
35
|
-
method !== "GET" &&
|
|
36
|
-
Object.keys(input.body as object).length > 0
|
|
31
|
+
body !== null && typeof body === "object" && method !== "GET" && Object.keys(body).length > 0
|
|
37
32
|
|
|
38
33
|
const res = await this.http.fetch({
|
|
39
34
|
method,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from "zod"
|
|
2
2
|
|
|
3
3
|
export const discordConnectorSchema = z.object({
|
|
4
|
-
|
|
4
|
+
id: z.string(),
|
|
5
5
|
name: z.string(),
|
|
6
|
+
type: z.literal("discord"),
|
|
6
7
|
botToken: z.string().min(10),
|
|
8
|
+
createdAt: z.string().datetime().optional(),
|
|
9
|
+
updatedAt: z.string().datetime().optional(),
|
|
7
10
|
})
|
|
8
11
|
|
|
9
12
|
export type DiscordConnectorConfig = z.infer<typeof discordConnectorSchema>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Client, GatewayIntentBits, Partials } from "discord.js"
|
|
2
|
+
import { FunnelConnectorListener, type NotifyFn } from "@/connectors/connector-listener"
|
|
3
|
+
import { FunnelDiscordEventProcessor } from "@/connectors/discord-event-processor"
|
|
4
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
5
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
6
|
+
import type { DiscordConnectorConfig } from "@/connectors/discord-connector-schema"
|
|
7
|
+
|
|
8
|
+
type Deps = {
|
|
9
|
+
config: DiscordConnectorConfig
|
|
10
|
+
logger?: FunnelLogger
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
14
|
+
|
|
15
|
+
export class FunnelDiscordListener extends FunnelConnectorListener {
|
|
16
|
+
private readonly config: DiscordConnectorConfig
|
|
17
|
+
private readonly logger: FunnelLogger
|
|
18
|
+
private client: Client | null = null
|
|
19
|
+
|
|
20
|
+
constructor(deps: Deps) {
|
|
21
|
+
super()
|
|
22
|
+
this.config = deps.config
|
|
23
|
+
this.logger = deps.logger ?? defaultLogger
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async start(notify: NotifyFn): Promise<void> {
|
|
27
|
+
const client = new Client({
|
|
28
|
+
intents: [
|
|
29
|
+
GatewayIntentBits.Guilds,
|
|
30
|
+
GatewayIntentBits.GuildMessages,
|
|
31
|
+
GatewayIntentBits.MessageContent,
|
|
32
|
+
GatewayIntentBits.DirectMessages,
|
|
33
|
+
],
|
|
34
|
+
partials: [Partials.Channel],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
client.on("messageCreate", async (message) => {
|
|
38
|
+
const ownUserId = client.user?.id ?? ""
|
|
39
|
+
const mentionedUserIds = [...message.mentions.users.keys()]
|
|
40
|
+
|
|
41
|
+
this.logger.info("discord messageCreate", {
|
|
42
|
+
author: message.author.id,
|
|
43
|
+
authorIsBot: String(message.author.bot),
|
|
44
|
+
channelId: message.channelId,
|
|
45
|
+
guildId: message.guildId ?? "",
|
|
46
|
+
mentions: mentionedUserIds.join(","),
|
|
47
|
+
ownUserId,
|
|
48
|
+
mentioned: String(mentionedUserIds.includes(ownUserId)),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const processor = new FunnelDiscordEventProcessor({ ownUserId })
|
|
52
|
+
|
|
53
|
+
const result = processor.process({
|
|
54
|
+
authorId: message.author.id,
|
|
55
|
+
authorIsBot: message.author.bot,
|
|
56
|
+
channelId: message.channelId,
|
|
57
|
+
guildId: message.guildId,
|
|
58
|
+
mentionedUserIds,
|
|
59
|
+
raw: message.toJSON(),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if (result.skip) {
|
|
63
|
+
this.logger.info("discord skip", { reason: "bot author" })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await notify(result.content, result.meta)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.logger.error("discord notify error", {
|
|
71
|
+
error: error instanceof Error ? error.message : String(error),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
client.on("ready", (readyClient) => {
|
|
77
|
+
this.logger.info("discord ready", {
|
|
78
|
+
userId: readyClient.user.id,
|
|
79
|
+
tag: readyClient.user.tag,
|
|
80
|
+
guilds: String(readyClient.guilds.cache.size),
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
client.on("error", (error) => {
|
|
85
|
+
this.logger.error("discord client error", {
|
|
86
|
+
error: error instanceof Error ? error.message : String(error),
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
await client.login(this.config.botToken)
|
|
91
|
+
this.client = client
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async stop(): Promise<void> {
|
|
95
|
+
if (!this.client) return
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await this.client.destroy()
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.logger.error("discord stop error", {
|
|
101
|
+
error: error instanceof Error ? error.message : String(error),
|
|
102
|
+
})
|
|
103
|
+
} finally {
|
|
104
|
+
this.client = null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override isAlive(): boolean {
|
|
109
|
+
return this.client !== null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "@/modules/connectors/funnel-connector-adapter"
|
|
5
|
-
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
6
|
-
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
1
|
+
import { FunnelConnectorAdapter, type CallInput } from "@/connectors/connector-adapter"
|
|
2
|
+
import { FunnelProcessRunner } from "@/engine/process/process-runner"
|
|
3
|
+
import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
|
|
7
4
|
|
|
8
5
|
type Deps = {
|
|
9
6
|
process?: FunnelProcessRunner
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from "zod"
|
|
2
2
|
|
|
3
3
|
export const ghConnectorSchema = z.object({
|
|
4
|
-
|
|
4
|
+
id: z.string(),
|
|
5
5
|
name: z.string(),
|
|
6
|
+
type: z.literal("gh"),
|
|
6
7
|
pollInterval: z.number().int().positive().optional(),
|
|
8
|
+
createdAt: z.string().datetime().optional(),
|
|
9
|
+
updatedAt: z.string().datetime().optional(),
|
|
7
10
|
})
|
|
8
11
|
|
|
9
12
|
export type GhConnectorConfig = z.infer<typeof ghConnectorSchema>
|
|
@@ -1,26 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "@/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { FunnelConnectorListener, type NotifyFn } from "@/connectors/connector-listener"
|
|
3
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
4
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
5
|
+
import { FunnelProcessRunner } from "@/engine/process/process-runner"
|
|
6
|
+
import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
|
|
7
|
+
import type { GhConnectorConfig } from "@/connectors/gh-connector-schema"
|
|
8
|
+
|
|
9
|
+
const ghNotificationSchema = z.object({
|
|
10
|
+
id: z.string(),
|
|
11
|
+
reason: z.string(),
|
|
12
|
+
subject: z.object({
|
|
13
|
+
type: z.string(),
|
|
14
|
+
url: z.string(),
|
|
15
|
+
title: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
repository: z.object({ full_name: z.string() }),
|
|
18
|
+
updated_at: z.string(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const ghNotificationsSchema = z.array(ghNotificationSchema)
|
|
22
|
+
|
|
23
|
+
type GhNotification = z.infer<typeof ghNotificationSchema>
|
|
17
24
|
|
|
18
25
|
type Deps = {
|
|
19
26
|
config: GhConnectorConfig
|
|
20
27
|
process?: FunnelProcessRunner
|
|
28
|
+
logger?: FunnelLogger
|
|
29
|
+
now?: () => Date
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
const defaultProcess = new NodeFunnelProcessRunner()
|
|
33
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
24
34
|
|
|
25
35
|
const MAX_SEEN = 10000
|
|
26
36
|
const KEEP_SEEN = 5000
|
|
@@ -28,14 +38,20 @@ const KEEP_SEEN = 5000
|
|
|
28
38
|
export class FunnelGhListener extends FunnelConnectorListener {
|
|
29
39
|
private readonly config: GhConnectorConfig
|
|
30
40
|
private readonly process: FunnelProcessRunner
|
|
41
|
+
private readonly logger: FunnelLogger
|
|
42
|
+
private readonly now: () => Date
|
|
31
43
|
private readonly seen = new Map<string, string>()
|
|
32
44
|
private bootstrapped = false
|
|
33
|
-
private since
|
|
45
|
+
private since: string
|
|
46
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
34
47
|
|
|
35
48
|
constructor(deps: Deps) {
|
|
36
49
|
super()
|
|
37
50
|
this.config = deps.config
|
|
38
51
|
this.process = deps.process ?? defaultProcess
|
|
52
|
+
this.logger = deps.logger ?? defaultLogger
|
|
53
|
+
this.now = deps.now ?? (() => new Date())
|
|
54
|
+
this.since = this.now().toISOString()
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
async start(notify: NotifyFn): Promise<void> {
|
|
@@ -43,22 +59,41 @@ export class FunnelGhListener extends FunnelConnectorListener {
|
|
|
43
59
|
|
|
44
60
|
const interval = this.config.pollInterval ?? 60
|
|
45
61
|
|
|
46
|
-
setInterval(() => void this.pollOnce(notify), interval * 1000)
|
|
62
|
+
this.timer = setInterval(() => void this.pollOnce(notify), interval * 1000)
|
|
63
|
+
this.timer.unref()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stop(): Promise<void> {
|
|
67
|
+
if (!this.timer) return
|
|
68
|
+
|
|
69
|
+
clearInterval(this.timer)
|
|
70
|
+
this.timer = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
override isAlive(): boolean {
|
|
74
|
+
return this.timer !== null
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
async pollOnce(notify: NotifyFn): Promise<void> {
|
|
50
|
-
const nextSince =
|
|
78
|
+
const nextSince = this.now().toISOString()
|
|
51
79
|
const params = new URLSearchParams({ since: this.since, all: "false" })
|
|
52
80
|
|
|
53
81
|
try {
|
|
54
82
|
const result = await this.process.run(["gh", "api", `/notifications?${params}`])
|
|
55
83
|
|
|
56
84
|
if (result.exitCode !== 0) {
|
|
57
|
-
logger.error("gh poll failed", { stderr: result.stderr })
|
|
85
|
+
this.logger.error("gh poll failed", { stderr: result.stderr })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parsed = ghNotificationsSchema.safeParse(JSON.parse(result.stdout))
|
|
90
|
+
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
this.logger.warn("gh response did not match schema", { error: parsed.error.message })
|
|
58
93
|
return
|
|
59
94
|
}
|
|
60
95
|
|
|
61
|
-
const items =
|
|
96
|
+
const items: GhNotification[] = parsed.data
|
|
62
97
|
|
|
63
98
|
for (const item of items) {
|
|
64
99
|
if (this.seen.get(item.id) === item.updated_at) continue
|
|
@@ -94,7 +129,7 @@ export class FunnelGhListener extends FunnelConnectorListener {
|
|
|
94
129
|
this.since = nextSince
|
|
95
130
|
this.bootstrapped = true
|
|
96
131
|
} catch (error) {
|
|
97
|
-
logger.error("gh poll error", {
|
|
132
|
+
this.logger.error("gh poll error", {
|
|
98
133
|
error: error instanceof Error ? error.message : String(error),
|
|
99
134
|
})
|
|
100
135
|
}
|
|
@@ -18,14 +18,16 @@ const parseField = (expr: string, min: number, max: number): Field => {
|
|
|
18
18
|
lo = min
|
|
19
19
|
hi = max
|
|
20
20
|
} else if (rangePart.includes("-")) {
|
|
21
|
-
const [
|
|
21
|
+
const [aStr, bStr] = rangePart.split("-")
|
|
22
|
+
const a = Number(aStr)
|
|
23
|
+
const b = Number(bStr)
|
|
22
24
|
|
|
23
25
|
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
24
26
|
throw new Error(`invalid cron range: "${rangePart}"`)
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
lo = a
|
|
28
|
-
hi = b
|
|
29
|
+
lo = a
|
|
30
|
+
hi = b
|
|
29
31
|
} else {
|
|
30
32
|
const n = Number(rangePart)
|
|
31
33
|
|
|
@@ -54,7 +56,11 @@ export const matchCron = (expr: string, date: Date): boolean => {
|
|
|
54
56
|
throw new Error(`cron must have 5 fields (got ${parts.length}): "${expr}"`)
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
const [minute, hour, dom, month, dow] = parts
|
|
59
|
+
const [minute, hour, dom, month, dow] = parts
|
|
60
|
+
|
|
61
|
+
if (!minute || !hour || !dom || !month || !dow) {
|
|
62
|
+
throw new Error(`cron has empty fields: "${expr}"`)
|
|
63
|
+
}
|
|
58
64
|
|
|
59
65
|
const fields = [
|
|
60
66
|
{ field: parseField(minute, 0, 59), value: date.getMinutes() },
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Catch-up behavior when the daemon was down past one or more matching minutes.
|
|
5
|
+
*
|
|
6
|
+
* - `latest`: fire once with the most recent missed match (default; preserves prior behavior).
|
|
7
|
+
* - `all`: fire once per missed minute, oldest first (capped at 24 h).
|
|
8
|
+
* - `skip`: never fire missed matches; only fire when the current minute matches.
|
|
9
|
+
*/
|
|
10
|
+
export const scheduleCatchupPolicySchema = z.enum(["latest", "all", "skip"])
|
|
11
|
+
|
|
12
|
+
export type ScheduleCatchupPolicy = z.infer<typeof scheduleCatchupPolicySchema>
|
|
13
|
+
|
|
14
|
+
export const scheduleEntrySchema = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
cron: z.string(),
|
|
17
|
+
prompt: z.string(),
|
|
18
|
+
enabled: z.boolean().default(true),
|
|
19
|
+
catchupPolicy: scheduleCatchupPolicySchema.default("latest"),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export type ScheduleEntry = z.infer<typeof scheduleEntrySchema>
|
|
23
|
+
|
|
24
|
+
export const scheduleConnectorSchema = z.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
name: z.string(),
|
|
27
|
+
type: z.literal("schedule"),
|
|
28
|
+
entries: z.array(scheduleEntrySchema).default([]),
|
|
29
|
+
createdAt: z.string().datetime().optional(),
|
|
30
|
+
updatedAt: z.string().datetime().optional(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export type ScheduleConnectorConfig = z.infer<typeof scheduleConnectorSchema>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { FunnelConnectorListener, type NotifyFn } from "@/connectors/connector-listener"
|
|
2
|
+
import { matchCron } from "@/connectors/match-cron"
|
|
3
|
+
import { ScheduleStateStore } from "@/connectors/schedule-state-store"
|
|
4
|
+
import { FunnelLogger } from "@/engine/logger/logger"
|
|
5
|
+
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
6
|
+
import type { ScheduleConnectorConfig, ScheduleEntry } from "@/connectors/schedule-connector-schema"
|
|
7
|
+
|
|
8
|
+
type Deps = {
|
|
9
|
+
config: ScheduleConnectorConfig
|
|
10
|
+
lastFiredStore: ScheduleStateStore
|
|
11
|
+
logger?: FunnelLogger
|
|
12
|
+
now?: () => Date
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
16
|
+
|
|
17
|
+
const MAX_CATCHUP_MINUTES = 60 * 24
|
|
18
|
+
|
|
19
|
+
export class FunnelScheduleListener extends FunnelConnectorListener {
|
|
20
|
+
private readonly config: ScheduleConnectorConfig
|
|
21
|
+
private readonly lastFiredStore: ScheduleStateStore
|
|
22
|
+
private readonly logger: FunnelLogger
|
|
23
|
+
private readonly now: () => Date
|
|
24
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
25
|
+
private stopped = false
|
|
26
|
+
|
|
27
|
+
constructor(deps: Deps) {
|
|
28
|
+
super()
|
|
29
|
+
this.config = deps.config
|
|
30
|
+
this.lastFiredStore = deps.lastFiredStore
|
|
31
|
+
this.logger = deps.logger ?? defaultLogger
|
|
32
|
+
this.now = deps.now ?? (() => new Date())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start(notify: NotifyFn): Promise<void> {
|
|
36
|
+
this.stopped = false
|
|
37
|
+
|
|
38
|
+
const scheduleNext = () => {
|
|
39
|
+
if (this.stopped) return
|
|
40
|
+
|
|
41
|
+
const date = this.now()
|
|
42
|
+
const msUntilNextMinute = 60_000 - (date.getSeconds() * 1000 + date.getMilliseconds())
|
|
43
|
+
this.timer = setTimeout(async () => {
|
|
44
|
+
if (this.stopped) return
|
|
45
|
+
await this.tick(notify)
|
|
46
|
+
scheduleNext()
|
|
47
|
+
}, msUntilNextMinute)
|
|
48
|
+
|
|
49
|
+
this.timer.unref()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await this.tick(notify)
|
|
53
|
+
scheduleNext()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async stop(): Promise<void> {
|
|
57
|
+
this.stopped = true
|
|
58
|
+
|
|
59
|
+
if (this.timer) {
|
|
60
|
+
clearTimeout(this.timer)
|
|
61
|
+
this.timer = null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override isAlive(): boolean {
|
|
66
|
+
return !this.stopped && this.timer !== null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async tick(notify: NotifyFn): Promise<void> {
|
|
70
|
+
const now = this.truncateToMinute(this.now())
|
|
71
|
+
const state = this.lastFiredStore.load()
|
|
72
|
+
let changed = false
|
|
73
|
+
|
|
74
|
+
for (const entry of this.config.entries) {
|
|
75
|
+
if (!entry.enabled) continue
|
|
76
|
+
|
|
77
|
+
const fired = await this.fireEntry(entry, now, state, notify)
|
|
78
|
+
|
|
79
|
+
if (fired) changed = true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (changed) this.lastFiredStore.save(state)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async fireEntry(
|
|
86
|
+
entry: ScheduleEntry,
|
|
87
|
+
now: Date,
|
|
88
|
+
state: Map<string, Date>,
|
|
89
|
+
notify: NotifyFn,
|
|
90
|
+
): Promise<boolean> {
|
|
91
|
+
const lastFired = state.get(entry.id)
|
|
92
|
+
const searchFrom = lastFired ? new Date(lastFired.getTime() + 60_000) : now
|
|
93
|
+
|
|
94
|
+
if (searchFrom.getTime() > now.getTime()) return false
|
|
95
|
+
|
|
96
|
+
if (entry.catchupPolicy === "skip") {
|
|
97
|
+
try {
|
|
98
|
+
if (!matchCron(entry.cron, now)) return false
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.logInvalidCron(entry, error)
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await this.notifyOne(entry, now, notify, false)
|
|
105
|
+
state.set(entry.id, now)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (entry.catchupPolicy === "all") {
|
|
110
|
+
const matches = this.findAllMatches(entry.cron, searchFrom, now, entry.id)
|
|
111
|
+
|
|
112
|
+
if (matches.length === 0) return false
|
|
113
|
+
|
|
114
|
+
for (const match of matches) {
|
|
115
|
+
await this.notifyOne(entry, match, notify, match.getTime() !== now.getTime())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
state.set(entry.id, matches[matches.length - 1] ?? now)
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const match = this.findMostRecentMatch(entry.cron, searchFrom, now, entry.id)
|
|
123
|
+
|
|
124
|
+
if (!match) return false
|
|
125
|
+
|
|
126
|
+
await this.notifyOne(entry, match, notify, match.getTime() !== now.getTime())
|
|
127
|
+
state.set(entry.id, match)
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async notifyOne(
|
|
132
|
+
entry: ScheduleEntry,
|
|
133
|
+
firedAt: Date,
|
|
134
|
+
notify: NotifyFn,
|
|
135
|
+
catchup: boolean,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const meta: Record<string, string> = {
|
|
138
|
+
event_type: "schedule",
|
|
139
|
+
schedule_id: entry.id,
|
|
140
|
+
cron: entry.cron,
|
|
141
|
+
fired_at: firedAt.toISOString(),
|
|
142
|
+
catchup_policy: entry.catchupPolicy,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (catchup) meta.catchup = "true"
|
|
146
|
+
|
|
147
|
+
await notify(entry.prompt, meta)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private findMostRecentMatch(cron: string, from: Date, until: Date, entryId: string): Date | null {
|
|
151
|
+
const maxIterations = Math.min(
|
|
152
|
+
MAX_CATCHUP_MINUTES,
|
|
153
|
+
Math.floor((until.getTime() - from.getTime()) / 60_000) + 1,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
157
|
+
const candidate = new Date(until.getTime() - i * 60_000)
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
if (matchCron(cron, candidate)) return candidate
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.logInvalidCron({ id: entryId, cron } as ScheduleEntry, error)
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private findAllMatches(cron: string, from: Date, until: Date, entryId: string): Date[] {
|
|
171
|
+
const maxIterations = Math.min(
|
|
172
|
+
MAX_CATCHUP_MINUTES,
|
|
173
|
+
Math.floor((until.getTime() - from.getTime()) / 60_000) + 1,
|
|
174
|
+
)
|
|
175
|
+
const matches: Date[] = []
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
178
|
+
const candidate = new Date(from.getTime() + i * 60_000)
|
|
179
|
+
|
|
180
|
+
if (candidate.getTime() > until.getTime()) break
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (matchCron(cron, candidate)) matches.push(candidate)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.logInvalidCron({ id: entryId, cron } as ScheduleEntry, error)
|
|
186
|
+
return []
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return matches
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private logInvalidCron(entry: Pick<ScheduleEntry, "id" | "cron">, error: unknown): void {
|
|
194
|
+
this.logger.error("invalid cron expression in schedule", {
|
|
195
|
+
connector: this.config.name,
|
|
196
|
+
id: entry.id,
|
|
197
|
+
cron: entry.cron,
|
|
198
|
+
error: error instanceof Error ? error.message : String(error),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private truncateToMinute(date: Date): Date {
|
|
203
|
+
const copy = new Date(date.getTime())
|
|
204
|
+
copy.setSeconds(0, 0)
|
|
205
|
+
return copy
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { dirname } from "node:path"
|
|
2
|
+
import { FunnelFileSystem } from "@/engine/fs/file-system"
|
|
3
|
+
import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
|
|
4
|
+
|
|
5
|
+
type Deps = {
|
|
6
|
+
path: string
|
|
7
|
+
fs?: FunnelFileSystem
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const defaultFs = new NodeFunnelFileSystem()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-connector lastFiredAt persistence for the schedule listener. The path is
|
|
14
|
+
* passed in by FunnelConnectorFactory so this store does not know about the
|
|
15
|
+
* funnel directory layout (`channels/<id>/connectors/<id>/state.json` lives
|
|
16
|
+
* outside this class).
|
|
17
|
+
*/
|
|
18
|
+
export class ScheduleStateStore {
|
|
19
|
+
private readonly path: string
|
|
20
|
+
private readonly fs: FunnelFileSystem
|
|
21
|
+
|
|
22
|
+
constructor(deps: Deps) {
|
|
23
|
+
this.path = deps.path
|
|
24
|
+
this.fs = deps.fs ?? defaultFs
|
|
25
|
+
Object.freeze(this)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
load(): Map<string, Date> {
|
|
29
|
+
const map = new Map<string, Date>()
|
|
30
|
+
|
|
31
|
+
if (!this.fs.existsSync(this.path)) return map
|
|
32
|
+
|
|
33
|
+
const raw: unknown = JSON.parse(this.fs.readFileSync(this.path))
|
|
34
|
+
|
|
35
|
+
if (raw === null || typeof raw !== "object") return map
|
|
36
|
+
|
|
37
|
+
for (const [id, iso] of Object.entries(raw)) {
|
|
38
|
+
if (typeof iso === "string") map.set(id, new Date(iso))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return map
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
save(state: Map<string, Date>): void {
|
|
45
|
+
const obj: Record<string, string> = {}
|
|
46
|
+
|
|
47
|
+
for (const [id, date] of state) {
|
|
48
|
+
obj[id] = date.toISOString()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.fs.mkdirSync(dirname(this.path), { recursive: true })
|
|
52
|
+
this.fs.writeFileSync(this.path, `${JSON.stringify(obj, null, 2)}\n`)
|
|
53
|
+
}
|
|
54
|
+
}
|