@interactive-inc/claude-funnel 0.3.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 (52) hide show
  1. package/README.md +66 -16
  2. package/lib/funnel.ts +46 -2
  3. package/lib/index.ts +4 -0
  4. package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
  5. package/lib/modules/channels/funnel-channels.ts +49 -7
  6. package/lib/modules/connectors/connector-config-schema.ts +16 -0
  7. package/lib/modules/connectors/connector-existence-checker.ts +3 -0
  8. package/lib/modules/connectors/discord-connector-schema.ts +9 -0
  9. package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
  10. package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
  11. package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
  12. package/lib/modules/connectors/funnel-connectors.ts +98 -77
  13. package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
  14. package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
  15. package/lib/modules/connectors/funnel-discord-store.ts +84 -0
  16. package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
  17. package/lib/modules/connectors/funnel-gh-store.ts +84 -0
  18. package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
  19. package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
  20. package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
  21. package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
  22. package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
  23. package/lib/modules/connectors/funnel-slack-store.ts +86 -0
  24. package/lib/modules/connectors/gh-connector-schema.ts +9 -0
  25. package/lib/modules/connectors/match-cron.ts +72 -0
  26. package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
  27. package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
  28. package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
  29. package/lib/modules/connectors/slack-connector-schema.ts +10 -0
  30. package/lib/modules/gateway/daemon.ts +30 -13
  31. package/lib/modules/profiles/funnel-profiles.ts +18 -0
  32. package/lib/modules/profiles/profile-channel-checker.ts +3 -0
  33. package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
  34. package/lib/modules/schedule/funnel-schedule.ts +34 -0
  35. package/lib/modules/settings/funnel-settings-store.ts +0 -1
  36. package/lib/modules/settings/mock-funnel-settings-reader.ts +0 -1
  37. package/lib/modules/settings/settings-schema.ts +0 -34
  38. package/lib/routes/connectors/add.help.ts +10 -4
  39. package/lib/routes/connectors/add.ts +10 -1
  40. package/lib/routes/connectors/routes.ts +6 -0
  41. package/lib/routes/connectors/schedules-add.help.ts +11 -0
  42. package/lib/routes/connectors/schedules-add.ts +33 -0
  43. package/lib/routes/connectors/schedules-group.help.ts +1 -0
  44. package/lib/routes/connectors/schedules-group.ts +38 -0
  45. package/lib/routes/connectors/schedules-remove.help.ts +3 -0
  46. package/lib/routes/connectors/schedules-remove.ts +17 -0
  47. package/lib/routes/connectors/set.ts +47 -5
  48. package/lib/routes/connectors/show.ts +9 -0
  49. package/lib/routes/request/discord.ts +1 -1
  50. package/lib/routes/request/slack.ts +1 -1
  51. package/package.json +1 -1
  52. package/lib/modules/connectors/resolve-listener.ts +0 -13
