@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.
- package/README.md +82 -26
- package/lib/funnel.ts +49 -5
- package/lib/index.ts +8 -2
- package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
- package/lib/modules/channels/funnel-channels.ts +50 -8
- package/lib/modules/claude/funnel-claude.ts +79 -1
- package/lib/modules/connectors/connector-config-schema.ts +16 -0
- package/lib/modules/connectors/connector-existence-checker.ts +3 -0
- package/lib/modules/connectors/discord-connector-schema.ts +9 -0
- package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
- package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
- package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
- package/lib/modules/connectors/funnel-connectors.ts +98 -77
- package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
- package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
- package/lib/modules/connectors/funnel-discord-store.ts +84 -0
- package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
- package/lib/modules/connectors/funnel-gh-store.ts +84 -0
- package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
- package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
- package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
- package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
- package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
- package/lib/modules/connectors/funnel-slack-store.ts +86 -0
- package/lib/modules/connectors/gh-connector-schema.ts +9 -0
- package/lib/modules/connectors/match-cron.ts +72 -0
- package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
- package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
- package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
- package/lib/modules/connectors/slack-connector-schema.ts +10 -0
- package/lib/modules/gateway/daemon.ts +30 -13
- package/lib/modules/mcp/channel-server.ts +1 -2
- package/lib/modules/profiles/funnel-profiles.ts +123 -0
- package/lib/modules/profiles/profile-channel-checker.ts +3 -0
- package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
- package/lib/modules/repos/funnel-repositories.ts +4 -4
- package/lib/modules/router/to-request.ts +2 -5
- package/lib/modules/schedule/funnel-schedule.ts +34 -0
- package/lib/modules/settings/funnel-settings-store.ts +1 -2
- package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -2
- package/lib/modules/settings/settings-schema.ts +3 -37
- package/lib/routes/claude/claude.help.ts +9 -4
- package/lib/routes/claude/claude.ts +44 -7
- package/lib/routes/connectors/add.help.ts +10 -4
- package/lib/routes/connectors/add.ts +10 -1
- package/lib/routes/connectors/routes.ts +6 -2
- package/lib/routes/connectors/schedules-add.help.ts +11 -0
- package/lib/routes/connectors/schedules-add.ts +33 -0
- package/lib/routes/connectors/schedules-group.help.ts +1 -0
- package/lib/routes/connectors/schedules-group.ts +38 -0
- package/lib/routes/connectors/schedules-remove.help.ts +3 -0
- package/lib/routes/connectors/schedules-remove.ts +17 -0
- package/lib/routes/connectors/set.ts +47 -5
- package/lib/routes/connectors/show.ts +9 -0
- package/lib/routes/profiles/add.help.ts +3 -0
- package/lib/routes/{agents → profiles}/add.ts +4 -4
- package/lib/routes/profiles/group.help.ts +16 -0
- package/lib/routes/profiles/group.ts +25 -0
- package/lib/routes/profiles/launch.help.ts +4 -0
- package/lib/routes/{agents → profiles}/launch.ts +9 -8
- package/lib/routes/profiles/remove.help.ts +3 -0
- package/lib/routes/{agents → profiles}/remove.ts +4 -4
- package/lib/routes/profiles/rename.help.ts +5 -0
- package/lib/routes/{agents → profiles}/rename.ts +4 -4
- package/lib/routes/profiles/routes.ts +18 -0
- package/lib/routes/profiles/set.help.ts +5 -0
- package/lib/routes/{agents → profiles}/set.ts +4 -4
- package/lib/routes/repos/add.help.ts +3 -2
- package/lib/routes/repos/add.ts +3 -2
- package/lib/routes/repos/group.help.ts +1 -1
- package/lib/routes/request/discord-help.ts +9 -0
- package/lib/routes/request/discord.help.ts +19 -0
- package/lib/routes/request/discord.ts +65 -0
- package/lib/routes/request/group.help.ts +15 -0
- package/lib/routes/request/group.ts +9 -0
- package/lib/routes/request/routes.ts +14 -0
- package/lib/routes/request/slack-help.ts +9 -0
- package/lib/routes/request/slack.help.ts +19 -0
- package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
- package/lib/routes/status/status.help.ts +1 -1
- package/lib/routes/status/status.ts +7 -7
- package/lib/routes/update/routes.ts +4 -0
- package/lib/routes/update/update.help.ts +5 -0
- package/lib/routes/update/update.ts +21 -0
- package/lib/routes.ts +6 -2
- package/package.json +1 -1
- package/lib/modules/agents/funnel-agents.ts +0 -105
- package/lib/modules/connectors/resolve-listener.ts +0 -13
- package/lib/routes/agents/add.help.ts +0 -3
- package/lib/routes/agents/group.help.ts +0 -13
- package/lib/routes/agents/group.ts +0 -25
- package/lib/routes/agents/launch.help.ts +0 -3
- package/lib/routes/agents/remove.help.ts +0 -3
- package/lib/routes/agents/rename.help.ts +0 -5
- package/lib/routes/agents/routes.ts +0 -17
- package/lib/routes/agents/set.help.ts +0 -5
- 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 {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
64
|
+
add(config: ConnectorConfig): void {
|
|
65
|
+
if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
|
|
40
66
|
|
|
41
|
-
|
|
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.
|
|
71
|
+
return this.schedule.add(config)
|
|
44
72
|
}
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
78
|
+
updateGh(name: string, fields: GhUpdateFields): void {
|
|
79
|
+
this.gh.update(name, fields)
|
|
80
|
+
}
|
|
52
81
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
82
|
+
updateDiscord(name: string, fields: DiscordUpdateFields): void {
|
|
83
|
+
this.discord.update(name, fields)
|
|
84
|
+
}
|
|
56
85
|
|
|
57
|
-
|
|
86
|
+
remove(name: string): void {
|
|
87
|
+
const current = this.get(name)
|
|
58
88
|
|
|
59
|
-
|
|
60
|
-
const index = channel.connectors.indexOf(oldName)
|
|
89
|
+
if (!current) throw new Error(`connector "${name}" not found`)
|
|
61
90
|
|
|
62
|
-
|
|
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.
|
|
96
|
+
this.refUpdater.removeRef(name)
|
|
66
97
|
}
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
const
|
|
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 (!
|
|
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 (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
110
|
+
this.refUpdater.renameRef(oldName, newName)
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
const
|
|
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 (
|
|
116
|
+
if (!config) throw new Error(`slack connector "${name}" not found`)
|
|
93
117
|
|
|
94
|
-
|
|
118
|
+
return await this.slack.createAdapter(config).call(input)
|
|
119
|
+
}
|
|
95
120
|
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
async callGh(name: string, input: CallInput): Promise<unknown> {
|
|
122
|
+
const config = this.gh.get(name)
|
|
98
123
|
|
|
99
|
-
|
|
100
|
-
}
|
|
124
|
+
if (!config) throw new Error(`gh connector "${name}" not found`)
|
|
101
125
|
|
|
102
|
-
this.
|
|
126
|
+
return await this.gh.createAdapter(config).call(input)
|
|
103
127
|
}
|
|
104
128
|
|
|
105
|
-
async
|
|
106
|
-
const
|
|
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
|
|
111
|
-
return await new FunnelSlackAdapter({ config: connector }).call(input)
|
|
112
|
-
}
|
|
132
|
+
if (!config) throw new Error(`discord connector "${name}" not found`)
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
+
}
|