@pikku/redis 0.12.19 → 0.12.21

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.
@@ -1,207 +0,0 @@
1
- import type {
2
- AgentThread,
3
- AgentMessage,
4
- AgentRunRow,
5
- AgentRunService,
6
- } from '@pikku/core/agent'
7
- import { isOwnedByPrincipal } from '@pikku/core/agent'
8
- import type { Redis } from 'ioredis'
9
-
10
- export class RedisAgentRunService implements AgentRunService {
11
- constructor(
12
- private redis: Redis,
13
- private keyPrefix = 'ai'
14
- ) {}
15
-
16
- private threadKey(threadId: string): string {
17
- return `${this.keyPrefix}:thread:${threadId}`
18
- }
19
-
20
- private threadsIndexKey(): string {
21
- return `${this.keyPrefix}:threads`
22
- }
23
-
24
- private messagesKey(threadId: string): string {
25
- return `${this.keyPrefix}:messages:${threadId}`
26
- }
27
-
28
- private runKey(runId: string): string {
29
- return `${this.keyPrefix}:run:${runId}`
30
- }
31
-
32
- private threadRunsKey(threadId: string): string {
33
- return `${this.keyPrefix}:thread-runs:${threadId}`
34
- }
35
-
36
- private agentThreadsKey(agentName: string): string {
37
- return `${this.keyPrefix}:agent-threads:${agentName}`
38
- }
39
-
40
- async listThreads(options?: {
41
- agentName?: string
42
- resourceId?: string
43
- owners?: string[]
44
- limit?: number
45
- offset?: number
46
- }): Promise<AgentThread[]> {
47
- const {
48
- agentName,
49
- resourceId,
50
- owners,
51
- limit = 50,
52
- offset = 0,
53
- } = options ?? {}
54
-
55
- // An owners constraint is an authorization boundary, so an empty list must
56
- // match nothing rather than degrade to "no filter".
57
- if (owners && owners.length === 0) return []
58
-
59
- let threadIds: string[]
60
-
61
- if (agentName) {
62
- const allIds = await this.redis.smembers(this.agentThreadsKey(agentName))
63
- const threadsWithScores: { id: string; score: number }[] = []
64
- for (const id of allIds) {
65
- const score = await this.redis.zscore(this.threadsIndexKey(), id)
66
- if (score) {
67
- threadsWithScores.push({ id, score: Number(score) })
68
- }
69
- }
70
- threadsWithScores.sort((a, b) => b.score - a.score)
71
- threadIds = threadsWithScores.map((t) => t.id)
72
- } else {
73
- threadIds = await this.redis.zrevrange(this.threadsIndexKey(), 0, -1)
74
- }
75
-
76
- // Filter before paging: slicing first would let a page of another owner's
77
- // threads collapse to fewer results than requested (and, for `owners`, makes
78
- // the page size leak how many foreign threads exist).
79
- const matched: AgentThread[] = []
80
- for (const id of threadIds) {
81
- const thread = await this.getThread(id)
82
- if (!thread) continue
83
- if (resourceId && thread.resourceId !== resourceId) continue
84
- if (
85
- owners &&
86
- !owners.some((owner) => isOwnedByPrincipal(thread.resourceId, owner))
87
- ) {
88
- continue
89
- }
90
- matched.push(thread)
91
- }
92
-
93
- return matched.slice(offset, offset + limit)
94
- }
95
-
96
- async getThread(threadId: string): Promise<AgentThread | null> {
97
- const data = await this.redis.hgetall(this.threadKey(threadId))
98
- if (!data.id) return null
99
- return {
100
- id: data.id,
101
- resourceId: data.resourceId!,
102
- title: data.title || undefined,
103
- metadata: data.metadata ? JSON.parse(data.metadata) : undefined,
104
- createdAt: new Date(Number(data.createdAt)),
105
- updatedAt: new Date(Number(data.updatedAt)),
106
- }
107
- }
108
-
109
- async getThreadMessages(threadId: string): Promise<AgentMessage[]> {
110
- const entries = await this.redis.zrange(this.messagesKey(threadId), 0, '-1')
111
- return entries.map((entry) => {
112
- const msg = JSON.parse(entry)
113
- return {
114
- ...msg,
115
- createdAt: new Date(msg.createdAt),
116
- }
117
- })
118
- }
119
-
120
- async getThreadRuns(threadId: string): Promise<AgentRunRow[]> {
121
- const runIds = await this.redis.zrevrange(
122
- this.threadRunsKey(threadId),
123
- 0,
124
- -1
125
- )
126
-
127
- const runs: AgentRunRow[] = []
128
- for (const runId of runIds) {
129
- const data = await this.redis.hgetall(this.runKey(runId))
130
- if (!data.runId) continue
131
- runs.push({
132
- runId: data.runId,
133
- agentName: data.agentName!,
134
- threadId: data.threadId!,
135
- resourceId: data.resourceId!,
136
- status: data.status!,
137
- suspendReason: data.suspendReason || undefined,
138
- missingRpcs: data.missingRpcs
139
- ? JSON.parse(data.missingRpcs)
140
- : undefined,
141
- usageInputTokens: Number(data.usageInputTokens || 0),
142
- usageOutputTokens: Number(data.usageOutputTokens || 0),
143
- usageModel: data.usageModel!,
144
- createdAt: new Date(Number(data.createdAt)),
145
- updatedAt: new Date(Number(data.updatedAt)),
146
- })
147
- }
148
-
149
- return runs
150
- }
151
-
152
- async deleteThread(threadId: string): Promise<boolean> {
153
- const exists = await this.redis.exists(this.threadKey(threadId))
154
- if (!exists) return false
155
-
156
- const runIds = await this.redis.zrange(
157
- this.threadRunsKey(threadId),
158
- 0,
159
- '-1'
160
- )
161
-
162
- const keysToDelete = [
163
- this.threadKey(threadId),
164
- this.messagesKey(threadId),
165
- this.threadRunsKey(threadId),
166
- ]
167
-
168
- for (const runId of runIds) {
169
- const agentName = await this.redis.hget(this.runKey(runId), 'agentName')
170
- keysToDelete.push(this.runKey(runId))
171
- if (agentName) {
172
- await this.redis.srem(this.agentThreadsKey(agentName), threadId)
173
- }
174
- }
175
-
176
- await this.redis.zrem(this.threadsIndexKey(), threadId)
177
-
178
- if (keysToDelete.length > 0) {
179
- await this.redis.del(...keysToDelete)
180
- }
181
-
182
- return true
183
- }
184
-
185
- async getDistinctAgentNames(): Promise<string[]> {
186
- const keys = await this.scanKeys(`${this.keyPrefix}:agent-threads:*`)
187
- const prefix = `${this.keyPrefix}:agent-threads:`
188
- return keys.map((k) => k.slice(prefix.length)).sort()
189
- }
190
-
191
- private async scanKeys(pattern: string): Promise<string[]> {
192
- const keys: string[] = []
193
- let cursor = '0'
194
- do {
195
- const [newCursor, foundKeys] = await this.redis.scan(
196
- cursor,
197
- 'MATCH',
198
- pattern,
199
- 'COUNT',
200
- 100
201
- )
202
- cursor = newCursor
203
- keys.push(...foundKeys)
204
- } while (cursor !== '0')
205
- return keys
206
- }
207
- }
@@ -1,158 +0,0 @@
1
- import { ChannelStore } from '@pikku/core/channel'
2
- import type { Channel } from '@pikku/core/channel'
3
- import { Redis, type RedisOptions } from 'ioredis'
4
-
5
- /**
6
- * Redis-based implementation of ChannelStore
7
- *
8
- * Stores channel state in Redis with configurable key prefix.
9
- *
10
- * @example
11
- * ```typescript
12
- * const redis = new Redis({ host: 'localhost', port: 6379 })
13
- * const store = new RedisChannelStore(redis, 'myapp')
14
- * ```
15
- */
16
- export class RedisChannelStore extends ChannelStore {
17
- private redis: Redis
18
- private keyPrefix: string
19
- private ownsConnection: boolean
20
-
21
- /**
22
- * @param connectionOrConfig - ioredis Redis instance or RedisOptions config
23
- * @param keyPrefix - Redis key prefix (default: 'pikku')
24
- */
25
- constructor(
26
- connectionOrConfig: Redis | RedisOptions | string,
27
- keyPrefix = 'pikku'
28
- ) {
29
- super()
30
- this.keyPrefix = keyPrefix
31
-
32
- // Check if it's a Redis instance or config options
33
- if (connectionOrConfig instanceof Redis) {
34
- this.redis = connectionOrConfig
35
- this.ownsConnection = false
36
- } else if (typeof connectionOrConfig === 'string') {
37
- // It's a connection string
38
- this.redis = new Redis(connectionOrConfig)
39
- this.ownsConnection = true
40
- } else {
41
- // It's a config object
42
- this.redis = new Redis(connectionOrConfig)
43
- this.ownsConnection = true
44
- }
45
- }
46
-
47
- /**
48
- * Initialize the store (no-op for Redis, always ready)
49
- */
50
- public async init(): Promise<void> {
51
- // Redis doesn't require schema initialization
52
- // Just verify connection works
53
- await this.redis.ping()
54
- }
55
-
56
- private channelKey(channelId: string): string {
57
- return `${this.keyPrefix}:channel:${channelId}`
58
- }
59
-
60
- public async addChannel({
61
- channelId,
62
- channelName,
63
- openingData,
64
- }: Channel): Promise<void> {
65
- const key = this.channelKey(channelId)
66
-
67
- await this.redis.hmset(
68
- key,
69
- 'channelId',
70
- channelId,
71
- 'channelName',
72
- channelName,
73
- 'openingData',
74
- JSON.stringify(openingData || {}),
75
- 'createdAt',
76
- Date.now().toString()
77
- )
78
- }
79
-
80
- public async removeChannels(channelIds: string[]): Promise<void> {
81
- if (channelIds.length === 0) {
82
- return
83
- }
84
-
85
- const pipeline = this.redis.pipeline()
86
-
87
- for (const channelId of channelIds) {
88
- const key = this.channelKey(channelId)
89
- pipeline.del(key)
90
- // Also remove from subscriptions
91
- pipeline.del(`${this.keyPrefix}:subs:${channelId}`)
92
- }
93
-
94
- await pipeline.exec()
95
- }
96
-
97
- public async setPikkuUserId(
98
- channelId: string,
99
- pikkuUserId: string | null
100
- ): Promise<void> {
101
- const key = this.channelKey(channelId)
102
-
103
- if (pikkuUserId) {
104
- await this.redis.hset(key, 'pikkuUserId', pikkuUserId)
105
- } else {
106
- await this.redis.hdel(key, 'pikkuUserId')
107
- }
108
- }
109
-
110
- public async getChannel(
111
- channelId: string
112
- ): Promise<Channel & { pikkuUserId?: string }> {
113
- const key = this.channelKey(channelId)
114
- const data = await this.redis.hgetall(key)
115
-
116
- if (!data.channelId) {
117
- throw new Error(`Channel not found: ${channelId}`)
118
- }
119
-
120
- return {
121
- channelId: data.channelId!,
122
- channelName: data.channelName!,
123
- openingData: data.openingData ? JSON.parse(data.openingData) : {},
124
- pikkuUserId: data.pikkuUserId || undefined,
125
- }
126
- }
127
-
128
- public async setState(channelId: string, state: unknown): Promise<void> {
129
- const key = this.channelKey(channelId)
130
- await this.redis.hset(key, 'state', JSON.stringify(state ?? null))
131
- }
132
-
133
- public async getState(channelId: string): Promise<unknown | undefined> {
134
- const key = this.channelKey(channelId)
135
- const raw = await this.redis.hget(key, 'state')
136
- if (!raw) return undefined
137
- try {
138
- const parsed = JSON.parse(raw)
139
- return parsed ?? undefined
140
- } catch {
141
- return undefined
142
- }
143
- }
144
-
145
- public async clearState(channelId: string): Promise<void> {
146
- const key = this.channelKey(channelId)
147
- await this.redis.hdel(key, 'state')
148
- }
149
-
150
- /**
151
- * Close the Redis connection if owned by this store
152
- */
153
- public async close(): Promise<void> {
154
- if (this.ownsConnection) {
155
- await this.redis.quit()
156
- }
157
- }
158
- }
@@ -1,171 +0,0 @@
1
- import { buildRemoteHeaders } from '@pikku/core/remote'
2
- import type {
3
- DeploymentService,
4
- DeploymentServiceConfig,
5
- DeploymentConfig,
6
- } from '@pikku/core/services'
7
- import type { JWTService } from '@pikku/core/services'
8
- import type { SecretService } from '@pikku/core/services'
9
- import { getAllFunctionNames } from '@pikku/core/function'
10
- import { Redis, type RedisOptions } from 'ioredis'
11
-
12
- export class RedisDeploymentService implements DeploymentService {
13
- private redis: Redis
14
- private keyPrefix: string
15
- private ownsConnection: boolean
16
- private heartbeatTimer?: ReturnType<typeof setInterval>
17
- private deploymentConfig?: DeploymentConfig
18
- private heartbeatInterval: number
19
- private heartbeatTtl: number
20
-
21
- constructor(
22
- config: DeploymentServiceConfig,
23
- connectionOrConfig: Redis | RedisOptions | string,
24
- keyPrefix = 'pikku',
25
- private jwt?: JWTService,
26
- private secrets?: SecretService
27
- ) {
28
- this.heartbeatInterval = config.heartbeatInterval ?? 10000
29
- this.heartbeatTtl = config.heartbeatTtl ?? 30000
30
- this.keyPrefix = keyPrefix
31
-
32
- if (connectionOrConfig instanceof Redis) {
33
- this.redis = connectionOrConfig
34
- this.ownsConnection = false
35
- } else if (typeof connectionOrConfig === 'string') {
36
- this.redis = new Redis(connectionOrConfig)
37
- this.ownsConnection = true
38
- } else {
39
- this.redis = new Redis(connectionOrConfig)
40
- this.ownsConnection = true
41
- }
42
- }
43
-
44
- private deploymentKey(deploymentId: string): string {
45
- return `${this.keyPrefix}:deployment:${deploymentId}`
46
- }
47
-
48
- private functionsIndexKey(funcName: string): string {
49
- return `${this.keyPrefix}:fn:${funcName}`
50
- }
51
-
52
- public async init(): Promise<void> {}
53
-
54
- async start(config: DeploymentConfig): Promise<void> {
55
- const functions = config.functions ?? getAllFunctionNames()
56
- this.deploymentConfig = { ...config, functions }
57
- const ttlSeconds = Math.ceil(this.heartbeatTtl / 1000)
58
- const expiryScore = Date.now() + this.heartbeatTtl
59
-
60
- const pipeline = this.redis.pipeline()
61
-
62
- pipeline.hset(this.deploymentKey(config.deploymentId), {
63
- endpoint: config.endpoint,
64
- functions: JSON.stringify(functions),
65
- })
66
- pipeline.expire(this.deploymentKey(config.deploymentId), ttlSeconds)
67
-
68
- for (const fn of functions) {
69
- pipeline.zadd(
70
- this.functionsIndexKey(fn),
71
- expiryScore,
72
- config.deploymentId
73
- )
74
- }
75
-
76
- await pipeline.exec()
77
-
78
- this.heartbeatTimer = setInterval(
79
- () => this.sendHeartbeat(),
80
- this.heartbeatInterval
81
- )
82
- }
83
-
84
- async stop(): Promise<void> {
85
- if (this.heartbeatTimer) {
86
- clearInterval(this.heartbeatTimer)
87
- this.heartbeatTimer = undefined
88
- }
89
-
90
- if (this.deploymentConfig) {
91
- const { deploymentId, functions } = this.deploymentConfig
92
- const pipeline = this.redis.pipeline()
93
-
94
- pipeline.del(this.deploymentKey(deploymentId))
95
- for (const fn of functions!) {
96
- pipeline.zrem(this.functionsIndexKey(fn), deploymentId)
97
- }
98
-
99
- await pipeline.exec()
100
- }
101
-
102
- if (this.ownsConnection) {
103
- await this.redis.quit()
104
- }
105
- }
106
-
107
- async invoke(
108
- funcName: string,
109
- data: unknown,
110
- session?: unknown,
111
- traceId?: string
112
- ): Promise<unknown> {
113
- const headers = await buildRemoteHeaders(
114
- this.jwt,
115
- this.secrets,
116
- funcName,
117
- session,
118
- traceId
119
- )
120
- const indexKey = this.functionsIndexKey(funcName)
121
- const now = Date.now()
122
-
123
- await this.redis.zremrangebyscore(indexKey, '-inf', now)
124
- const deploymentIds = await this.redis.zrangebyscore(indexKey, now, '+inf')
125
-
126
- let endpoint: string | null = null
127
- for (const deploymentId of deploymentIds) {
128
- endpoint = await this.redis.hget(
129
- this.deploymentKey(deploymentId),
130
- 'endpoint'
131
- )
132
- if (endpoint) break
133
- }
134
-
135
- if (!endpoint) {
136
- throw new Error(`No deployment found for function '${funcName}'`)
137
- }
138
-
139
- const url = `${endpoint}/remote/rpc/${encodeURIComponent(funcName)}`
140
- const response = await fetch(url, {
141
- method: 'POST',
142
- headers,
143
- body: JSON.stringify({ data }),
144
- })
145
- if (!response.ok) {
146
- throw new Error(
147
- `Remote RPC call to '${funcName}' failed: ${response.status}`
148
- )
149
- }
150
- return response.json()
151
- }
152
-
153
- private async sendHeartbeat(): Promise<void> {
154
- if (!this.deploymentConfig) return
155
-
156
- const { deploymentId, functions } = this.deploymentConfig
157
- const ttlSeconds = Math.ceil(this.heartbeatTtl / 1000)
158
- const expiryScore = Date.now() + this.heartbeatTtl
159
-
160
- try {
161
- const pipeline = this.redis.pipeline()
162
- pipeline.expire(this.deploymentKey(deploymentId), ttlSeconds)
163
- for (const fn of functions!) {
164
- pipeline.zadd(this.functionsIndexKey(fn), expiryScore, deploymentId)
165
- }
166
- await pipeline.exec()
167
- } catch {
168
- // Heartbeat failed, will retry on next interval
169
- }
170
- }
171
- }
@@ -1,104 +0,0 @@
1
- import type { EventHubStore } from '@pikku/core/channel'
2
- import { Redis, type RedisOptions } from 'ioredis'
3
-
4
- /**
5
- * Redis-based implementation of EventHubStore
6
- *
7
- * Manages pub/sub topic subscriptions for channels in Redis.
8
- *
9
- * @example
10
- * ```typescript
11
- * const redis = new Redis({ host: 'localhost', port: 6379 })
12
- * const store = new RedisEventHubStore(redis, 'myapp')
13
- * await store.subscribe('user.123', channelId)
14
- * ```
15
- */
16
- export class RedisEventHubStore implements EventHubStore {
17
- private redis: Redis
18
- private keyPrefix: string
19
- private ownsConnection: boolean
20
-
21
- /**
22
- * @param connectionOrConfig - ioredis Redis instance or RedisOptions config
23
- * @param keyPrefix - Redis key prefix (default: 'pikku')
24
- */
25
- constructor(
26
- connectionOrConfig: Redis | RedisOptions | string,
27
- keyPrefix = 'pikku'
28
- ) {
29
- this.keyPrefix = keyPrefix
30
-
31
- // Check if it's a Redis instance or config options
32
- if (connectionOrConfig instanceof Redis) {
33
- this.redis = connectionOrConfig
34
- this.ownsConnection = false
35
- } else if (typeof connectionOrConfig === 'string') {
36
- // It's a connection string
37
- this.redis = new Redis(connectionOrConfig)
38
- this.ownsConnection = true
39
- } else {
40
- // It's a config object
41
- this.redis = new Redis(connectionOrConfig)
42
- this.ownsConnection = true
43
- }
44
- }
45
-
46
- /**
47
- * Initialize the store (no-op for Redis, always ready)
48
- */
49
- public async init(): Promise<void> {
50
- // Redis doesn't require schema initialization
51
- await this.redis.ping()
52
- }
53
-
54
- private topicKey(topic: string): string {
55
- return `${this.keyPrefix}:topic:${topic}`
56
- }
57
-
58
- private channelSubsKey(channelId: string): string {
59
- return `${this.keyPrefix}:subs:${channelId}`
60
- }
61
-
62
- public async getChannelIdsForTopic(topic: string): Promise<string[]> {
63
- const key = this.topicKey(topic)
64
- return await this.redis.smembers(key)
65
- }
66
-
67
- public async subscribe(topic: string, channelId: string): Promise<boolean> {
68
- const topicKey = this.topicKey(topic)
69
- const subsKey = this.channelSubsKey(channelId)
70
-
71
- // Add channelId to topic set and topic to channel's subscription set
72
- const pipeline = this.redis.pipeline()
73
- pipeline.sadd(topicKey, channelId)
74
- pipeline.sadd(subsKey, topic)
75
-
76
- await pipeline.exec()
77
-
78
- return true
79
- }
80
-
81
- public async unsubscribe(topic: string, channelId: string): Promise<boolean> {
82
- const topicKey = this.topicKey(topic)
83
- const subsKey = this.channelSubsKey(channelId)
84
-
85
- // Remove channelId from topic set and topic from channel's subscription set
86
- const pipeline = this.redis.pipeline()
87
- pipeline.srem(topicKey, channelId)
88
- pipeline.srem(subsKey, topic)
89
-
90
- const results = await pipeline.exec()
91
-
92
- // Check if the channel was actually in the topic set
93
- return (results![0]![1] as number) > 0
94
- }
95
-
96
- /**
97
- * Close the Redis connection if owned by this store
98
- */
99
- public async close(): Promise<void> {
100
- if (this.ownsConnection) {
101
- await this.redis.quit()
102
- }
103
- }
104
- }