@@ -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
+ }
@@ -0,0 +1,178 @@
1
+ import { join } from "node:path"
2
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
3
+ import { FunnelConnectorTypeStore } from "@/modules/connectors/funnel-connector-type-store"
4
+ import { logger } from "@/modules/logger"
5
+ import { DEFAULT_FUNNEL_DIR } from "@/modules/connectors/funnel-json-connector-store"
6
+ import { FunnelScheduleListener } from "@/modules/connectors/funnel-schedule-listener"
7
+ import { ScheduleLastFiredStore } from "@/modules/connectors/schedule-last-fired-store"
8
+ import {
9
+ type ScheduleConnectorConfig,
10
+ type ScheduleEntry,
11
+ scheduleEntrySchema,
12
+ } from "@/modules/connectors/schedule-connector-schema"
13
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
14
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
15
+
16
+ type Deps = {
17
+ fs?: FunnelFileSystem
18
+ dir?: string
19
+ }
20
+
21
+ const defaultFs = new NodeFunnelFileSystem()
22
+
23
+ export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnectorConfig> {
24
+ readonly type = "schedule" as const
25
+ private readonly fs: FunnelFileSystem
26
+ private readonly baseDir: string
27
+ private readonly dir: string
28
+
29
+ constructor(deps: Deps = {}) {
30
+ super()
31
+ this.fs = deps.fs ?? defaultFs
32
+ this.baseDir = deps.dir ?? DEFAULT_FUNNEL_DIR
33
+ this.dir = join(this.baseDir, "connectors", "schedule")
34
+ Object.freeze(this)
35
+ }
36
+
37
+ list(): ScheduleConnectorConfig[] {
38
+ if (!this.fs.existsSync(this.dir)) return []
39
+
40
+ const files = this.fs.readdirSync(this.dir).filter((f) => f.endsWith(".jsonl"))
41
+ const configs: ScheduleConnectorConfig[] = []
42
+
43
+ for (const file of files) {
44
+ const name = file.slice(0, -6)
45
+ const config = this.get(name)
46
+
47
+ if (config) configs.push(config)
48
+ }
49
+
50
+ return configs
51
+ }
52
+
53
+ get(name: string): ScheduleConnectorConfig | null {
54
+ const path = this.pathFor(name)
55
+
56
+ if (!this.fs.existsSync(path)) return null
57
+
58
+ return { type: "schedule", name, entries: this.readEntries(name) }
59
+ }
60
+
61
+ has(name: string): boolean {
62
+ return this.fs.existsSync(this.pathFor(name))
63
+ }
64
+
65
+ add(config: ScheduleConnectorConfig): void {
66
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
67
+
68
+ this.fs.mkdirSync(this.dir, { recursive: true })
69
+ const lines = config.entries.map((e) => JSON.stringify(e)).join("\n")
70
+ this.fs.writeFileSync(this.pathFor(config.name), lines ? `${lines}\n` : "")
71
+ }
72
+
73
+ remove(name: string): void {
74
+ if (!this.has(name)) throw new Error(`connector "${name}" not found`)
75
+
76
+ this.fs.unlink(this.pathFor(name))
77
+ this.fs.unlink(this.statePathFor(name))
78
+ }
79
+
80
+ rename(oldName: string, newName: string): void {
81
+ if (!this.has(oldName)) throw new Error(`connector "${oldName}" not found`)
82
+ if (this.has(newName)) throw new Error(`connector "${newName}" already exists`)
83
+
84
+ const content = this.fs.readFileSync(this.pathFor(oldName))
85
+ this.fs.writeFileSync(this.pathFor(newName), content)
86
+ this.fs.unlink(this.pathFor(oldName))
87
+
88
+ if (this.fs.existsSync(this.statePathFor(oldName))) {
89
+ const state = this.fs.readFileSync(this.statePathFor(oldName))
90
+ this.fs.writeFileSync(this.statePathFor(newName), state)
91
+ this.fs.unlink(this.statePathFor(oldName))
92
+ }
93
+ }
94
+
95
+ addEntry(name: string, entry: Omit<ScheduleEntry, "id"> & { id?: string }): ScheduleEntry {
96
+ if (!this.has(name)) throw new Error(`connector "${name}" not found`)
97
+
98
+ const full: ScheduleEntry = {
99
+ id: entry.id ?? crypto.randomUUID(),
100
+ cron: entry.cron,
101
+ prompt: entry.prompt,
102
+ enabled: entry.enabled ?? true,
103
+ }
104
+
105
+ this.fs.appendFileSync(this.pathFor(name), `${JSON.stringify(full)}\n`)
106
+
107
+ return full
108
+ }
109
+
110
+ removeEntry(name: string, id: string): void {
111
+ const entries = this.readEntries(name)
112
+ const next = entries.filter((e) => e.id !== id)
113
+
114
+ if (next.length === entries.length) throw new Error(`schedule entry "${id}" not found`)
115
+
116
+ const content = next.map((e) => JSON.stringify(e)).join("\n")
117
+ this.fs.writeFileSync(this.pathFor(name), content ? `${content}\n` : "")
118
+ }
119
+
120
+ createListener(config: ScheduleConnectorConfig): FunnelConnectorListener {
121
+ return new FunnelScheduleListener({
122
+ config,
123
+ store: this,
124
+ lastFiredStore: this.createLastFiredStore(config.name),
125
+ })
126
+ }
127
+
128
+ private createLastFiredStore(name: string): ScheduleLastFiredStore {
129
+ return new ScheduleLastFiredStore({ connector: name, fs: this.fs, dir: this.baseDir })
130
+ }
131
+
132
+ private pathFor(name: string): string {
133
+ return join(this.dir, `${name}.jsonl`)
134
+ }
135
+
136
+ private statePathFor(name: string): string {
137
+ return join(this.dir, `${name}.state.json`)
138
+ }
139
+
140
+ private readEntries(name: string): ScheduleEntry[] {
141
+ const path = this.pathFor(name)
142
+
143
+ if (!this.fs.existsSync(path)) return []
144
+
145
+ const content = this.fs.readFileSync(path)
146
+ const lines = content.split("\n").filter((l) => l.trim().length > 0)
147
+ const entries: ScheduleEntry[] = []
148
+
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const line = lines[i]!
151
+ const lineNumber = i + 1
152
+
153
+ try {
154
+ const parsed = JSON.parse(line)
155
+ const result = scheduleEntrySchema.safeParse(parsed)
156
+
157
+ if (!result.success) {
158
+ logger.warn("skipping invalid schedule entry", {
159
+ connector: name,
160
+ line: lineNumber,
161
+ issues: result.error.issues.map((iss) => `${iss.path.join(".")}: ${iss.message}`),
162
+ })
163
+ continue
164
+ }
165
+
166
+ entries.push(result.data)
167
+ } catch (error) {
168
+ logger.warn("skipping unparseable schedule entry", {
169
+ connector: name,
170
+ line: lineNumber,
171
+ error: error instanceof Error ? error.message : String(error),
172
+ })
173
+ }
174
+ }
175
+
176
+ return entries
177
+ }
178
+ }
@@ -3,7 +3,7 @@ import {
3
3
  FunnelConnectorAdapter,
4
4
  type CallInput,
5
5
  } from "@/modules/connectors/funnel-connector-adapter"
