@interactive-inc/claude-funnel 0.2.0 → 0.4.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 (97) hide show
  1. package/README.md +82 -26
  2. package/lib/funnel.ts +49 -5
  3. package/lib/index.ts +8 -2
  4. package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
  5. package/lib/modules/channels/funnel-channels.ts +50 -8
  6. package/lib/modules/claude/funnel-claude.ts +79 -1
  7. package/lib/modules/connectors/connector-config-schema.ts +16 -0
  8. package/lib/modules/connectors/connector-existence-checker.ts +3 -0
  9. package/lib/modules/connectors/discord-connector-schema.ts +9 -0
  10. package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
  11. package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
  12. package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
  13. package/lib/modules/connectors/funnel-connectors.ts +98 -77
  14. package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
  15. package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
  16. package/lib/modules/connectors/funnel-discord-store.ts +84 -0
  17. package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
  18. package/lib/modules/connectors/funnel-gh-store.ts +84 -0
  19. package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
  20. package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
  21. package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
  22. package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
  23. package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
  24. package/lib/modules/connectors/funnel-slack-store.ts +86 -0
  25. package/lib/modules/connectors/gh-connector-schema.ts +9 -0
  26. package/lib/modules/connectors/match-cron.ts +72 -0
  27. package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
  28. package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
  29. package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
  30. package/lib/modules/connectors/slack-connector-schema.ts +10 -0
  31. package/lib/modules/gateway/daemon.ts +30 -13
  32. package/lib/modules/mcp/channel-server.ts +1 -2
  33. package/lib/modules/profiles/funnel-profiles.ts +123 -0
  34. package/lib/modules/profiles/profile-channel-checker.ts +3 -0
  35. package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
  36. package/lib/modules/repos/funnel-repositories.ts +4 -4
  37. package/lib/modules/router/to-request.ts +2 -5
  38. package/lib/modules/schedule/funnel-schedule.ts +34 -0
  39. package/lib/modules/settings/funnel-settings-store.ts +1 -2
  40. package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -2
  41. package/lib/modules/settings/settings-schema.ts +3 -37
  42. package/lib/routes/claude/claude.help.ts +9 -4
  43. package/lib/routes/claude/claude.ts +44 -7
  44. package/lib/routes/connectors/add.help.ts +10 -4
  45. package/lib/routes/connectors/add.ts +10 -1
  46. package/lib/routes/connectors/routes.ts +6 -2
  47. package/lib/routes/connectors/schedules-add.help.ts +11 -0
  48. package/lib/routes/connectors/schedules-add.ts +33 -0
  49. package/lib/routes/connectors/schedules-group.help.ts +1 -0
  50. package/lib/routes/connectors/schedules-group.ts +38 -0
  51. package/lib/routes/connectors/schedules-remove.help.ts +3 -0
  52. package/lib/routes/connectors/schedules-remove.ts +17 -0
  53. package/lib/routes/connectors/set.ts +47 -5
  54. package/lib/routes/connectors/show.ts +9 -0
  55. package/lib/routes/profiles/add.help.ts +3 -0
  56. package/lib/routes/{agents → profiles}/add.ts +4 -4
  57. package/lib/routes/profiles/group.help.ts +16 -0
  58. package/lib/routes/profiles/group.ts +25 -0
  59. package/lib/routes/profiles/launch.help.ts +4 -0
  60. package/lib/routes/{agents → profiles}/launch.ts +9 -8
  61. package/lib/routes/profiles/remove.help.ts +3 -0
  62. package/lib/routes/{agents → profiles}/remove.ts +4 -4
  63. package/lib/routes/profiles/rename.help.ts +5 -0
  64. package/lib/routes/{agents → profiles}/rename.ts +4 -4
  65. package/lib/routes/profiles/routes.ts +18 -0
  66. package/lib/routes/profiles/set.help.ts +5 -0
  67. package/lib/routes/{agents → profiles}/set.ts +4 -4
  68. package/lib/routes/repos/add.help.ts +3 -2
  69. package/lib/routes/repos/add.ts +3 -2
  70. package/lib/routes/repos/group.help.ts +1 -1
  71. package/lib/routes/request/discord-help.ts +9 -0
  72. package/lib/routes/request/discord.help.ts +19 -0
  73. package/lib/routes/request/discord.ts +65 -0
  74. package/lib/routes/request/group.help.ts +15 -0
  75. package/lib/routes/request/group.ts +9 -0
  76. package/lib/routes/request/routes.ts +14 -0
  77. package/lib/routes/request/slack-help.ts +9 -0
  78. package/lib/routes/request/slack.help.ts +19 -0
  79. package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
  80. package/lib/routes/status/status.help.ts +1 -1
  81. package/lib/routes/status/status.ts +7 -7
  82. package/lib/routes/update/routes.ts +4 -0
  83. package/lib/routes/update/update.help.ts +5 -0
  84. package/lib/routes/update/update.ts +21 -0
  85. package/lib/routes.ts +6 -2
  86. package/package.json +1 -1
  87. package/lib/modules/agents/funnel-agents.ts +0 -105
  88. package/lib/modules/connectors/resolve-listener.ts +0 -13
  89. package/lib/routes/agents/add.help.ts +0 -3
  90. package/lib/routes/agents/group.help.ts +0 -13
  91. package/lib/routes/agents/group.ts +0 -25
  92. package/lib/routes/agents/launch.help.ts +0 -3
  93. package/lib/routes/agents/remove.help.ts +0 -3
  94. package/lib/routes/agents/rename.help.ts +0 -5
  95. package/lib/routes/agents/routes.ts +0 -17
  96. package/lib/routes/agents/set.help.ts +0 -5
  97. package/lib/routes/connectors/call.help.ts +0 -17
