@pikku/redis 0.12.12 → 0.12.14

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,6 +1,8 @@
1
1
  import type { SecretService } from '@pikku/core/services'
2
2
  import { Redis, type RedisOptions } from 'ioredis'
3
3
  import {
4
+ deriveKEK,
5
+ generateKEKSalt,
4
6
  envelopeEncrypt,
5
7
  envelopeDecrypt,
6
8
  envelopeRewrap,
@@ -20,6 +22,8 @@ export class RedisSecretService implements SecretService {
20
22
  private keyVersion: number
21
23
  private previousKey?: string
22
24
  private keyPrefix: string
25
+ private kekSalts = new Map<number, string>()
26
+ private keks = new Map<number, CryptoKey>()
23
27
 
24
28
  constructor(
25
29
  connectionOrConfig: Redis | RedisOptions | string,
@@ -50,17 +54,49 @@ export class RedisSecretService implements SecretService {
50
54
  return `${this.keyPrefix}:secret:${key}`
51
55
  }
52
56
 
53
- private getKEK(version: number): string {
54
- if (version === this.keyVersion) return this.key
55
- if (this.previousKey) return this.previousKey
56
- throw new Error(`No KEK available for key_version ${version}`)
57
+ private saltKey(): string {
58
+ return `${this.keyPrefix}:kek-salt`
59
+ }
60
+
61
+ private async getKEKSalt(version: number): Promise<string> {
62
+ const cached = this.kekSalts.get(version)
63
+ if (cached) return cached
64
+
65
+ const field = String(version)
66
+ let salt = await this.redis.hget(this.saltKey(), field)
67
+ if (!salt) {
68
+ await this.redis.hsetnx(this.saltKey(), field, generateKEKSalt())
69
+ salt = await this.redis.hget(this.saltKey(), field)
70
+ if (!salt) {
71
+ throw new Error(
72
+ `Failed to persist a KEK salt for key_version ${version}`
73
+ )
74
+ }
75
+ }
76
+
77
+ this.kekSalts.set(version, salt)
78
+ return salt
79
+ }
80
+
81
+ private async getKEK(version: number): Promise<CryptoKey> {
82
+ const cached = this.keks.get(version)
83
+ if (cached) return cached
84
+
85
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey
86
+ if (!passphrase) {
87
+ throw new Error(`No KEK available for key_version ${version}`)
88
+ }
89
+
90
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
91
+ this.keks.set(version, kek)
92
+ return kek
57
93
  }
58
94
 
59
95
  async getSecret<T = string>(key: string): Promise<T> {
60
96
  const data = await this.redis.hgetall(this.secretKey(key))
61
97
  if (!data.ciphertext) throw new Error('Requested secret not found')
62
98
 
63
- const kek = this.getKEK(Number(data.key_version))
99
+ const kek = await this.getKEK(Number(data.key_version))
64
100
  return envelopeDecrypt<T>(kek, data.ciphertext!, data.wrapped_dek!)
65
101
  }
66
102
 
@@ -70,7 +106,10 @@ export class RedisSecretService implements SecretService {
70
106
  }
71
107
 
72
108
  async setSecret(key: string, value: unknown): Promise<void> {
73
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value)
109
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
110
+ await this.getKEK(this.keyVersion),
111
+ value
112
+ )
74
113
 
75
114
  await this.redis.hset(this.secretKey(key), {
76
115
  ciphertext,
@@ -83,15 +122,32 @@ export class RedisSecretService implements SecretService {
83
122
  await this.redis.del(this.secretKey(key))
84
123
  }
85
124
 
86
- async getSecrets<
87
- T extends Record<string, unknown> = Record<string, unknown>,
88
- >(keys: (keyof T & string)[]): Promise<T> {
89
- const results = await Promise.allSettled(keys.map((k) => this.getSecret(k)))
125
+ async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
126
+ keys: (keyof T & string)[]
127
+ ): Promise<T> {
128
+ const rows = await Promise.all(
129
+ keys.map(async (key) => ({
130
+ key,
131
+ data: await this.redis.hgetall(this.secretKey(key)),
132
+ }))
133
+ )
134
+
90
135
  const out: Record<string, unknown> = {}
91
- keys.forEach((key, i) => {
92
- const result = results[i]
93
- if (result?.status === 'fulfilled') out[key] = result.value
94
- })
136
+ for (const { key, data } of rows) {
137
+ if (!data.ciphertext) continue
138
+
139
+ const keyVersion = Number(data.key_version)
140
+ try {
141
+ const kek = await this.getKEK(keyVersion)
142
+ out[key] = await envelopeDecrypt(kek, data.ciphertext, data.wrapped_dek!)
143
+ } catch (cause) {
144
+ throw new Error(
145
+ `Failed to decrypt secret "${key}" (key_version ${keyVersion}): ` +
146
+ `the configured KEK does not match the key it was wrapped under`,
147
+ { cause }
148
+ )
149
+ }
150
+ }
95
151
  return out as T
96
152
  }
97
153
 
@@ -100,6 +156,9 @@ export class RedisSecretService implements SecretService {
100
156
  throw new Error('No previousKey configured — nothing to rotate from')
101
157
  }
102
158
 
159
+ const oldKEK = await this.getKEK(this.keyVersion - 1)
160
+ const newKEK = await this.getKEK(this.keyVersion)
161
+
103
162
  const pattern = `${this.keyPrefix}:secret:*`
104
163
  let cursor = '0'
105
164
  let count = 0
@@ -120,8 +179,8 @@ export class RedisSecretService implements SecretService {
120
179
  if (version >= this.keyVersion) continue
121
180
 
122
181
  const newWrappedDEK = await envelopeRewrap(
123
- this.previousKey,
124
- this.key,
182
+ oldKEK,
183
+ newKEK,
125
184
  data.wrapped_dek!
126
185
  )
127
186
  await this.redis.hset(redisKey, {
@@ -167,48 +167,6 @@ export class RedisWorkflowRunService implements WorkflowRunService {
167
167
  }
168
168
  }
169
169
 
170
- async getAIGeneratedWorkflows(
171
- agentName?: string
172
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
173
- const pattern = agentName
174
- ? `${this.keyPrefix}:version:ai:${agentName}:*`
175
- : `${this.keyPrefix}:version:ai:*`
176
- const results: Array<{
177
- workflowName: string
178
- graphHash: string
179
- graph: any
180
- }> = []
181
- let cursor = '0'
182
- do {
183
- const [nextCursor, keys] = await this.redis.scan(
184
- cursor,
185
- 'MATCH',
186
- pattern,
187
- 'COUNT',
188
- 100
189
- )
190
- cursor = nextCursor
191
- for (const key of keys) {
192
- const data = await this.redis.hgetall(key)
193
- if (
194
- data.source === 'ai-agent' &&
195
- data.graph &&
196
- data.status === 'active'
197
- ) {
198
- const workflowName = data.workflowName
199
- const graphHash = data.graphHash
200
- if (!workflowName || !graphHash) continue
201
- results.push({
202
- workflowName,
203
- graphHash,
204
- graph: JSON.parse(data.graph),
205
- })
206
- }
207
- }
208
- } while (cursor !== '0')
209
- return results
210
- }
211
-
212
170
  async deleteRun(id: string): Promise<boolean> {
213
171
  const exists = await this.redis.exists(this.runKey(id))
214
172
  if (!exists) return false
@@ -0,0 +1,115 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import Redis from 'ioredis-mock'
4
+
5
+ import { RedisWorkflowService } from './redis-workflow-service.js'
6
+
7
+ const KEY_PREFIX = 'workflows'
8
+
9
+ let ws: RedisWorkflowService
10
+ let redis: any
11
+
12
+ const newRun = () =>
13
+ ws.createRun('flow', {}, false, 'hash', { type: 'test' } as any)
14
+
15
+ beforeEach(() => {
16
+ redis = new Redis()
17
+ ws = new RedisWorkflowService(redis, KEY_PREFIX)
18
+ })
19
+
20
+ afterEach(() => {
21
+ redis.disconnect()
22
+ })
23
+
24
+ describe('workflow run state in redis', () => {
25
+ test('two branches setting different keys both survive', async () => {
26
+ const runId = await newRun()
27
+
28
+ await Promise.all([
29
+ ws.updateRunState(runId, 'left', 'a'),
30
+ ws.updateRunState(runId, 'right', 'b'),
31
+ ])
32
+
33
+ assert.deepEqual(
34
+ await ws.getRunState(runId),
35
+ { left: 'a', right: 'b' },
36
+ 'a parallel branch overwrote the other branch’s state'
37
+ )
38
+ })
39
+
40
+ test('many concurrent writers all land', async () => {
41
+ const runId = await newRun()
42
+ const keys = Array.from({ length: 20 }, (_, i) => `k${i}`)
43
+
44
+ await Promise.all(keys.map((k, i) => ws.updateRunState(runId, k, i)))
45
+
46
+ const state = await ws.getRunState(runId)
47
+ assert.equal(Object.keys(state).length, keys.length)
48
+ keys.forEach((k, i) => assert.equal(state[k], i))
49
+ })
50
+
51
+ test('an empty array stays an array when another key is written', async () => {
52
+ const runId = await newRun()
53
+
54
+ await ws.updateRunState(runId, 'items', [])
55
+ await ws.updateRunState(runId, 'count', 0)
56
+
57
+ const state = await ws.getRunState(runId)
58
+ assert.ok(
59
+ Array.isArray(state.items),
60
+ `an empty array came back as ${JSON.stringify(state.items)}`
61
+ )
62
+ assert.equal(state.count, 0)
63
+ })
64
+
65
+ test('values keep their types, including null and nested objects', async () => {
66
+ const runId = await newRun()
67
+
68
+ await ws.updateRunState(runId, 'nothing', null)
69
+ await ws.updateRunState(runId, 'nested', { a: [1, { b: true }] })
70
+ await ws.updateRunState(runId, 'flag', false)
71
+
72
+ assert.deepEqual(await ws.getRunState(runId), {
73
+ nothing: null,
74
+ nested: { a: [1, { b: true }] },
75
+ flag: false,
76
+ })
77
+ })
78
+
79
+ test('a later write to the same key replaces it', async () => {
80
+ const runId = await newRun()
81
+
82
+ await ws.updateRunState(runId, 'attempt', 1)
83
+ await ws.updateRunState(runId, 'attempt', 2)
84
+
85
+ assert.deepEqual(await ws.getRunState(runId), { attempt: 2 })
86
+ })
87
+
88
+ test('the state of a run that has none is empty', async () => {
89
+ assert.deepEqual(await ws.getRunState(await newRun()), {})
90
+ })
91
+
92
+ test('state written before the per-key layout is still readable', async () => {
93
+ const runId = await newRun()
94
+ const redis = (ws as any).redis
95
+ await redis.hset(
96
+ (ws as any).runKey(runId),
97
+ 'state',
98
+ JSON.stringify({ legacy: 'kept', shared: 'old' })
99
+ )
100
+
101
+ assert.deepEqual(await ws.getRunState(runId), {
102
+ legacy: 'kept',
103
+ shared: 'old',
104
+ })
105
+
106
+ await ws.updateRunState(runId, 'shared', 'new')
107
+ await ws.updateRunState(runId, 'fresh', 1)
108
+
109
+ assert.deepEqual(
110
+ await ws.getRunState(runId),
111
+ { legacy: 'kept', shared: 'new', fresh: 1 },
112
+ 'a run in flight across the deploy lost the state it already had'
113
+ )
114
+ })
115
+ })
@@ -42,9 +42,17 @@ export class RedisWorkflowService extends PikkuWorkflowService {
42
42
  super(options)
43
43
  this.keyPrefix = keyPrefix
44
44
 
45
- // Check if it's a Redis instance or config options
46
- if (connectionOrConfig instanceof Redis) {
47
- this.redis = connectionOrConfig
45
+ // An already-connected client is recognised by the commands it exposes
46
+ // rather than by `instanceof Redis`, so a compatible client that is not an
47
+ // ioredis subclass — a test double, a wrapper — is used as given instead of
48
+ // being silently ignored in favour of a fresh connection to localhost.
49
+ if (
50
+ typeof connectionOrConfig === 'object' &&
51
+ connectionOrConfig !== null &&
52
+ 'hgetall' in connectionOrConfig &&
53
+ 'hset' in connectionOrConfig
54
+ ) {
55
+ this.redis = connectionOrConfig as Redis
48
56
  this.ownsConnection = false
49
57
  } else {
50
58
  this.redis = new Redis(connectionOrConfig as any)
@@ -821,26 +829,9 @@ export class RedisWorkflowService extends PikkuWorkflowService {
821
829
  return { completedNodeIds, failedNodeIds, branchKeys }
822
830
  }
823
831
 
824
- async getNodesWithoutSteps(
825
- runId: string,
826
- nodeIds: string[]
827
- ): Promise<string[]> {
828
- if (nodeIds.length === 0) return []
829
-
830
- const result: string[] = []
831
-
832
- for (const nodeId of nodeIds) {
833
- const key = this.stepKey(runId, nodeId)
834
- const exists = await this.redis.exists(key)
835
- if (!exists) {
836
- result.push(nodeId)
837
- }
838
- }
839
-
840
- return result
841
- }
842
-
843
- async getStepInstances(runId: string): Promise<
832
+ async getStepInstances(
833
+ runId: string
834
+ ): Promise<
844
835
  Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
845
836
  > {
846
837
  const instances: Array<{
@@ -920,37 +911,50 @@ export class RedisWorkflowService extends PikkuWorkflowService {
920
911
  )
921
912
  }
922
913
 
914
+ /**
915
+ * Hash holding one field per state variable of a run.
916
+ *
917
+ * The whole state used to be a single JSON blob on the run hash, which made
918
+ * every write a read-modify-write: two parallel branches each setting their
919
+ * own variable would both read the blob, add their key, and write it back,
920
+ * so whichever wrote second silently dropped the other's variable.
921
+ */
922
+ private runStateKey(runId: string): string {
923
+ return `${this.keyPrefix}:run-state:${runId}`
924
+ }
925
+
923
926
  protected async updateRunStateImpl(
924
927
  runId: string,
925
928
  name: string,
926
929
  value: unknown
927
930
  ): Promise<void> {
928
- const key = this.runKey(runId)
929
- const now = Date.now()
930
-
931
- // Get existing state or create empty object
932
- const existingState = await this.redis.hget(key, 'state')
933
- const state = existingState ? JSON.parse(existingState) : {}
934
-
935
- // Update the specific field
936
- state[name] = value
937
-
938
- await this.redis.hmset(
939
- key,
940
- 'state',
941
- JSON.stringify(state),
931
+ // HSET touches only the field it is named, so concurrent writers to
932
+ // different variables no longer race, and nothing has to parse and
933
+ // re-encode the values it is not writing.
934
+ await this.redis.hset(
935
+ this.runStateKey(runId),
936
+ name,
937
+ JSON.stringify(value) ?? 'null'
938
+ )
939
+ await this.redis.hset(
940
+ this.runKey(runId),
942
941
  'updatedAt',
943
- now.toString()
942
+ Date.now().toString()
944
943
  )
945
944
  }
946
945
 
947
946
  async getRunState(runId: string): Promise<Record<string, unknown>> {
948
- const key = this.runKey(runId)
949
- const stateStr = await this.redis.hget(key, 'state')
950
- if (!stateStr) {
951
- return {}
947
+ // The blob is what a run started before the per-key layout still holds; a
948
+ // field written since wins, so a run in flight across the deploy keeps
949
+ // everything it had.
950
+ const blob = await this.redis.hget(this.runKey(runId), 'state')
951
+ const state: Record<string, unknown> = blob ? JSON.parse(blob) : {}
952
+
953
+ const fields = await this.redis.hgetall(this.runStateKey(runId))
954
+ for (const [name, value] of Object.entries(fields)) {
955
+ state[name] = JSON.parse(value)
952
956
  }
953
- return JSON.parse(stateStr)
957
+ return state
954
958
  }
955
959
 
956
960
  private versionKey(name: string, graphHash: string): string {
@@ -1007,48 +1011,6 @@ export class RedisWorkflowService extends PikkuWorkflowService {
1007
1011
  }
1008
1012
  }
1009
1013
 
1010
- async getAIGeneratedWorkflows(
1011
- agentName?: string
1012
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
1013
- const pattern = agentName
1014
- ? `${this.keyPrefix}:version:ai\\:${agentName}\\:*`
1015
- : `${this.keyPrefix}:version:ai\\:*`
1016
- const results: Array<{
1017
- workflowName: string
1018
- graphHash: string
1019
- graph: any
1020
- }> = []
1021
- let cursor = '0'
1022
- do {
1023
- const [nextCursor, keys] = await this.redis.scan(
1024
- cursor,
1025
- 'MATCH',
1026
- pattern,
1027
- 'COUNT',
1028
- 100
1029
- )
1030
- cursor = nextCursor
1031
- for (const key of keys) {
1032
- const data = await this.redis.hgetall(key)
1033
- if (
1034
- !data.graph ||
1035
- data.source !== 'ai-agent' ||
1036
- data.status !== 'active'
1037
- )
1038
- continue
1039
- const workflowName = data.workflowName
1040
- const graphHash = data.graphHash
1041
- if (!workflowName || !graphHash) continue
1042
- results.push({
1043
- workflowName,
1044
- graphHash,
1045
- graph: JSON.parse(data.graph),
1046
- })
1047
- }
1048
- } while (cursor !== '0')
1049
- return results
1050
- }
1051
-
1052
1014
  async close(): Promise<void> {
1053
1015
  if (this.ownsConnection) {
1054
1016
  await this.redis.quit()