6
- import type { SlackConnectorConfig } from "@/modules/settings/settings-schema"
6
+ import type { SlackConnectorConfig } from "@/modules/connectors/slack-connector-schema"
7
7
 
8
8
  export type SlackWebClientLike = {
9
9
  apiCall: (method: string, options: Record<string, unknown>) => Promise<unknown>
@@ -5,7 +5,7 @@ import {
5
5
  } from "@/modules/connectors/funnel-connector-listener"
6
6
  import { FunnelSlackEventProcessor } from "@/modules/connectors/funnel-slack-event-processor"
7
7
  import { logger } from "@/modules/logger"
8
- import type { SlackConnectorConfig } from "@/modules/settings/settings-schema"
8
+ import type { SlackConnectorConfig } from "@/modules/connectors/slack-connector-schema"
9
9
 
10
10
  type Deps = {
11
11
  config: SlackConnectorConfig
@@ -0,0 +1,86 @@
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 { FunnelSlackAdapter } from "@/modules/connectors/funnel-slack-adapter"
9
+ import { FunnelSlackListener } from "@/modules/connectors/funnel-slack-listener"
10
+ import {
11
+ type SlackConnectorConfig,
12
+ slackConnectorSchema,
13
+ } from "@/modules/connectors/slack-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 SlackUpdateFields = {
22
+ botToken?: string
23
+ appToken?: string
24
+ }
25
+
26
+ export class FunnelSlackStore extends FunnelCallableConnectorStore<SlackConnectorConfig> {
27
+ readonly type = "slack" as const
28
+ private readonly store: FunnelJsonConnectorStore<SlackConnectorConfig>
29
+
30
+ constructor(deps: Deps = {}) {
31
+ super()
32
+ this.store = new FunnelJsonConnectorStore<SlackConnectorConfig>({
33
+ type: "slack",
34
+ schema: slackConnectorSchema,
35
+ fs: deps.fs,
36
+ dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
37
+ })
38
+ Object.freeze(this)
39
+ }
40
+
41
+ list(): SlackConnectorConfig[] {
42
+ return this.store.list()
43
+ }
44
+
45
+ get(name: string): SlackConnectorConfig | null {
46
+ return this.store.get(name)
47
+ }
48
+
49
+ has(name: string): boolean {
50
+ return this.store.has(name)
51
+ }
52
+
53
+ add(config: SlackConnectorConfig): void {
54
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
55
+
56
+ this.store.write(config)
57
+ }
58
+
59
+ update(name: string, fields: SlackUpdateFields): void {
60
+ const current = this.store.get(name)
61
+
62
+ if (!current) throw new Error(`connector "${name}" not found`)
63
+
64
+ this.store.write({
65
+ ...current,
66
+ botToken: fields.botToken ?? current.botToken,
67
+ appToken: fields.appToken ?? current.appToken,
68
+ })
69
+ }
70
+
71
+ remove(name: string): void {
72
+ this.store.remove(name)
73
+ }
74
+
75
+ rename(oldName: string, newName: string): void {
76
+ this.store.rename(oldName, newName)
77
+ }
78
+
79
+ createListener(config: SlackConnectorConfig): FunnelConnectorListener {
80
+ return new FunnelSlackListener({ config })
81
+ }
82
+
83
+ createAdapter(config: SlackConnectorConfig): FunnelConnectorAdapter {
84
+ return new FunnelSlackAdapter({ config })
85
+ }
86
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from "zod"
2
+
3
+ export const ghConnectorSchema = z.object({
4
+ type: z.literal("gh"),
5
+ name: z.string(),
6
+ pollInterval: z.number().int().positive().optional(),
7
+ })
8
+
9
+ export type GhConnectorConfig = z.infer<typeof ghConnectorSchema>