@@ -1,124 +1,145 @@
1
+ import type { ChannelConnectorRefUpdater } from "@/modules/channels/channel-connector-ref-updater"
2
+ import type { ConnectorConfig } from "@/modules/connectors/connector-config-schema"
1
3
  import type { CallInput } from "@/modules/connectors/funnel-connector-adapter"
2
- import { FunnelDiscordAdapter } from "@/modules/connectors/funnel-discord-adapter"
3
- import { FunnelGhAdapter } from "@/modules/connectors/funnel-gh-adapter"
4
- import { FunnelSlackAdapter } from "@/modules/connectors/funnel-slack-adapter"
5
- import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
6
- import type { ConnectorConfig } from "@/modules/settings/settings-schema"
4
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
5
+ import type {
6
+ DiscordUpdateFields,
7
+ FunnelDiscordStore,
8
+ } from "@/modules/connectors/funnel-discord-store"
9
+ import type { FunnelGhStore, GhUpdateFields } from "@/modules/connectors/funnel-gh-store"
10
+ import type { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
11
+ import type { FunnelSlackStore, SlackUpdateFields } from "@/modules/connectors/funnel-slack-store"
7
12
 
8
13
  type Deps = {
9
- store: FunnelSettingsReader
10
- }
11
-
12
- type UpdateFields = {
13
- botToken?: string
14
- appToken?: string
15
- pollInterval?: number
14
+ slack: FunnelSlackStore
15
+ gh: FunnelGhStore
16
+ discord: FunnelDiscordStore
17
+ schedule: FunnelScheduleStore
18
+ refUpdater: ChannelConnectorRefUpdater
16
19
  }
17
20
 
18
21
  export class FunnelConnectors {
19
- private readonly store: FunnelSettingsReader
22
+ private readonly slack: FunnelSlackStore
23
+ private readonly gh: FunnelGhStore
24
+ private readonly discord: FunnelDiscordStore
25
+ private readonly schedule: FunnelScheduleStore
26
+ private readonly refUpdater: ChannelConnectorRefUpdater
20
27
 
21
28
  constructor(deps: Deps) {
22
- this.store = deps.store
29
+ this.slack = deps.slack
30
+ this.gh = deps.gh
31
+ this.discord = deps.discord
32
+ this.schedule = deps.schedule
33
+ this.refUpdater = deps.refUpdater
23
34
  Object.freeze(this)
24
35
  }
25
36
 
26
37
  list(): ConnectorConfig[] {
27
- return this.store.read().connectors
38
+ return [
39
+ ...this.slack.list(),
40
+ ...this.gh.list(),
41
+ ...this.discord.list(),
42
+ ...this.schedule.list(),
43
+ ]
28
44
  }
29
45
 
30
46
  get(name: string): ConnectorConfig | null {
31
- return this.list().find((c) => c.name === name) ?? null
47
+ return (
48
+ this.slack.get(name) ??
49
+ this.gh.get(name) ??
50
+ this.discord.get(name) ??
51
+ this.schedule.get(name)
52
+ )
32
53
  }
33
54
 
34
- add(config: ConnectorConfig): void {
35
- const settings = this.store.read()
55
+ has(name: string): boolean {
56
+ return (
57
+ this.slack.has(name) ||
58
+ this.gh.has(name) ||
59
+ this.discord.has(name) ||
60
+ this.schedule.has(name)
61
+ )
62
+ }
36
63
 
37
- if (settings.connectors.some((c) => c.name === config.name)) {
38
- throw new Error(`connector "${config.name}" already exists`)
39
- }
64
+ add(config: ConnectorConfig): void {
65
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
40
66
 
41
- settings.connectors.push(config)
67
+ if (config.type === "slack") return this.slack.add(config)
68
+ if (config.type === "gh") return this.gh.add(config)
69
+ if (config.type === "discord") return this.discord.add(config)
42
70
 
43
- this.store.write(settings)
71
+ return this.schedule.add(config)
44
72
  }
45
73
 
46
- rename(oldName: string, newName: string): void {
47
- const settings = this.store.read()
48
-
49
- const connector = settings.connectors.find((c) => c.name === oldName)
74
+ updateSlack(name: string, fields: SlackUpdateFields): void {
75
+ this.slack.update(name, fields)
76
+ }
50
77
 
51
- if (!connector) throw new Error(`connector "${oldName}" not found`)
78
+ updateGh(name: string, fields: GhUpdateFields): void {
79
+ this.gh.update(name, fields)
80
+ }
52
81
 
53
- if (settings.connectors.some((c) => c.name === newName)) {
54
- throw new Error(`connector "${newName}" already exists`)
55
- }
82
+ updateDiscord(name: string, fields: DiscordUpdateFields): void {
83
+ this.discord.update(name, fields)
84
+ }
56
85
 
57
- connector.name = newName
86
+ remove(name: string): void {
87
+ const current = this.get(name)
58
88
 
59
- for (const channel of settings.channels) {
60
- const index = channel.connectors.indexOf(oldName)
89
+ if (!current) throw new Error(`connector "${name}" not found`)
61
90
 
62
- if (index >= 0) channel.connectors[index] = newName
63
- }
91
+ if (current.type === "slack") this.slack.remove(name)
92
+ else if (current.type === "gh") this.gh.remove(name)
93
+ else if (current.type === "discord") this.discord.remove(name)
94
+ else this.schedule.remove(name)
64
95
 
65
- this.store.write(settings)
96
+ this.refUpdater.removeRef(name)
66
97
  }
67
98
 
68
- update(name: string, fields: UpdateFields): void {
69
- const settings = this.store.read()
70
-
71
- const connector = settings.connectors.find((c) => c.name === name)
99
+ rename(oldName: string, newName: string): void {
100
+ const current = this.get(oldName)
72
101
 
73
- if (!connector) throw new Error(`connector "${name}" not found`)
102
+ if (!current) throw new Error(`connector "${oldName}" not found`)
103
+ if (this.has(newName)) throw new Error(`connector "${newName}" already exists`)
74
104
 
75
- if (connector.type === "slack") {
76
- if (fields.botToken !== undefined) connector.botToken = fields.botToken
77
- if (fields.appToken !== undefined) connector.appToken = fields.appToken
78
- } else if (connector.type === "gh") {
79
- if (fields.pollInterval !== undefined) connector.pollInterval = fields.pollInterval
80
- } else if (connector.type === "discord") {
81
- if (fields.botToken !== undefined) connector.botToken = fields.botToken
82
- }
105
+ if (current.type === "slack") this.slack.rename(oldName, newName)
106
+ else if (current.type === "gh") this.gh.rename(oldName, newName)
107
+ else if (current.type === "discord") this.discord.rename(oldName, newName)
108
+ else this.schedule.rename(oldName, newName)
83
109
 
84
- this.store.write(settings)
110
+ this.refUpdater.renameRef(oldName, newName)
85
111
  }
86
112
 
87
- remove(name: string): void {
88
- const settings = this.store.read()
89
-
90
- const index = settings.connectors.findIndex((c) => c.name === name)
113
+ async callSlack(name: string, input: CallInput): Promise<unknown> {
114
+ const config = this.slack.get(name)
91
115
 
92
- if (index < 0) throw new Error(`connector "${name}" not found`)
116
+ if (!config) throw new Error(`slack connector "${name}" not found`)
93
117
 
94
- settings.connectors.splice(index, 1)
118
+ return await this.slack.createAdapter(config).call(input)
119
+ }
95
120
 
96
- for (const channel of settings.channels) {
97
- const ci = channel.connectors.indexOf(name)
121
+ async callGh(name: string, input: CallInput): Promise<unknown> {
122
+ const config = this.gh.get(name)
98
123
 
99
- if (ci >= 0) channel.connectors.splice(ci, 1)
100
- }
124
+ if (!config) throw new Error(`gh connector "${name}" not found`)
101
125
 
102
- this.store.write(settings)
126
+ return await this.gh.createAdapter(config).call(input)
103
127
  }
104
128
 
105
- async call(name: string, input: CallInput): Promise<unknown> {
106
- const connector = this.get(name)
107
-
108
- if (!connector) throw new Error(`connector "${name}" not found`)
129
+ async callDiscord(name: string, input: CallInput): Promise<unknown> {
130
+ const config = this.discord.get(name)
109
131
 
110
- if (connector.type === "slack") {
111
- return await new FunnelSlackAdapter({ config: connector }).call(input)
112
- }
132
+ if (!config) throw new Error(`discord connector "${name}" not found`)
113
133
 
114
- if (connector.type === "gh") {
115
- return await new FunnelGhAdapter().call(input)
116
- }
117
-
118
- if (connector.type === "discord") {
119
- return await new FunnelDiscordAdapter({ config: connector }).call(input)
120
- }
134
+ return await this.discord.createAdapter(config).call(input)
135
+ }
121
136
 
122
- throw new Error(`unsupported connector type: ${(connector as { type: string }).type}`)
137
+ createListeners(): { config: ConnectorConfig; listener: FunnelConnectorListener }[] {
138
+ return [
139
+ ...this.slack.createAllListeners(),
140
+ ...this.gh.createAllListeners(),
141
+ ...this.discord.createAllListeners(),
142
+ ...this.schedule.createAllListeners(),
143
+ ]
123
144
  }
124
145
  }
@@ -4,7 +4,7 @@ import {
4
4
  } from "@/modules/connectors/funnel-connector-adapter"
5
5
  import { FunnelHttpClient } from "@/modules/http/funnel-http-client"
6
6
  import { NodeFunnelHttpClient } from "@/modules/http/node-funnel-http-client"
7
- import type { DiscordConnectorConfig } from "@/modules/settings/settings-schema"
7
+ import type { DiscordConnectorConfig } from "@/modules/connectors/discord-connector-schema"
8
8
 
9
9
  const DISCORD_API_BASE = "https://discord.com/api/v10"
10
10
 
@@ -5,7 +5,7 @@ import {
5
5
  } from "@/modules/connectors/funnel-connector-listener"
6
6
  import { FunnelDiscordEventProcessor } from "@/modules/connectors/funnel-discord-event-processor"
7
7
  import { logger } from "@/modules/logger"
8
- import type { DiscordConnectorConfig } from "@/modules/settings/settings-schema"
8
+ import type { DiscordConnectorConfig } from "@/modules/connectors/discord-connector-schema"
9
9
 
10
10
  type Deps = {
11
11
  config: DiscordConnectorConfig
@@ -0,0 +1,84 @@
1
+ import { FunnelCallableConnectorStore } from "@/modules/connectors/funnel-callable-connector-store"
2
+ import type { FunnelConnectorAdapter } from "@/modules/connectors/funnel-connector-adapter"
3
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
4
+ import {
5
+ DEFAULT_FUNNEL_DIR,
6
+ FunnelJsonConnectorStore,
7
+ } from "@/modules/connectors/funnel-json-connector-store"
8
+ import { FunnelDiscordAdapter } from "@/modules/connectors/funnel-discord-adapter"
9
+ import { FunnelDiscordListener } from "@/modules/connectors/funnel-discord-listener"
10
+ import {
11
+ type DiscordConnectorConfig,
12
+ discordConnectorSchema,
13
+ } from "@/modules/connectors/discord-connector-schema"
14
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
15
+
16
+ type Deps = {
17
+ fs?: FunnelFileSystem
18
+ dir?: string
19
+ }
20
+
21
+ export type DiscordUpdateFields = {
22
+ botToken?: string
23
+ }
24
+
25
+ export class FunnelDiscordStore extends FunnelCallableConnectorStore<DiscordConnectorConfig> {
26
+ readonly type = "discord" as const
27
+ private readonly store: FunnelJsonConnectorStore<DiscordConnectorConfig>
28
+
29
+ constructor(deps: Deps = {}) {
30
+ super()
31
+ this.store = new FunnelJsonConnectorStore<DiscordConnectorConfig>({
32
+ type: "discord",
33
+ schema: discordConnectorSchema,
34
+ fs: deps.fs,
35
+ dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
36
+ })
37
+ Object.freeze(this)
38
+ }
39
+
40
+ list(): DiscordConnectorConfig[] {
41
+ return this.store.list()
42
+ }
43
+
44
+ get(name: string): DiscordConnectorConfig | null {
45
+ return this.store.get(name)
46
+ }
47
+
48
+ has(name: string): boolean {
49
+ return this.store.has(name)
50
+ }
51
+
52
+ add(config: DiscordConnectorConfig): void {
53
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
54
+
55
+ this.store.write(config)
56
+ }
57
+
58
+ update(name: string, fields: DiscordUpdateFields): void {
59
+ const current = this.store.get(name)
60
+
61
+ if (!current) throw new Error(`connector "${name}" not found`)
62
+
63
+ this.store.write({
64
+ ...current,
65
+ botToken: fields.botToken ?? current.botToken,
66
+ })
67
+ }
68
+
69
+ remove(name: string): void {
70
+ this.store.remove(name)
71
+ }
72
+
73
+ rename(oldName: string, newName: string): void {
74
+ this.store.rename(oldName, newName)
75
+ }
76
+
77
+ createListener(config: DiscordConnectorConfig): FunnelConnectorListener {
78
+ return new FunnelDiscordListener({ config })
79
+ }
80
+
81
+ createAdapter(config: DiscordConnectorConfig): FunnelConnectorAdapter {
82
+ return new FunnelDiscordAdapter({ config })
83
+ }
84
+ }
@@ -5,7 +5,7 @@ import {
5
5
  import { logger } from "@/modules/logger"
6
6
  import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
7
7
  import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
8
- import type { GhConnectorConfig } from "@/modules/settings/settings-schema"
8
+ import type { GhConnectorConfig } from "@/modules/connectors/gh-connector-schema"
9
9
 
10
10
  type GhNotification = {
11
11
  id: string
@@ -0,0 +1,84 @@
1
+ import { FunnelCallableConnectorStore } from "@/modules/connectors/funnel-callable-connector-store"
2
+ import type { FunnelConnectorAdapter } from "@/modules/connectors/funnel-connector-adapter"
3
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
4
+ import {
5
+ DEFAULT_FUNNEL_DIR,
6
+ FunnelJsonConnectorStore,
7
+ } from "@/modules/connectors/funnel-json-connector-store"
8
+ import { FunnelGhAdapter } from "@/modules/connectors/funnel-gh-adapter"
9
+ import { FunnelGhListener } from "@/modules/connectors/funnel-gh-listener"
10
+ import {
11
+ type GhConnectorConfig,
12
+ ghConnectorSchema,
13
+ } from "@/modules/connectors/gh-connector-schema"
14
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
15
+
16
+ type Deps = {
17
+ fs?: FunnelFileSystem
18
+ dir?: string
19
+ }
20
+
21
+ export type GhUpdateFields = {
22
+ pollInterval?: number
23
+ }
24
+
25
+ export class FunnelGhStore extends FunnelCallableConnectorStore<GhConnectorConfig> {
26
+ readonly type = "gh" as const
27
+ private readonly store: FunnelJsonConnectorStore<GhConnectorConfig>
28
+
29
+ constructor(deps: Deps = {}) {
30
+ super()
31
+ this.store = new FunnelJsonConnectorStore<GhConnectorConfig>({
32
+ type: "gh",
33
+ schema: ghConnectorSchema,
34
+ fs: deps.fs,
35
+ dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
36
+ })
37
+ Object.freeze(this)
38
+ }
39
+
40
+ list(): GhConnectorConfig[] {
41
+ return this.store.list()
42
+ }
43
+
44
+ get(name: string): GhConnectorConfig | null {
45
+ return this.store.get(name)
46
+ }
47
+
48
+ has(name: string): boolean {
49
+ return this.store.has(name)
50
+ }
51
+
52
+ add(config: GhConnectorConfig): void {
53
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
54
+
55
+ this.store.write(config)
56
+ }
57
+
58
+ update(name: string, fields: GhUpdateFields): void {
59
+ const current = this.store.get(name)
60
+
61
+ if (!current) throw new Error(`connector "${name}" not found`)
62
+
63
+ this.store.write({
64
+ ...current,
65
+ pollInterval: fields.pollInterval ?? current.pollInterval,
66
+ })
67
+ }
68
+
69
+ remove(name: string): void {
70
+ this.store.remove(name)
71
+ }
72
+
73
+ rename(oldName: string, newName: string): void {
74
+ this.store.rename(oldName, newName)
75
+ }
76
+
77
+ createListener(config: GhConnectorConfig): FunnelConnectorListener {
78
+ return new FunnelGhListener({ config })
79
+ }
80
+
81
+ createAdapter(_config: GhConnectorConfig): FunnelConnectorAdapter {
82
+ return new FunnelGhAdapter()
83
+ }
84
+ }
@@ -0,0 +1,100 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import type { ZodType } from "zod"
4
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
5
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
6
+
7
+ const defaultFs = new NodeFunnelFileSystem()
8
+
9
+ export const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel")
10
+
11
+ type Props<TConfig> = {
12
+ type: string
13
+ schema: ZodType<TConfig>
14
+ fs?: FunnelFileSystem
15
+ dir?: string
16
+ }
17
+
18
+ export class FunnelJsonConnectorStore<TConfig extends { type: string; name: string }> {
19
+ private readonly type: string
20
+ private readonly schema: ZodType<TConfig>
21
+ private readonly fs: FunnelFileSystem
22
+ private readonly dir: string
23
+
24
+ constructor(props: Props<TConfig>) {
25
+ this.type = props.type
26
+ this.schema = props.schema
27
+ this.fs = props.fs ?? defaultFs
28
+ const base = props.dir ?? DEFAULT_FUNNEL_DIR
29
+ this.dir = join(base, "connectors", props.type)
30
+ Object.freeze(this)
31
+ }
32
+
33
+ list(): TConfig[] {
34
+ if (!this.fs.existsSync(this.dir)) return []
35
+
36
+ const files = this.fs.readdirSync(this.dir).filter((f) => f.endsWith(".json"))
37
+ const configs: TConfig[] = []
38
+
39
+ for (const file of files) {
40
+ const name = file.slice(0, -5)
41
+ const config = this.get(name)
42
+
43
+ if (config) configs.push(config)
44
+ }
45
+
46
+ return configs
47
+ }
48
+
49
+ get(name: string): TConfig | null {
50
+ const path = this.pathFor(name)
51
+
52
+ if (!this.fs.existsSync(path)) return null
53
+
54
+ const content = this.fs.readFileSync(path)
55
+ const parsed = JSON.parse(content)
56
+ const result = this.schema.safeParse(parsed)
57
+
58
+ if (!result.success) {
59
+ throw new Error(
60
+ `invalid ${this.type} connector "${name}": ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`,
61
+ )
62
+ }
63
+
64
+ return result.data
65
+ }
66
+
67
+ has(name: string): boolean {
68
+ return this.fs.existsSync(this.pathFor(name))
69
+ }
70
+
71
+ write(config: TConfig): void {
72
+ this.fs.mkdirSync(this.dir, { recursive: true })
73
+ this.fs.writeFileSync(this.pathFor(config.name), `${JSON.stringify(config, null, 2)}\n`)
74
+ }
75
+
76
+ remove(name: string): void {
77
+ if (!this.has(name)) throw new Error(`connector "${name}" not found`)
78
+
79
+ this.fs.unlink(this.pathFor(name))
80
+ }
81
+
82
+ rename(oldName: string, newName: string): void {
83
+ const config = this.get(oldName)
84
+
85
+ if (!config) throw new Error(`connector "${oldName}" not found`)
86
+
87
+ if (this.has(newName)) {
88
+ throw new Error(`connector "${newName}" already exists`)
89
+ }
90
+
91
+ const renamed = { ...config, name: newName } as TConfig
92
+
93
+ this.write(renamed)
94
+ this.fs.unlink(this.pathFor(oldName))
95
+ }
96
+
97
+ pathFor(name: string): string {
98
+ return join(this.dir, `${name}.json`)
99
+ }
100
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ FunnelConnectorListener,
3
+ type NotifyFn,
4
+ } from "@/modules/connectors/funnel-connector-listener"
5
+ import { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
6
+ import { matchCron } from "@/modules/connectors/match-cron"
7
+ import { ScheduleLastFiredStore } from "@/modules/connectors/schedule-last-fired-store"
8
+ import { logger } from "@/modules/logger"
9
+ import type { ScheduleConnectorConfig } from "@/modules/connectors/schedule-connector-schema"
10
+
11
+ type Deps = {
12
+ config: ScheduleConnectorConfig
13
+ store: FunnelScheduleStore
14
+ lastFiredStore: ScheduleLastFiredStore
15
+ now?: () => Date
16
+ }
17
+
18
+ const MAX_CATCHUP_MINUTES = 60 * 24
19
+
20
+ export class FunnelScheduleListener extends FunnelConnectorListener {
21
+ private readonly config: ScheduleConnectorConfig
22
+ private readonly store: FunnelScheduleStore
23
+ private readonly lastFiredStore: ScheduleLastFiredStore
24
+ private readonly now: () => Date
25
+
26
+ constructor(deps: Deps) {
27
+ super()
28
+ this.config = deps.config
29
+ this.store = deps.store
30
+ this.lastFiredStore = deps.lastFiredStore
31
+ this.now = deps.now ?? (() => new Date())
32
+ Object.freeze(this)
33
+ }
34
+
35
+ async start(notify: NotifyFn): Promise<void> {
36
+ const scheduleNext = () => {
37
+ const date = this.now()
38
+ const msUntilNextMinute = 60_000 - (date.getSeconds() * 1000 + date.getMilliseconds())
39
+ const timer = setTimeout(async () => {
40
+ await this.tick(notify)
41
+ scheduleNext()
42
+ }, msUntilNextMinute)
43
+
44
+ timer.unref()
45
+ }
46
+
47
+ await this.tick(notify)
48
+ scheduleNext()
49
+ }
50
+
51
+ async tick(notify: NotifyFn): Promise<void> {
52
+ const config = this.store.get(this.config.name)
53
+
54
+ if (!config) return
55
+
56
+ const now = this.truncateToMinute(this.now())
57
+ const state = this.lastFiredStore.load()
58
+ let changed = false
59
+
60
+ for (const entry of config.entries) {
61
+ if (!entry.enabled) continue
62
+
63
+ const lastFired = state.get(entry.id)
64
+ const searchFrom = lastFired ? new Date(lastFired.getTime() + 60_000) : now
65
+
66
+ if (searchFrom.getTime() > now.getTime()) continue
67
+
68
+ const match = this.findMostRecentMatch(entry.cron, searchFrom, now, entry.id)
69
+
70
+ if (!match) continue
71
+
72
+ const meta: Record<string, string> = {
73
+ event_type: "schedule",
74
+ schedule_id: entry.id,
75
+ cron: entry.cron,
76
+ fired_at: match.toISOString(),
77
+ }
78
+
79
+ if (match.getTime() !== now.getTime()) meta.catchup = "true"
80
+
81
+ await notify(entry.prompt, meta)
82
+ state.set(entry.id, match)
83
+ changed = true
84
+ }
85
+
86
+ if (changed) this.lastFiredStore.save(state)
87
+ }
88
+
89
+ private findMostRecentMatch(
90
+ cron: string,
91
+ from: Date,
92
+ until: Date,
93
+ entryId: string,
94
+ ): Date | null {
95
+ const maxIterations = Math.min(
96
+ MAX_CATCHUP_MINUTES,
97
+ Math.floor((until.getTime() - from.getTime()) / 60_000) + 1,
98
+ )
99
+
100
+ for (let i = 0; i < maxIterations; i++) {
101
+ const candidate = new Date(until.getTime() - i * 60_000)
102
+
103
+ try {
104
+ if (matchCron(cron, candidate)) return candidate
105
+ } catch (error) {
106
+ logger.error("invalid cron expression in schedule", {
107
+ connector: this.config.name,
108
+ id: entryId,
109
+ cron,
110
+ error: error instanceof Error ? error.message : String(error),
111
+ })
112
+ return null
113
+ }
114
+ }
115
+
116
+ return null
117
+ }
118
+
119
+ private truncateToMinute(date: Date): Date {
120
+ const copy = new Date(date.getTime())
121
+ copy.setSeconds(0, 0)
122
+ return copy
123
+ }
124
+ }