@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.
Files changed (349) hide show
  1. package/README.md +233 -111
  2. package/dist/bin.js +1417 -0
  3. package/dist/gateway/daemon.js +513 -0
  4. package/dist/highlights-eq9cgrbb.scm +604 -0
  5. package/dist/highlights-ghv9g403.scm +205 -0
  6. package/dist/highlights-hk7bwhj4.scm +284 -0
  7. package/dist/highlights-r812a2qc.scm +150 -0
  8. package/dist/highlights-x6tmsnaa.scm +115 -0
  9. package/dist/injections-73j83es3.scm +27 -0
  10. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/lib/bin.ts +78 -0
  16. package/lib/{modules → cli}/router/to-request.ts +13 -20
  17. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
  18. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
  19. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
  20. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
  21. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
  22. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
  23. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
  24. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
  25. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
  26. package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
  27. package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
  28. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
  29. package/lib/cli/routes/channels.$channel.ts +34 -0
  30. package/lib/cli/routes/channels.add.$channel.ts +33 -0
  31. package/lib/cli/routes/channels.remove.$channel.ts +20 -0
  32. package/lib/cli/routes/channels.ts +39 -0
  33. package/lib/cli/routes/claude.ts +69 -0
  34. package/lib/cli/routes/gateway.listeners.ts +41 -0
  35. package/lib/cli/routes/gateway.logs.ts +123 -0
  36. package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
  37. package/lib/cli/routes/gateway.run.ts +41 -0
  38. package/lib/cli/routes/gateway.start.ts +50 -0
  39. package/lib/cli/routes/gateway.status.ts +19 -0
  40. package/lib/cli/routes/gateway.stop.ts +32 -0
  41. package/lib/cli/routes/gateway.ts +55 -0
  42. package/lib/cli/routes/index.ts +202 -0
  43. package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
  44. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
  45. package/lib/cli/routes/profiles.$profile.run.ts +36 -0
  46. package/lib/cli/routes/profiles.add.$profile.ts +46 -0
  47. package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
  48. package/lib/cli/routes/profiles.set.$profile.ts +46 -0
  49. package/lib/cli/routes/profiles.ts +40 -0
  50. package/lib/cli/routes/status.ts +93 -0
  51. package/lib/cli/routes/update.ts +27 -0
  52. package/lib/connectors/connector-config-schema.ts +16 -0
  53. package/lib/connectors/connector-factory.ts +94 -0
  54. package/lib/connectors/connector-listener.ts +20 -0
  55. package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
  56. package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
  57. package/lib/connectors/discord-listener.ts +111 -0
  58. package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
  59. package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
  60. package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
  61. package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
  62. package/lib/connectors/schedule-connector-schema.ts +33 -0
  63. package/lib/connectors/schedule-listener.ts +207 -0
  64. package/lib/connectors/schedule-state-store.ts +54 -0
  65. package/lib/connectors/slack-adapter.ts +36 -0
  66. package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
  67. package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
  68. package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
  69. package/lib/engine/channels/channels.ts +520 -0
  70. package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
  71. package/lib/engine/claude/gateway-controller.ts +4 -0
  72. package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
  73. package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
  74. package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
  75. package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
  76. package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
  77. package/lib/engine/id/id-generator.ts +7 -0
  78. package/lib/engine/id/memory-id-generator.ts +20 -0
  79. package/lib/engine/id/node-id-generator.ts +7 -0
  80. package/lib/engine/logger/logger.ts +11 -0
  81. package/lib/engine/logger/memory-logger.ts +28 -0
  82. package/lib/engine/logger/node-logger.ts +49 -0
  83. package/lib/engine/logger/noop-logger.ts +9 -0
  84. package/lib/engine/mcp/channel-server.ts +204 -0
  85. package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
  86. package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
  87. package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
  88. package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
  89. package/lib/engine/profiles/profile-channel-checker.ts +7 -0
  90. package/lib/engine/profiles/profiles.ts +126 -0
  91. package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
  92. package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
  93. package/lib/engine/settings/settings-schema.ts +46 -0
  94. package/lib/engine/settings/settings-store.ts +110 -0
  95. package/lib/engine/time/clock.ts +15 -0
  96. package/lib/engine/time/memory-clock.ts +26 -0
  97. package/lib/engine/time/node-clock.ts +7 -0
  98. package/lib/funnel.ts +148 -56
  99. package/lib/gateway/auth-middleware.ts +44 -0
  100. package/lib/gateway/broadcaster.ts +319 -0
  101. package/lib/gateway/daemon.ts +47 -0
  102. package/lib/gateway/factory.ts +10 -0
  103. package/lib/gateway/funnel-event-store.ts +155 -0
  104. package/lib/gateway/gateway-server.ts +414 -0
  105. package/lib/gateway/gateway-token.ts +79 -0
  106. package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
  107. package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
  108. package/lib/gateway/listener-supervisor.ts +339 -0
  109. package/lib/gateway/listeners-client.ts +128 -0
  110. package/lib/gateway/resolve-daemon-script.ts +26 -0
  111. package/lib/gateway/routes/channels.connectors.call.ts +39 -0
  112. package/lib/gateway/routes/health.ts +13 -0
  113. package/lib/gateway/routes/index.ts +24 -0
  114. package/lib/gateway/routes/listeners.list.ts +6 -0
  115. package/lib/gateway/routes/listeners.restart.ts +15 -0
  116. package/lib/gateway/routes/listeners.start.ts +15 -0
  117. package/lib/gateway/routes/listeners.stop.ts +15 -0
  118. package/lib/gateway/routes/route-deps.ts +11 -0
  119. package/lib/gateway/routes/status.ts +15 -0
  120. package/lib/gateway/routes/validator.ts +17 -0
  121. package/lib/index.ts +50 -92
  122. package/lib/logger/leuco-human-file-writer.ts +65 -0
  123. package/lib/logger/leuco-human-logger.ts +98 -0
  124. package/lib/logger/leuco-human-record.ts +16 -0
  125. package/lib/logger/leuco-human-stdout-writer.ts +26 -0
  126. package/lib/logger/leuco-human-writer.ts +14 -0
  127. package/lib/logger/leuco-logger-memory-sink.ts +67 -0
  128. package/lib/logger/leuco-logger-record.ts +13 -0
  129. package/lib/logger/leuco-logger-sink.ts +33 -0
  130. package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
  131. package/lib/logger/leuco-logger.ts +135 -0
  132. package/lib/tui/app.tsx +357 -0
  133. package/lib/tui/components/add-row.tsx +18 -0
  134. package/lib/tui/components/brand.tsx +27 -0
  135. package/lib/tui/components/card.tsx +44 -0
  136. package/lib/tui/components/detail-bar.tsx +46 -0
  137. package/lib/tui/components/editable-field.tsx +33 -0
  138. package/lib/tui/components/empty-state.tsx +11 -0
  139. package/lib/tui/components/gateway-status.tsx +66 -0
  140. package/lib/tui/components/keymap.tsx +29 -0
  141. package/lib/tui/components/menu-item.tsx +73 -0
  142. package/lib/tui/components/menu.tsx +26 -0
  143. package/lib/tui/components/panel-header.tsx +22 -0
  144. package/lib/tui/components/readonly-field.tsx +18 -0
  145. package/lib/tui/components/section-header.tsx +25 -0
  146. package/lib/tui/components/selection-accent.tsx +32 -0
  147. package/lib/tui/components/session-item.tsx +33 -0
  148. package/lib/tui/components/session-list.tsx +33 -0
  149. package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
  150. package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
  151. package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
  152. package/lib/tui/components/ui/hascii/badge.tsx +51 -0
  153. package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
  154. package/lib/tui/components/ui/hascii/button.tsx +194 -0
  155. package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
  156. package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
  157. package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
  158. package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
  159. package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
  160. package/lib/tui/components/ui/hascii/card.tsx +27 -0
  161. package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
  162. package/lib/tui/components/ui/hascii/command.tsx +159 -0
  163. package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
  164. package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
  165. package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
  166. package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
  167. package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
  168. package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
  169. package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
  170. package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
  171. package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
  172. package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
  173. package/lib/tui/components/ui/hascii/input.tsx +130 -0
  174. package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
  175. package/lib/tui/components/ui/hascii/progress.tsx +28 -0
  176. package/lib/tui/components/ui/hascii/select.tsx +131 -0
  177. package/lib/tui/components/ui/hascii/separator.tsx +35 -0
  178. package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
  179. package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
  180. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
  181. package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
  182. package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
  183. package/lib/tui/components/ui/hascii/slider.tsx +91 -0
  184. package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
  185. package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
  186. package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
  187. package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
  188. package/lib/tui/components/ui/hascii/switch.tsx +66 -0
  189. package/lib/tui/components/ui/hascii/table.tsx +95 -0
  190. package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
  191. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
  192. package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
  193. package/lib/tui/components/ui/hascii/tree.tsx +104 -0
  194. package/lib/tui/components/view-shell.tsx +44 -0
  195. package/lib/tui/filter-input.tsx +33 -0
  196. package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
  197. package/lib/tui/parse-comma-list.ts +14 -0
  198. package/lib/tui/profile-launcher.tsx +61 -0
  199. package/lib/tui/scrollbar-options.ts +19 -0
  200. package/lib/tui/sidebar.tsx +50 -0
  201. package/lib/tui/theme.ts +40 -0
  202. package/lib/tui/tui.tsx +20 -0
  203. package/lib/tui/types.ts +38 -0
  204. package/lib/tui/unique-name.ts +18 -0
  205. package/lib/tui/use-event-stream.ts +133 -0
  206. package/lib/tui/use-snapshot.ts +99 -0
  207. package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
  208. package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
  209. package/lib/tui/utils/hascii/theme-context.tsx +26 -0
  210. package/lib/tui/utils/hascii/theme.ts +176 -0
  211. package/lib/tui/views/channels-view.tsx +108 -0
  212. package/lib/tui/views/connectors-view.tsx +164 -0
  213. package/lib/tui/views/events-view.tsx +160 -0
  214. package/lib/tui/views/listeners-view.tsx +80 -0
  215. package/lib/tui/views/profiles-view.tsx +152 -0
  216. package/package.json +51 -34
  217. package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
  218. package/lib/modules/channels/funnel-channels.ts +0 -155
  219. package/lib/modules/connectors/connector-config-schema.ts +0 -16
  220. package/lib/modules/connectors/connector-existence-checker.ts +0 -3
  221. package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
  222. package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
  223. package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
  224. package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
  225. package/lib/modules/connectors/funnel-connectors.ts +0 -145
  226. package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
  227. package/lib/modules/connectors/funnel-discord-store.ts +0 -84
  228. package/lib/modules/connectors/funnel-gh-store.ts +0 -84
  229. package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
  230. package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
  231. package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
  232. package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
  233. package/lib/modules/connectors/funnel-slack-store.ts +0 -86
  234. package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
  235. package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
  236. package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
  237. package/lib/modules/gateway/daemon.ts +0 -207
  238. package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
  239. package/lib/modules/gateway/funnel-event-logger.ts +0 -59
  240. package/lib/modules/logger.ts +0 -26
  241. package/lib/modules/mcp/channel-server.ts +0 -76
  242. package/lib/modules/profiles/funnel-profiles.ts +0 -123
  243. package/lib/modules/profiles/profile-channel-checker.ts +0 -3
  244. package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
  245. package/lib/modules/repos/funnel-repositories.ts +0 -107
  246. package/lib/modules/schedule/funnel-schedule.ts +0 -34
  247. package/lib/modules/settings/funnel-settings-store.ts +0 -56
  248. package/lib/modules/settings/settings-schema.ts +0 -33
  249. package/lib/modules/tui/app.tsx +0 -44
  250. package/lib/modules/tui/tui.tsx +0 -13
  251. package/lib/routes/channels/add.help.ts +0 -3
  252. package/lib/routes/channels/add.ts +0 -21
  253. package/lib/routes/channels/connectors-attach.help.ts +0 -3
  254. package/lib/routes/channels/connectors-attach.ts +0 -17
  255. package/lib/routes/channels/connectors-detach.help.ts +0 -3
  256. package/lib/routes/channels/connectors-detach.ts +0 -17
  257. package/lib/routes/channels/group.help.ts +0 -16
  258. package/lib/routes/channels/group.ts +0 -22
  259. package/lib/routes/channels/remove.help.ts +0 -3
  260. package/lib/routes/channels/remove.ts +0 -17
  261. package/lib/routes/channels/rename.help.ts +0 -5
  262. package/lib/routes/channels/rename.ts +0 -17
  263. package/lib/routes/channels/routes.ts +0 -19
  264. package/lib/routes/channels/show.help.ts +0 -1
  265. package/lib/routes/channels/show.ts +0 -26
  266. package/lib/routes/claude/claude.help.ts +0 -16
  267. package/lib/routes/claude/claude.ts +0 -76
  268. package/lib/routes/claude/routes.ts +0 -4
  269. package/lib/routes/connectors/add.help.ts +0 -28
  270. package/lib/routes/connectors/add.ts +0 -64
  271. package/lib/routes/connectors/group.help.ts +0 -14
  272. package/lib/routes/connectors/group.ts +0 -18
  273. package/lib/routes/connectors/remove.help.ts +0 -3
  274. package/lib/routes/connectors/remove.ts +0 -17
  275. package/lib/routes/connectors/rename.help.ts +0 -5
  276. package/lib/routes/connectors/rename.ts +0 -17
  277. package/lib/routes/connectors/routes.ts +0 -23
  278. package/lib/routes/connectors/schedules-add.help.ts +0 -11
  279. package/lib/routes/connectors/schedules-add.ts +0 -33
  280. package/lib/routes/connectors/schedules-group.help.ts +0 -1
  281. package/lib/routes/connectors/schedules-group.ts +0 -38
  282. package/lib/routes/connectors/schedules-remove.help.ts +0 -3
  283. package/lib/routes/connectors/schedules-remove.ts +0 -17
  284. package/lib/routes/connectors/set.help.ts +0 -8
  285. package/lib/routes/connectors/set.ts +0 -72
  286. package/lib/routes/connectors/show.help.ts +0 -1
  287. package/lib/routes/connectors/show.ts +0 -41
  288. package/lib/routes/gateway/group.help.ts +0 -15
  289. package/lib/routes/gateway/group.ts +0 -28
  290. package/lib/routes/gateway/logs.help.ts +0 -13
  291. package/lib/routes/gateway/logs.ts +0 -100
  292. package/lib/routes/gateway/restart.help.ts +0 -10
  293. package/lib/routes/gateway/routes.ts +0 -18
  294. package/lib/routes/gateway/run.help.ts +0 -12
  295. package/lib/routes/gateway/run.ts +0 -35
  296. package/lib/routes/gateway/start.help.ts +0 -15
  297. package/lib/routes/gateway/start.ts +0 -32
  298. package/lib/routes/gateway/status.help.ts +0 -9
  299. package/lib/routes/gateway/status.ts +0 -28
  300. package/lib/routes/gateway/stop.help.ts +0 -8
  301. package/lib/routes/gateway/stop.ts +0 -21
  302. package/lib/routes/profiles/add.help.ts +0 -3
  303. package/lib/routes/profiles/add.ts +0 -33
  304. package/lib/routes/profiles/group.help.ts +0 -16
  305. package/lib/routes/profiles/group.ts +0 -25
  306. package/lib/routes/profiles/launch.help.ts +0 -4
  307. package/lib/routes/profiles/launch.ts +0 -36
  308. package/lib/routes/profiles/remove.help.ts +0 -3
  309. package/lib/routes/profiles/remove.ts +0 -17
  310. package/lib/routes/profiles/rename.help.ts +0 -5
  311. package/lib/routes/profiles/rename.ts +0 -17
  312. package/lib/routes/profiles/routes.ts +0 -18
  313. package/lib/routes/profiles/set.help.ts +0 -5
  314. package/lib/routes/profiles/set.ts +0 -32
  315. package/lib/routes/repos/add.help.ts +0 -6
  316. package/lib/routes/repos/add.ts +0 -20
  317. package/lib/routes/repos/group.help.ts +0 -11
  318. package/lib/routes/repos/group.ts +0 -18
  319. package/lib/routes/repos/remove.help.ts +0 -3
  320. package/lib/routes/repos/remove.ts +0 -17
  321. package/lib/routes/repos/rename.help.ts +0 -5
  322. package/lib/routes/repos/rename.ts +0 -17
  323. package/lib/routes/repos/routes.ts +0 -17
  324. package/lib/routes/repos/set.help.ts +0 -5
  325. package/lib/routes/repos/set.ts +0 -21
  326. package/lib/routes/repos/show.help.ts +0 -1
  327. package/lib/routes/repos/show.ts +0 -19
  328. package/lib/routes/request/discord-help.ts +0 -9
  329. package/lib/routes/request/discord.help.ts +0 -19
  330. package/lib/routes/request/discord.ts +0 -65
  331. package/lib/routes/request/group.help.ts +0 -15
  332. package/lib/routes/request/group.ts +0 -9
  333. package/lib/routes/request/routes.ts +0 -14
  334. package/lib/routes/request/slack-help.ts +0 -9
  335. package/lib/routes/request/slack.help.ts +0 -19
  336. package/lib/routes/request/slack.ts +0 -61
  337. package/lib/routes/status/routes.ts +0 -4
  338. package/lib/routes/status/status.help.ts +0 -6
  339. package/lib/routes/status/status.ts +0 -77
  340. package/lib/routes/update/routes.ts +0 -4
  341. package/lib/routes/update/update.help.ts +0 -5
  342. package/lib/routes/update/update.ts +0 -21
  343. package/lib/routes.ts +0 -40
  344. /package/lib/{factory.ts → cli/factory.ts} +0 -0
  345. /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
  346. /package/lib/{modules → cli}/router/validator.ts +0 -0
  347. /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
  348. /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
  349. /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
- FunnelConnectorAdapter,
3
- type CallInput,
4
- } from "@/modules/connectors/funnel-connector-adapter"
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
- input.body &&
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
- type: z.literal("discord"),
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
- FunnelConnectorAdapter,
3
- type CallInput,
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
- type: z.literal("gh"),
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
- FunnelConnectorListener,
3
- type NotifyFn,
4
- } from "@/modules/connectors/funnel-connector-listener"
5
- import { logger } from "@/modules/logger"
6
- import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
7
- import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
8
- import type { GhConnectorConfig } from "@/modules/connectors/gh-connector-schema"
9
-
10
- type GhNotification = {
11
- id: string
12
- reason: string
13
- subject: { type: string; url: string; title: string }
14
- repository: { full_name: string }
15
- updated_at: string
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 = new Date().toISOString()
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).unref()
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 = new Date().toISOString()
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 = JSON.parse(result.stdout) as GhNotification[]
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 [a, b] = rangePart.split("-").map(Number)
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 as number
28
- hi = b as number
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 as [string, string, string, string, string]
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
+ }