@interactive-inc/claude-funnel 0.10.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +106 -56
  2. package/dist/bin.js +557 -530
  3. package/dist/connectors/schedule.d.ts +2 -49
  4. package/dist/connectors/schedule.js +1 -1
  5. package/dist/connectors/slack.d.ts +4 -48
  6. package/dist/connectors/slack.js +1 -1
  7. package/dist/gateway/daemon.js +213 -211
  8. package/dist/index.d.ts +465 -173
  9. package/dist/index.js +692 -154
  10. package/dist/{schedule-connector-schema-CkuIQ0JQ.js → schedule-connector-schema-FxP7LPlx.js} +11 -0
  11. package/dist/{file-system-Co60LrmR.d.ts → schedule-listener-BPodvbld.d.ts} +56 -1
  12. package/dist/{slack-connector-schema-Cd22WiHB.js → slack-connector-schema-B4hsf3AY.js} +10 -1
  13. package/dist/slack-listener-CHj6uMY-.d.ts +74 -0
  14. package/package.json +2 -6
  15. package/schemas/funnel.schema.json +144 -0
  16. package/dist/slack-connector-schema-D7zAHN8k.d.ts +0 -15
  17. package/lib/bin.ts +0 -3
  18. package/lib/cli/factory.ts +0 -10
  19. package/lib/cli/index.ts +0 -85
  20. package/lib/cli/router/query-to-cli-args.ts +0 -20
  21. package/lib/cli/router/to-request.ts +0 -113
  22. package/lib/cli/router/validator.ts +0 -27
  23. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +0 -27
  24. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +0 -40
  25. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +0 -41
  26. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +0 -22
  27. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +0 -23
  28. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +0 -26
  29. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +0 -92
  30. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +0 -22
  31. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +0 -63
  32. package/lib/cli/routes/channels.$channel.connectors.ts +0 -26
  33. package/lib/cli/routes/channels.$channel.publish.ts +0 -52
  34. package/lib/cli/routes/channels.$channel.rename.$newName.ts +0 -22
  35. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +0 -34
  36. package/lib/cli/routes/channels.$channel.ts +0 -34
  37. package/lib/cli/routes/channels.add.$channel.ts +0 -33
  38. package/lib/cli/routes/channels.remove.$channel.ts +0 -20
  39. package/lib/cli/routes/channels.ts +0 -39
  40. package/lib/cli/routes/claude.ts +0 -70
  41. package/lib/cli/routes/gateway.listeners.ts +0 -41
  42. package/lib/cli/routes/gateway.logs.ts +0 -123
  43. package/lib/cli/routes/gateway.restart.ts +0 -50
  44. package/lib/cli/routes/gateway.run.ts +0 -41
  45. package/lib/cli/routes/gateway.start.ts +0 -50
  46. package/lib/cli/routes/gateway.status.ts +0 -19
  47. package/lib/cli/routes/gateway.stop.ts +0 -32
  48. package/lib/cli/routes/gateway.ts +0 -55
  49. package/lib/cli/routes/index.ts +0 -219
  50. package/lib/cli/routes/profiles.$profile.as-default.ts +0 -22
  51. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +0 -22
  52. package/lib/cli/routes/profiles.$profile.run.ts +0 -36
  53. package/lib/cli/routes/profiles.add.$profile.ts +0 -49
  54. package/lib/cli/routes/profiles.remove.$profile.ts +0 -20
  55. package/lib/cli/routes/profiles.set.$profile.ts +0 -45
  56. package/lib/cli/routes/profiles.ts +0 -40
  57. package/lib/cli/routes/status.ts +0 -93
  58. package/lib/cli/routes/update.ts +0 -27
  59. package/lib/connectors/connector-adapter.ts +0 -9
  60. package/lib/connectors/connector-config-schema.ts +0 -16
  61. package/lib/connectors/connector-factory.ts +0 -94
  62. package/lib/connectors/connector-listener.ts +0 -20
  63. package/lib/connectors/discord-adapter.ts +0 -51
  64. package/lib/connectors/discord-connector-schema.ts +0 -12
  65. package/lib/connectors/discord-event-processor.ts +0 -48
  66. package/lib/connectors/discord-listener.ts +0 -111
  67. package/lib/connectors/discord.ts +0 -4
  68. package/lib/connectors/gh-adapter.ts +0 -48
  69. package/lib/connectors/gh-connector-schema.ts +0 -12
  70. package/lib/connectors/gh-listener.ts +0 -137
  71. package/lib/connectors/gh.ts +0 -3
  72. package/lib/connectors/match-cron.ts +0 -78
  73. package/lib/connectors/schedule-connector-schema.ts +0 -33
  74. package/lib/connectors/schedule-listener.ts +0 -207
  75. package/lib/connectors/schedule-state-store.ts +0 -54
  76. package/lib/connectors/schedule.ts +0 -4
  77. package/lib/connectors/slack-adapter.ts +0 -36
  78. package/lib/connectors/slack-connector-schema.ts +0 -13
  79. package/lib/connectors/slack-event-processor.ts +0 -97
  80. package/lib/connectors/slack-listener.ts +0 -97
  81. package/lib/connectors/slack.ts +0 -4
  82. package/lib/engine/channels/channels.ts +0 -520
  83. package/lib/engine/claude/claude.ts +0 -205
  84. package/lib/engine/claude/gateway-controller.ts +0 -4
  85. package/lib/engine/fs/file-system.ts +0 -23
  86. package/lib/engine/fs/memory-file-system.ts +0 -102
  87. package/lib/engine/fs/node-file-system.ts +0 -68
  88. package/lib/engine/http/http-client.ts +0 -17
  89. package/lib/engine/http/memory-http-client.ts +0 -36
  90. package/lib/engine/http/node-http-client.ts +0 -23
  91. package/lib/engine/id/id-generator.ts +0 -7
  92. package/lib/engine/id/memory-id-generator.ts +0 -20
  93. package/lib/engine/id/node-id-generator.ts +0 -7
  94. package/lib/engine/logger/logger.ts +0 -11
  95. package/lib/engine/logger/memory-logger.ts +0 -28
  96. package/lib/engine/logger/node-logger.ts +0 -49
  97. package/lib/engine/logger/noop-logger.ts +0 -9
  98. package/lib/engine/mcp/channel-server.ts +0 -123
  99. package/lib/engine/mcp/channel-subscriber.ts +0 -82
  100. package/lib/engine/mcp/mcp.ts +0 -126
  101. package/lib/engine/mcp/read-channel-connectors.ts +0 -34
  102. package/lib/engine/mcp/read-gateway-token.ts +0 -16
  103. package/lib/engine/mcp/usage-hint-for-type.ts +0 -15
  104. package/lib/engine/process/memory-process-runner.ts +0 -88
  105. package/lib/engine/process/node-process-runner.ts +0 -91
  106. package/lib/engine/process/process-runner.ts +0 -33
  107. package/lib/engine/profiles/profile-channel-checker.ts +0 -7
  108. package/lib/engine/profiles/profiles.ts +0 -126
  109. package/lib/engine/settings/mock-settings-reader.ts +0 -27
  110. package/lib/engine/settings/settings-reader.ts +0 -6
  111. package/lib/engine/settings/settings-schema.ts +0 -48
  112. package/lib/engine/settings/settings-store.ts +0 -110
  113. package/lib/engine/time/clock.ts +0 -15
  114. package/lib/engine/time/memory-clock.ts +0 -26
  115. package/lib/engine/time/node-clock.ts +0 -7
  116. package/lib/funnel.ts +0 -294
  117. package/lib/gateway/auth-middleware.ts +0 -44
  118. package/lib/gateway/broadcaster.ts +0 -319
  119. package/lib/gateway/channel-publisher.ts +0 -67
  120. package/lib/gateway/daemon.ts +0 -47
  121. package/lib/gateway/factory.ts +0 -10
  122. package/lib/gateway/funnel-event-store.ts +0 -155
  123. package/lib/gateway/gateway-server.ts +0 -426
  124. package/lib/gateway/gateway-token.ts +0 -79
  125. package/lib/gateway/gateway.ts +0 -209
  126. package/lib/gateway/kill-competing-slack-gateways.ts +0 -56
  127. package/lib/gateway/listener-supervisor.ts +0 -339
  128. package/lib/gateway/listeners-client.ts +0 -128
  129. package/lib/gateway/publish-schema.ts +0 -27
  130. package/lib/gateway/resolve-daemon-script.ts +0 -26
  131. package/lib/gateway/routes/channels.connectors.call.ts +0 -39
  132. package/lib/gateway/routes/channels.publish.ts +0 -44
  133. package/lib/gateway/routes/health.ts +0 -13
  134. package/lib/gateway/routes/index.ts +0 -26
  135. package/lib/gateway/routes/listeners.list.ts +0 -6
  136. package/lib/gateway/routes/listeners.restart.ts +0 -15
  137. package/lib/gateway/routes/listeners.start.ts +0 -15
  138. package/lib/gateway/routes/listeners.stop.ts +0 -15
  139. package/lib/gateway/routes/route-deps.ts +0 -19
  140. package/lib/gateway/routes/status.ts +0 -15
  141. package/lib/gateway/routes/validator.ts +0 -17
  142. package/lib/index.ts +0 -67
  143. package/lib/logger/leuco-human-file-writer.ts +0 -65
  144. package/lib/logger/leuco-human-logger.ts +0 -98
  145. package/lib/logger/leuco-human-record.ts +0 -16
  146. package/lib/logger/leuco-human-stdout-writer.ts +0 -26
  147. package/lib/logger/leuco-human-writer.ts +0 -14
  148. package/lib/logger/leuco-logger-memory-sink.ts +0 -67
  149. package/lib/logger/leuco-logger-record.ts +0 -13
  150. package/lib/logger/leuco-logger-sink.ts +0 -33
  151. package/lib/logger/leuco-logger-sqlite-sink.ts +0 -355
  152. package/lib/logger/leuco-logger.ts +0 -135
  153. package/lib/tui/app.tsx +0 -357
  154. package/lib/tui/components/add-row.tsx +0 -18
  155. package/lib/tui/components/brand.tsx +0 -27
  156. package/lib/tui/components/card.tsx +0 -44
  157. package/lib/tui/components/detail-bar.tsx +0 -46
  158. package/lib/tui/components/editable-field.tsx +0 -33
  159. package/lib/tui/components/empty-state.tsx +0 -11
  160. package/lib/tui/components/gateway-status.tsx +0 -66
  161. package/lib/tui/components/keymap.tsx +0 -29
  162. package/lib/tui/components/menu-item.tsx +0 -73
  163. package/lib/tui/components/menu.tsx +0 -26
  164. package/lib/tui/components/panel-header.tsx +0 -22
  165. package/lib/tui/components/readonly-field.tsx +0 -18
  166. package/lib/tui/components/section-header.tsx +0 -25
  167. package/lib/tui/components/selection-accent.tsx +0 -32
  168. package/lib/tui/components/session-item.tsx +0 -33
  169. package/lib/tui/components/session-list.tsx +0 -33
  170. package/lib/tui/components/ui/hascii/accordion-item.tsx +0 -88
  171. package/lib/tui/components/ui/hascii/accordion.tsx +0 -96
  172. package/lib/tui/components/ui/hascii/alert-dialog.tsx +0 -43
  173. package/lib/tui/components/ui/hascii/badge.tsx +0 -51
  174. package/lib/tui/components/ui/hascii/breadcrumb.tsx +0 -58
  175. package/lib/tui/components/ui/hascii/button.tsx +0 -194
  176. package/lib/tui/components/ui/hascii/card-content.tsx +0 -14
  177. package/lib/tui/components/ui/hascii/card-description.tsx +0 -13
  178. package/lib/tui/components/ui/hascii/card-footer.tsx +0 -14
  179. package/lib/tui/components/ui/hascii/card-header.tsx +0 -14
  180. package/lib/tui/components/ui/hascii/card-title.tsx +0 -13
  181. package/lib/tui/components/ui/hascii/card.tsx +0 -27
  182. package/lib/tui/components/ui/hascii/checkbox.tsx +0 -65
  183. package/lib/tui/components/ui/hascii/command.tsx +0 -159
  184. package/lib/tui/components/ui/hascii/dialog-content.tsx +0 -14
  185. package/lib/tui/components/ui/hascii/dialog-description.tsx +0 -13
  186. package/lib/tui/components/ui/hascii/dialog-footer.tsx +0 -14
  187. package/lib/tui/components/ui/hascii/dialog-header.tsx +0 -14
  188. package/lib/tui/components/ui/hascii/dialog-title.tsx +0 -13
  189. package/lib/tui/components/ui/hascii/dialog.tsx +0 -27
  190. package/lib/tui/components/ui/hascii/file-tree.tsx +0 -142
  191. package/lib/tui/components/ui/hascii/focus-group.tsx +0 -62
  192. package/lib/tui/components/ui/hascii/form-item.tsx +0 -43
  193. package/lib/tui/components/ui/hascii/input-otp.tsx +0 -86
  194. package/lib/tui/components/ui/hascii/input.tsx +0 -130
  195. package/lib/tui/components/ui/hascii/pagination.tsx +0 -105
  196. package/lib/tui/components/ui/hascii/progress.tsx +0 -28
  197. package/lib/tui/components/ui/hascii/select.tsx +0 -131
  198. package/lib/tui/components/ui/hascii/separator.tsx +0 -35
  199. package/lib/tui/components/ui/hascii/sidebar-content.tsx +0 -23
  200. package/lib/tui/components/ui/hascii/sidebar-header.tsx +0 -14
  201. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +0 -67
  202. package/lib/tui/components/ui/hascii/sidebar.tsx +0 -24
  203. package/lib/tui/components/ui/hascii/skeleton.tsx +0 -60
  204. package/lib/tui/components/ui/hascii/slider.tsx +0 -91
  205. package/lib/tui/components/ui/hascii/snackbar.tsx +0 -75
  206. package/lib/tui/components/ui/hascii/sparkline.tsx +0 -53
  207. package/lib/tui/components/ui/hascii/spinner.tsx +0 -47
  208. package/lib/tui/components/ui/hascii/stepper.tsx +0 -54
  209. package/lib/tui/components/ui/hascii/switch.tsx +0 -66
  210. package/lib/tui/components/ui/hascii/table.tsx +0 -95
  211. package/lib/tui/components/ui/hascii/tabs.tsx +0 -59
  212. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +0 -45
  213. package/lib/tui/components/ui/hascii/toggle-group.tsx +0 -99
  214. package/lib/tui/components/ui/hascii/tree.tsx +0 -104
  215. package/lib/tui/components/view-shell.tsx +0 -44
  216. package/lib/tui/filter-input.tsx +0 -33
  217. package/lib/tui/hooks/hascii/use-pressable.ts +0 -54
  218. package/lib/tui/parse-comma-list.ts +0 -14
  219. package/lib/tui/profile-launcher.tsx +0 -61
  220. package/lib/tui/scrollbar-options.ts +0 -19
  221. package/lib/tui/sidebar.tsx +0 -50
  222. package/lib/tui/theme.ts +0 -40
  223. package/lib/tui/tui.tsx +0 -20
  224. package/lib/tui/types.ts +0 -38
  225. package/lib/tui/unique-name.ts +0 -18
  226. package/lib/tui/use-event-stream.ts +0 -133
  227. package/lib/tui/use-snapshot.ts +0 -99
  228. package/lib/tui/utils/hascii/form-item-context.tsx +0 -23
  229. package/lib/tui/utils/hascii/input-focus-context.tsx +0 -31
  230. package/lib/tui/utils/hascii/theme-context.tsx +0 -26
  231. package/lib/tui/utils/hascii/theme.ts +0 -176
  232. package/lib/tui/views/channels-view.tsx +0 -108
  233. package/lib/tui/views/connectors-view.tsx +0 -164
  234. package/lib/tui/views/events-view.tsx +0 -160
  235. package/lib/tui/views/listeners-view.tsx +0 -80
  236. package/lib/tui/views/profiles-view.tsx +0 -152
@@ -1,4 +0,0 @@
1
- export * from "@/connectors/discord-adapter"
2
- export * from "@/connectors/discord-connector-schema"
3
- export * from "@/connectors/discord-event-processor"
4
- export * from "@/connectors/discord-listener"
@@ -1,48 +0,0 @@
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"
4
-
5
- type Deps = {
6
- process?: FunnelProcessRunner
7
- }
8
-
9
- const defaultProcess = new NodeFunnelProcessRunner()
10
-
11
- export class FunnelGhAdapter extends FunnelConnectorAdapter {
12
- private readonly process: FunnelProcessRunner
13
-
14
- constructor(deps: Deps = {}) {
15
- super()
16
- this.process = deps.process ?? defaultProcess
17
- Object.freeze(this)
18
- }
19
-
20
- async call(input: CallInput): Promise<unknown> {
21
- const args = ["api", input.path]
22
-
23
- if (input.method && input.method.toLowerCase() !== "get") {
24
- args.push("-X", input.method.toUpperCase())
25
- }
26
-
27
- const hasBody =
28
- input.body && typeof input.body === "object" && Object.keys(input.body).length > 0
29
-
30
- if (hasBody) {
31
- args.push("--input", "-")
32
- }
33
-
34
- const result = await this.process.run(["gh", ...args], {
35
- input: hasBody ? JSON.stringify(input.body) : undefined,
36
- })
37
-
38
- if (result.exitCode !== 0) {
39
- throw new Error(`gh api failed: ${result.stderr.trim() || result.stdout.trim()}`)
40
- }
41
-
42
- try {
43
- return JSON.parse(result.stdout)
44
- } catch {
45
- return result.stdout
46
- }
47
- }
48
- }
@@ -1,12 +0,0 @@
1
- import { z } from "zod"
2
-
3
- export const ghConnectorSchema = z.object({
4
- id: z.string(),
5
- name: z.string(),
6
- type: z.literal("gh"),
7
- pollInterval: z.number().int().positive().optional(),
8
- createdAt: z.string().datetime().optional(),
9
- updatedAt: z.string().datetime().optional(),
10
- })
11
-
12
- export type GhConnectorConfig = z.infer<typeof ghConnectorSchema>
@@ -1,137 +0,0 @@
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>
24
-
25
- type Deps = {
26
- config: GhConnectorConfig
27
- process?: FunnelProcessRunner
28
- logger?: FunnelLogger
29
- now?: () => Date
30
- }
31
-
32
- const defaultProcess = new NodeFunnelProcessRunner()
33
- const defaultLogger = new NodeFunnelLogger()
34
-
35
- const MAX_SEEN = 10000
36
- const KEEP_SEEN = 5000
37
-
38
- export class FunnelGhListener extends FunnelConnectorListener {
39
- private readonly config: GhConnectorConfig
40
- private readonly process: FunnelProcessRunner
41
- private readonly logger: FunnelLogger
42
- private readonly now: () => Date
43
- private readonly seen = new Map<string, string>()
44
- private bootstrapped = false
45
- private since: string
46
- private timer: ReturnType<typeof setInterval> | null = null
47
-
48
- constructor(deps: Deps) {
49
- super()
50
- this.config = deps.config
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()
55
- }
56
-
57
- async start(notify: NotifyFn): Promise<void> {
58
- await this.pollOnce(notify)
59
-
60
- const interval = this.config.pollInterval ?? 60
61
-
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
75
- }
76
-
77
- async pollOnce(notify: NotifyFn): Promise<void> {
78
- const nextSince = this.now().toISOString()
79
- const params = new URLSearchParams({ since: this.since, all: "false" })
80
-
81
- try {
82
- const result = await this.process.run(["gh", "api", `/notifications?${params}`])
83
-
84
- if (result.exitCode !== 0) {
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 })
93
- return
94
- }
95
-
96
- const items: GhNotification[] = parsed.data
97
-
98
- for (const item of items) {
99
- if (this.seen.get(item.id) === item.updated_at) continue
100
-
101
- this.seen.set(item.id, item.updated_at)
102
-
103
- if (!this.bootstrapped) continue
104
-
105
- const meta: Record<string, string> = {
106
- event_type: "gh",
107
- reason: item.reason,
108
- subject_type: item.subject.type,
109
- subject_url: item.subject.url,
110
- repository: item.repository.full_name,
111
- thread_id: item.id,
112
- updated_at: item.updated_at,
113
- }
114
-
115
- await notify(JSON.stringify(item), meta)
116
- }
117
-
118
- if (this.seen.size > MAX_SEEN) {
119
- const toDrop = this.seen.size - KEEP_SEEN
120
- let dropped = 0
121
-
122
- for (const key of this.seen.keys()) {
123
- if (dropped >= toDrop) break
124
- this.seen.delete(key)
125
- dropped++
126
- }
127
- }
128
-
129
- this.since = nextSince
130
- this.bootstrapped = true
131
- } catch (error) {
132
- this.logger.error("gh poll error", {
133
- error: error instanceof Error ? error.message : String(error),
134
- })
135
- }
136
- }
137
- }
@@ -1,3 +0,0 @@
1
- export * from "@/connectors/gh-adapter"
2
- export * from "@/connectors/gh-connector-schema"
3
- export * from "@/connectors/gh-listener"
@@ -1,78 +0,0 @@
1
- type Field = { min: number; max: number; values: Set<number> }
2
-
3
- const parseField = (expr: string, min: number, max: number): Field => {
4
- const values = new Set<number>()
5
-
6
- for (const part of expr.split(",")) {
7
- const [rangePart, stepPart] = part.split("/")
8
- const step = stepPart ? Number(stepPart) : 1
9
-
10
- if (!Number.isFinite(step) || step <= 0) {
11
- throw new Error(`invalid cron step: "${stepPart}"`)
12
- }
13
-
14
- let lo = min
15
- let hi = max
16
-
17
- if (rangePart === "*" || rangePart === undefined || rangePart === "") {
18
- lo = min
19
- hi = max
20
- } else if (rangePart.includes("-")) {
21
- const [aStr, bStr] = rangePart.split("-")
22
- const a = Number(aStr)
23
- const b = Number(bStr)
24
-
25
- if (!Number.isFinite(a) || !Number.isFinite(b)) {
26
- throw new Error(`invalid cron range: "${rangePart}"`)
27
- }
28
-
29
- lo = a
30
- hi = b
31
- } else {
32
- const n = Number(rangePart)
33
-
34
- if (!Number.isFinite(n)) throw new Error(`invalid cron value: "${rangePart}"`)
35
-
36
- lo = n
37
- hi = stepPart ? max : n
38
- }
39
-
40
- if (lo < min || hi > max || lo > hi) {
41
- throw new Error(`cron value out of range: ${rangePart} (must be ${min}-${max})`)
42
- }
43
-
44
- for (let i = lo; i <= hi; i += step) {
45
- values.add(i)
46
- }
47
- }
48
-
49
- return { min, max, values }
50
- }
51
-
52
- export const matchCron = (expr: string, date: Date): boolean => {
53
- const parts = expr.trim().split(/\s+/)
54
-
55
- if (parts.length !== 5) {
56
- throw new Error(`cron must have 5 fields (got ${parts.length}): "${expr}"`)
57
- }
58
-
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
- }
64
-
65
- const fields = [
66
- { field: parseField(minute, 0, 59), value: date.getMinutes() },
67
- { field: parseField(hour, 0, 23), value: date.getHours() },
68
- { field: parseField(dom, 1, 31), value: date.getDate() },
69
- { field: parseField(month, 1, 12), value: date.getMonth() + 1 },
70
- { field: parseField(dow, 0, 6), value: date.getDay() },
71
- ]
72
-
73
- for (const { field, value } of fields) {
74
- if (!field.values.has(value)) return false
75
- }
76
-
77
- return true
78
- }
@@ -1,33 +0,0 @@
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>
@@ -1,207 +0,0 @@
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
- }
@@ -1,54 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
1
- export * from "@/connectors/match-cron"
2
- export * from "@/connectors/schedule-connector-schema"
3
- export * from "@/connectors/schedule-listener"
4
- export * from "@/connectors/schedule-state-store"
@@ -1,36 +0,0 @@
1
- import { WebClient } from "@slack/web-api"
2
- import { FunnelConnectorAdapter, type CallInput } from "@/connectors/connector-adapter"
3
- import type { SlackConnectorConfig } from "@/connectors/slack-connector-schema"
4
-
5
- export type SlackWebClientLike = {
6
- apiCall: (method: string, options?: Record<string, unknown>) => Promise<unknown>
7
- }
8
-
9
- const toRecord = (value: object): Record<string, unknown> => {
10
- const result: Record<string, unknown> = {}
11
-
12
- for (const [key, val] of Object.entries(value)) result[key] = val
13
-
14
- return result
15
- }
16
-
17
- type Deps = {
18
- config: SlackConnectorConfig
19
- client?: SlackWebClientLike
20
- }
21
-
22
- export class FunnelSlackAdapter extends FunnelConnectorAdapter {
23
- private readonly client: SlackWebClientLike
24
-
25
- constructor(deps: Deps) {
26
- super()
27
- this.client = deps.client ?? new WebClient(deps.config.botToken)
28
- Object.freeze(this)
29
- }
30
-
31
- async call(input: CallInput): Promise<unknown> {
32
- const body = input.body !== null && typeof input.body === "object" ? toRecord(input.body) : {}
33
-
34
- return await this.client.apiCall(input.path, body)
35
- }
36
- }
@@ -1,13 +0,0 @@
1
- import { z } from "zod"
2
-
3
- export const slackConnectorSchema = z.object({
4
- id: z.string(),
5
- name: z.string(),
6
- type: z.literal("slack"),
7
- botToken: z.string().startsWith("xoxb-"),
8
- appToken: z.string().startsWith("xapp-"),
9
- createdAt: z.string().datetime().optional(),
10
- updatedAt: z.string().datetime().optional(),
11
- })
12
-
13
- export type SlackConnectorConfig = z.infer<typeof slackConnectorSchema>