@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.
- package/CHANGELOG.md +52 -0
- package/dist/src/redis-agent-run-service.js.map +1 -1
- package/dist/src/redis-deployment-service.js.map +1 -1
- package/dist/src/redis-secret-service.js +1 -1
- package/dist/src/redis-secret-service.js.map +1 -1
- package/dist/src/redis-workflow-run-service.js.map +1 -1
- package/dist/src/redis-workflow-service.js.map +1 -1
- package/package.json +10 -6
- package/run-tests.sh +59 -24
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/index.ts +0 -10
- package/src/redis-agent-run-service.ts +0 -207
- package/src/redis-channel-store.ts +0 -158
- package/src/redis-deployment-service.ts +0 -171
- package/src/redis-eventhub-store.ts +0 -104
- package/src/redis-secret-service.test.ts +0 -122
- package/src/redis-secret-service.ts +0 -210
- package/src/redis-session-store.ts +0 -71
- package/src/redis-workflow-run-service.ts +0 -231
- package/src/redis-workflow-service.test.ts +0 -115
- package/src/redis-workflow-service.ts +0 -1025
- package/tsconfig.json +0 -17
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import Redis from 'ioredis-mock'
|
|
4
|
-
import { defineServiceTests } from '@pikku/core/testing'
|
|
5
|
-
import { RedisSecretService } from './redis-secret-service.js'
|
|
6
|
-
|
|
7
|
-
describe('RedisSecretService', () => {
|
|
8
|
-
let redis: InstanceType<typeof Redis>
|
|
9
|
-
|
|
10
|
-
before(() => {
|
|
11
|
-
redis = new Redis()
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
after(async () => {
|
|
15
|
-
redis.disconnect()
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
defineServiceTests({
|
|
19
|
-
name: 'Redis',
|
|
20
|
-
services: {
|
|
21
|
-
secretService: async (config) =>
|
|
22
|
-
new RedisSecretService(redis as any, config),
|
|
23
|
-
},
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('custom keyPrefix isolates secrets', async () => {
|
|
27
|
-
const prefixRedis = new Redis()
|
|
28
|
-
const kek = 'test-key-encryption-key-32chars!'
|
|
29
|
-
const s1 = new RedisSecretService(prefixRedis as any, {
|
|
30
|
-
key: kek,
|
|
31
|
-
keyPrefix: 'app1',
|
|
32
|
-
})
|
|
33
|
-
const s2 = new RedisSecretService(prefixRedis as any, {
|
|
34
|
-
key: kek,
|
|
35
|
-
keyPrefix: 'app2',
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
await s1.setSecret('shared-name', { from: 'app1' })
|
|
39
|
-
await s2.setSecret('shared-name', { from: 'app2' })
|
|
40
|
-
|
|
41
|
-
const r1 = await s1.getSecret<{ from: string }>('shared-name')
|
|
42
|
-
const r2 = await s2.getSecret<{ from: string }>('shared-name')
|
|
43
|
-
|
|
44
|
-
assert.deepEqual(r1.reveal(), { from: 'app1' })
|
|
45
|
-
assert.deepEqual(r2.reveal(), { from: 'app2' })
|
|
46
|
-
prefixRedis.disconnect()
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
describe('decryption failures', () => {
|
|
50
|
-
const kek = 'test-key-encryption-key-32chars!'
|
|
51
|
-
const otherKek = 'a-totally-different-kek-32chars!'
|
|
52
|
-
|
|
53
|
-
test('getSecrets throws naming a secret it cannot decrypt', async () => {
|
|
54
|
-
const writer = new RedisSecretService(redis as any, { key: kek })
|
|
55
|
-
await writer.setSecret('undecryptable', { token: 'abc' })
|
|
56
|
-
|
|
57
|
-
const reader = new RedisSecretService(redis as any, { key: otherKek })
|
|
58
|
-
|
|
59
|
-
await assert.rejects(
|
|
60
|
-
() => reader.getSecrets(['undecryptable']),
|
|
61
|
-
(error: Error) => {
|
|
62
|
-
assert.equal(
|
|
63
|
-
error.message,
|
|
64
|
-
'Failed to decrypt secret "undecryptable" (key_version 1): ' +
|
|
65
|
-
'the configured KEK does not match the key it was wrapped under'
|
|
66
|
-
)
|
|
67
|
-
assert.ok(error.cause, 'the underlying crypto failure is preserved')
|
|
68
|
-
return true
|
|
69
|
-
}
|
|
70
|
-
)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
test('getSecrets throws when no KEK is available for a stored key_version', async () => {
|
|
74
|
-
const writer = new RedisSecretService(redis as any, {
|
|
75
|
-
key: kek,
|
|
76
|
-
keyVersion: 7,
|
|
77
|
-
})
|
|
78
|
-
await writer.setSecret('old-version', 'value')
|
|
79
|
-
|
|
80
|
-
const reader = new RedisSecretService(redis as any, {
|
|
81
|
-
key: kek,
|
|
82
|
-
keyVersion: 8,
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
await assert.rejects(
|
|
86
|
-
() => reader.getSecrets(['old-version']),
|
|
87
|
-
(error: Error) => {
|
|
88
|
-
assert.equal(
|
|
89
|
-
error.message,
|
|
90
|
-
'Failed to decrypt secret "old-version" (key_version 7): ' +
|
|
91
|
-
'the configured KEK does not match the key it was wrapped under'
|
|
92
|
-
)
|
|
93
|
-
return true
|
|
94
|
-
}
|
|
95
|
-
)
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
test('getSecrets returns every secret when they all decrypt', async () => {
|
|
99
|
-
const service = new RedisSecretService(redis as any, { key: kek })
|
|
100
|
-
await service.setSecret('batch-a', { v: 1 })
|
|
101
|
-
await service.setSecret('batch-b', 'two')
|
|
102
|
-
|
|
103
|
-
const out = await service.getSecrets<{
|
|
104
|
-
'batch-a': { v: number }
|
|
105
|
-
'batch-b': string
|
|
106
|
-
}>(['batch-a', 'batch-b'])
|
|
107
|
-
|
|
108
|
-
assert.deepEqual(out['batch-a']!.reveal(), { v: 1 })
|
|
109
|
-
assert.equal(out['batch-b']!.reveal(), 'two')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('getSecrets omits keys that were never stored', async () => {
|
|
113
|
-
const service = new RedisSecretService(redis as any, { key: kek })
|
|
114
|
-
await service.setSecret('present', 'here')
|
|
115
|
-
|
|
116
|
-
const out = await service.getSecrets(['present', 'never-stored'])
|
|
117
|
-
|
|
118
|
-
assert.deepEqual(Object.keys(out), ['present'])
|
|
119
|
-
assert.equal(out.present!.reveal(), 'here')
|
|
120
|
-
})
|
|
121
|
-
})
|
|
122
|
-
})
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
deriveKEK,
|
|
3
|
-
envelopeDecrypt,
|
|
4
|
-
envelopeEncrypt,
|
|
5
|
-
envelopeRewrap,
|
|
6
|
-
generateKEKSalt,
|
|
7
|
-
} from '@pikku/core/crypto-utils'
|
|
8
|
-
import { createSecretValue, isSecretValue } from '@pikku/core/classification'
|
|
9
|
-
import type { SecretService, SecretValues } from '@pikku/core/services'
|
|
10
|
-
import type { SecretValue } from '@pikku/core/classification'
|
|
11
|
-
import { Redis, type RedisOptions } from 'ioredis'
|
|
12
|
-
|
|
13
|
-
export interface RedisSecretServiceConfig {
|
|
14
|
-
key: string
|
|
15
|
-
keyVersion?: number
|
|
16
|
-
previousKey?: string
|
|
17
|
-
keyPrefix?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class RedisSecretService implements SecretService {
|
|
21
|
-
private redis: Redis
|
|
22
|
-
private ownsConnection: boolean
|
|
23
|
-
private key: string
|
|
24
|
-
private keyVersion: number
|
|
25
|
-
private previousKey?: string
|
|
26
|
-
private keyPrefix: string
|
|
27
|
-
private kekSalts = new Map<number, string>()
|
|
28
|
-
private keks = new Map<number, CryptoKey>()
|
|
29
|
-
|
|
30
|
-
constructor(
|
|
31
|
-
connectionOrConfig: Redis | RedisOptions | string,
|
|
32
|
-
config: RedisSecretServiceConfig
|
|
33
|
-
) {
|
|
34
|
-
if (
|
|
35
|
-
typeof connectionOrConfig === 'object' &&
|
|
36
|
-
'hgetall' in connectionOrConfig &&
|
|
37
|
-
'hset' in connectionOrConfig
|
|
38
|
-
) {
|
|
39
|
-
this.redis = connectionOrConfig as Redis
|
|
40
|
-
this.ownsConnection = false
|
|
41
|
-
} else if (typeof connectionOrConfig === 'string') {
|
|
42
|
-
this.redis = new Redis(connectionOrConfig)
|
|
43
|
-
this.ownsConnection = true
|
|
44
|
-
} else {
|
|
45
|
-
this.redis = new Redis(connectionOrConfig)
|
|
46
|
-
this.ownsConnection = true
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
this.key = config.key
|
|
50
|
-
this.keyVersion = config.keyVersion ?? 1
|
|
51
|
-
this.previousKey = config.previousKey
|
|
52
|
-
this.keyPrefix = config.keyPrefix ?? 'pikku'
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
private secretKey(key: string): string {
|
|
56
|
-
return `${this.keyPrefix}:secret:${key}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private saltKey(): string {
|
|
60
|
-
return `${this.keyPrefix}:kek-salt`
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private async getKEKSalt(version: number): Promise<string> {
|
|
64
|
-
const cached = this.kekSalts.get(version)
|
|
65
|
-
if (cached) return cached
|
|
66
|
-
|
|
67
|
-
const field = String(version)
|
|
68
|
-
let salt = await this.redis.hget(this.saltKey(), field)
|
|
69
|
-
if (!salt) {
|
|
70
|
-
await this.redis.hsetnx(this.saltKey(), field, generateKEKSalt())
|
|
71
|
-
salt = await this.redis.hget(this.saltKey(), field)
|
|
72
|
-
if (!salt) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`Failed to persist a KEK salt for key_version ${version}`
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
this.kekSalts.set(version, salt)
|
|
80
|
-
return salt
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private async getKEK(version: number): Promise<CryptoKey> {
|
|
84
|
-
const cached = this.keks.get(version)
|
|
85
|
-
if (cached) return cached
|
|
86
|
-
|
|
87
|
-
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
88
|
-
if (!passphrase) {
|
|
89
|
-
throw new Error(`No KEK available for key_version ${version}`)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
93
|
-
this.keks.set(version, kek)
|
|
94
|
-
return kek
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
98
|
-
const data = await this.redis.hgetall(this.secretKey(key))
|
|
99
|
-
if (!data.ciphertext) throw new Error('Requested secret not found')
|
|
100
|
-
|
|
101
|
-
const kek = await this.getKEK(Number(data.key_version))
|
|
102
|
-
return createSecretValue(
|
|
103
|
-
await envelopeDecrypt<T>(kek, data.ciphertext!, data.wrapped_dek!)
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async hasSecret(key: string): Promise<boolean> {
|
|
108
|
-
const exists = await this.redis.exists(this.secretKey(key))
|
|
109
|
-
return exists === 1
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async setSecret(key: string, value: unknown): Promise<void> {
|
|
113
|
-
// Encrypting the wrapper would store its redaction, not the secret —
|
|
114
|
-
// writing a secret back to the vault is exactly what this method is for.
|
|
115
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
116
|
-
await this.getKEK(this.keyVersion),
|
|
117
|
-
isSecretValue(value) ? value.reveal() : value
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
await this.redis.hset(this.secretKey(key), {
|
|
121
|
-
ciphertext,
|
|
122
|
-
wrapped_dek: wrappedDEK,
|
|
123
|
-
key_version: this.keyVersion.toString(),
|
|
124
|
-
})
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async deleteSecret(key: string): Promise<void> {
|
|
128
|
-
await this.redis.del(this.secretKey(key))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
132
|
-
keys: (keyof T & string)[]
|
|
133
|
-
): Promise<Partial<SecretValues<T>>> {
|
|
134
|
-
const rows = await Promise.all(
|
|
135
|
-
keys.map(async (key) => ({
|
|
136
|
-
key,
|
|
137
|
-
data: await this.redis.hgetall(this.secretKey(key)),
|
|
138
|
-
}))
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
const out: Record<string, unknown> = {}
|
|
142
|
-
for (const { key, data } of rows) {
|
|
143
|
-
if (!data.ciphertext) continue
|
|
144
|
-
|
|
145
|
-
const keyVersion = Number(data.key_version)
|
|
146
|
-
try {
|
|
147
|
-
const kek = await this.getKEK(keyVersion)
|
|
148
|
-
out[key] = createSecretValue(
|
|
149
|
-
await envelopeDecrypt(kek, data.ciphertext, data.wrapped_dek!)
|
|
150
|
-
)
|
|
151
|
-
} catch (cause) {
|
|
152
|
-
throw new Error(
|
|
153
|
-
`Failed to decrypt secret "${key}" (key_version ${keyVersion}): ` +
|
|
154
|
-
`the configured KEK does not match the key it was wrapped under`,
|
|
155
|
-
{ cause }
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return out as Partial<SecretValues<T>>
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async rotateKEK(): Promise<number> {
|
|
163
|
-
if (!this.previousKey) {
|
|
164
|
-
throw new Error('No previousKey configured — nothing to rotate from')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
168
|
-
const newKEK = await this.getKEK(this.keyVersion)
|
|
169
|
-
|
|
170
|
-
const pattern = `${this.keyPrefix}:secret:*`
|
|
171
|
-
let cursor = '0'
|
|
172
|
-
let count = 0
|
|
173
|
-
|
|
174
|
-
do {
|
|
175
|
-
const [nextCursor, keys] = await this.redis.scan(
|
|
176
|
-
cursor,
|
|
177
|
-
'MATCH',
|
|
178
|
-
pattern,
|
|
179
|
-
'COUNT',
|
|
180
|
-
100
|
|
181
|
-
)
|
|
182
|
-
cursor = nextCursor
|
|
183
|
-
|
|
184
|
-
for (const redisKey of keys) {
|
|
185
|
-
const data = await this.redis.hgetall(redisKey)
|
|
186
|
-
const version = Number(data.key_version)
|
|
187
|
-
if (version >= this.keyVersion) continue
|
|
188
|
-
|
|
189
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
190
|
-
oldKEK,
|
|
191
|
-
newKEK,
|
|
192
|
-
data.wrapped_dek!
|
|
193
|
-
)
|
|
194
|
-
await this.redis.hset(redisKey, {
|
|
195
|
-
wrapped_dek: newWrappedDEK,
|
|
196
|
-
key_version: this.keyVersion.toString(),
|
|
197
|
-
})
|
|
198
|
-
count++
|
|
199
|
-
}
|
|
200
|
-
} while (cursor !== '0')
|
|
201
|
-
|
|
202
|
-
return count
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async close(): Promise<void> {
|
|
206
|
-
if (this.ownsConnection) {
|
|
207
|
-
await this.redis.quit()
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import type { SessionStore } from '@pikku/core/services'
|
|
2
|
-
import type { CoreUserSession } from '@pikku/core/types'
|
|
3
|
-
import { Redis, type RedisOptions } from 'ioredis'
|
|
4
|
-
|
|
5
|
-
export class RedisSessionStore implements SessionStore {
|
|
6
|
-
private redis: Redis
|
|
7
|
-
private keyPrefix: string
|
|
8
|
-
private ownsConnection: boolean
|
|
9
|
-
private ttlSeconds?: number
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @param connectionOrConfig - ioredis Redis instance or RedisOptions config
|
|
13
|
-
* @param keyPrefix - Redis key prefix (default: 'pikku')
|
|
14
|
-
* @param ttlSeconds - Optional TTL in seconds for session entries
|
|
15
|
-
*/
|
|
16
|
-
constructor(
|
|
17
|
-
connectionOrConfig: Redis | RedisOptions | string,
|
|
18
|
-
keyPrefix = 'pikku',
|
|
19
|
-
ttlSeconds?: number
|
|
20
|
-
) {
|
|
21
|
-
this.keyPrefix = keyPrefix
|
|
22
|
-
this.ttlSeconds = ttlSeconds
|
|
23
|
-
|
|
24
|
-
if (connectionOrConfig instanceof Redis) {
|
|
25
|
-
this.redis = connectionOrConfig
|
|
26
|
-
this.ownsConnection = false
|
|
27
|
-
} else if (typeof connectionOrConfig === 'string') {
|
|
28
|
-
this.redis = new Redis(connectionOrConfig)
|
|
29
|
-
this.ownsConnection = true
|
|
30
|
-
} else {
|
|
31
|
-
this.redis = new Redis(connectionOrConfig)
|
|
32
|
-
this.ownsConnection = true
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
public async init(): Promise<void> {
|
|
37
|
-
await this.redis.ping()
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
private sessionKey(pikkuUserId: string): string {
|
|
41
|
-
return `${this.keyPrefix}:session:${pikkuUserId}`
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async get(pikkuUserId: string): Promise<CoreUserSession | undefined> {
|
|
45
|
-
const data = await this.redis.get(this.sessionKey(pikkuUserId))
|
|
46
|
-
if (!data) {
|
|
47
|
-
return undefined
|
|
48
|
-
}
|
|
49
|
-
return JSON.parse(data) as CoreUserSession
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async set(pikkuUserId: string, session: CoreUserSession): Promise<void> {
|
|
53
|
-
const key = this.sessionKey(pikkuUserId)
|
|
54
|
-
const value = JSON.stringify(session)
|
|
55
|
-
if (this.ttlSeconds) {
|
|
56
|
-
await this.redis.set(key, value, 'EX', this.ttlSeconds)
|
|
57
|
-
} else {
|
|
58
|
-
await this.redis.set(key, value)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async clear(pikkuUserId: string): Promise<void> {
|
|
63
|
-
await this.redis.del(this.sessionKey(pikkuUserId))
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
public async close(): Promise<void> {
|
|
67
|
-
if (this.ownsConnection) {
|
|
68
|
-
await this.redis.quit()
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
WorkflowRun,
|
|
3
|
-
StepState,
|
|
4
|
-
WorkflowStatus,
|
|
5
|
-
WorkflowRunService,
|
|
6
|
-
} from '@pikku/core/workflow'
|
|
7
|
-
import type { Redis } from 'ioredis'
|
|
8
|
-
|
|
9
|
-
export class RedisWorkflowRunService implements WorkflowRunService {
|
|
10
|
-
constructor(
|
|
11
|
-
private redis: Redis,
|
|
12
|
-
private keyPrefix = 'workflows'
|
|
13
|
-
) {}
|
|
14
|
-
|
|
15
|
-
private runKey(runId: string): string {
|
|
16
|
-
return `${this.keyPrefix}:run:${runId}`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
private stepKeyPattern(runId: string): string {
|
|
20
|
-
return `${this.keyPrefix}:step:${runId}:*`
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
private stepHistoryKey(stepId: string): string {
|
|
24
|
-
return `${this.keyPrefix}:step-history:${stepId}`
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
private versionKey(name: string, graphHash: string): string {
|
|
28
|
-
return `${this.keyPrefix}:version:${name}:${graphHash}`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async listRuns(options?: {
|
|
32
|
-
workflowName?: string
|
|
33
|
-
status?: string
|
|
34
|
-
limit?: number
|
|
35
|
-
offset?: number
|
|
36
|
-
}): Promise<WorkflowRun[]> {
|
|
37
|
-
const { workflowName, status, limit = 50, offset = 0 } = options ?? {}
|
|
38
|
-
|
|
39
|
-
const runKeys = await this.scanKeys(`${this.keyPrefix}:run:*`)
|
|
40
|
-
|
|
41
|
-
const runs: WorkflowRun[] = []
|
|
42
|
-
for (const key of runKeys) {
|
|
43
|
-
const data = await this.redis.hgetall(key)
|
|
44
|
-
if (!data.id) continue
|
|
45
|
-
|
|
46
|
-
if (workflowName && data.workflow !== workflowName) continue
|
|
47
|
-
if (status && data.status !== status) continue
|
|
48
|
-
|
|
49
|
-
runs.push(this.mapRunData(data))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
runs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
53
|
-
|
|
54
|
-
return runs.slice(offset, offset + limit)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async getRun(id: string): Promise<WorkflowRun | null> {
|
|
58
|
-
const data = await this.redis.hgetall(this.runKey(id))
|
|
59
|
-
if (!data.id) return null
|
|
60
|
-
return this.mapRunData(data)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async getRunSteps(
|
|
64
|
-
runId: string
|
|
65
|
-
): Promise<
|
|
66
|
-
Array<StepState & { stepName: string; rpcName?: string; data?: any }>
|
|
67
|
-
> {
|
|
68
|
-
const stepKeys = await this.scanKeys(this.stepKeyPattern(runId))
|
|
69
|
-
|
|
70
|
-
const steps: Array<
|
|
71
|
-
StepState & { stepName: string; rpcName?: string; data?: any }
|
|
72
|
-
> = []
|
|
73
|
-
|
|
74
|
-
for (const key of stepKeys) {
|
|
75
|
-
const data = await this.redis.hgetall(key)
|
|
76
|
-
if (!data.stepId) continue
|
|
77
|
-
|
|
78
|
-
steps.push({
|
|
79
|
-
stepId: data.stepId,
|
|
80
|
-
stepName: this.extractStepName(key, runId),
|
|
81
|
-
rpcName: data.rpcName || undefined,
|
|
82
|
-
data: data.data ? JSON.parse(data.data) : undefined,
|
|
83
|
-
status: data.status as any,
|
|
84
|
-
result: data.result ? JSON.parse(data.result) : undefined,
|
|
85
|
-
error: data.error ? JSON.parse(data.error) : undefined,
|
|
86
|
-
attemptCount: Number(data.attemptCount || 1),
|
|
87
|
-
retries: data.retries ? Number(data.retries) : undefined,
|
|
88
|
-
retryDelay: data.retryDelay || undefined,
|
|
89
|
-
createdAt: new Date(Number(data.createdAt)),
|
|
90
|
-
updatedAt: new Date(Number(data.updatedAt)),
|
|
91
|
-
})
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
steps.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
95
|
-
|
|
96
|
-
return steps
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async getRunHistory(
|
|
100
|
-
runId: string
|
|
101
|
-
): Promise<Array<StepState & { stepName: string }>> {
|
|
102
|
-
const stepKeys = await this.scanKeys(this.stepKeyPattern(runId))
|
|
103
|
-
|
|
104
|
-
const allHistoryEntries: Array<StepState & { stepName: string }> = []
|
|
105
|
-
|
|
106
|
-
for (const key of stepKeys) {
|
|
107
|
-
const data = await this.redis.hgetall(key)
|
|
108
|
-
if (!data.stepId) continue
|
|
109
|
-
|
|
110
|
-
const historyEntries = await this.redis.zrange(
|
|
111
|
-
this.stepHistoryKey(data.stepId),
|
|
112
|
-
0,
|
|
113
|
-
'-1'
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
for (const entryStr of historyEntries) {
|
|
117
|
-
const entry = JSON.parse(entryStr)
|
|
118
|
-
allHistoryEntries.push({
|
|
119
|
-
stepId: entry.stepId,
|
|
120
|
-
stepName: entry.stepName,
|
|
121
|
-
status: entry.status,
|
|
122
|
-
result: entry.result ? JSON.parse(entry.result) : undefined,
|
|
123
|
-
error: entry.error ? JSON.parse(entry.error) : undefined,
|
|
124
|
-
attemptCount: entry.attemptCount,
|
|
125
|
-
retries: entry.retries,
|
|
126
|
-
retryDelay: entry.retryDelay,
|
|
127
|
-
createdAt: new Date(entry.createdAt),
|
|
128
|
-
updatedAt: new Date(entry.createdAt),
|
|
129
|
-
runningAt: entry.runningAt ? new Date(entry.runningAt) : undefined,
|
|
130
|
-
scheduledAt: entry.scheduledAt
|
|
131
|
-
? new Date(entry.scheduledAt)
|
|
132
|
-
: undefined,
|
|
133
|
-
succeededAt: entry.succeededAt
|
|
134
|
-
? new Date(entry.succeededAt)
|
|
135
|
-
: undefined,
|
|
136
|
-
failedAt: entry.failedAt ? new Date(entry.failedAt) : undefined,
|
|
137
|
-
})
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return allHistoryEntries.sort(
|
|
142
|
-
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
143
|
-
)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async getDistinctWorkflowNames(): Promise<string[]> {
|
|
147
|
-
const runKeys = await this.scanKeys(`${this.keyPrefix}:run:*`)
|
|
148
|
-
|
|
149
|
-
const names = new Set<string>()
|
|
150
|
-
for (const key of runKeys) {
|
|
151
|
-
const workflow = await this.redis.hget(key, 'workflow')
|
|
152
|
-
if (workflow) names.add(workflow)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return [...names].sort()
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async getWorkflowVersion(
|
|
159
|
-
name: string,
|
|
160
|
-
graphHash: string
|
|
161
|
-
): Promise<{ graph: any; source: string } | null> {
|
|
162
|
-
const data = await this.redis.hgetall(this.versionKey(name, graphHash))
|
|
163
|
-
if (!data.graph) return null
|
|
164
|
-
return {
|
|
165
|
-
graph: JSON.parse(data.graph),
|
|
166
|
-
source: data.source!,
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async deleteRun(id: string): Promise<boolean> {
|
|
171
|
-
const exists = await this.redis.exists(this.runKey(id))
|
|
172
|
-
if (!exists) return false
|
|
173
|
-
|
|
174
|
-
const stepKeys = await this.scanKeys(this.stepKeyPattern(id))
|
|
175
|
-
|
|
176
|
-
const keysToDelete = [this.runKey(id)]
|
|
177
|
-
|
|
178
|
-
for (const stepKey of stepKeys) {
|
|
179
|
-
const stepId = await this.redis.hget(stepKey, 'stepId')
|
|
180
|
-
keysToDelete.push(stepKey)
|
|
181
|
-
if (stepId) {
|
|
182
|
-
keysToDelete.push(this.stepHistoryKey(stepId))
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
await this.redis.del(...keysToDelete)
|
|
187
|
-
return true
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private mapRunData(data: Record<string, string>): WorkflowRun {
|
|
191
|
-
return {
|
|
192
|
-
id: data.id!,
|
|
193
|
-
workflow: data.workflow!,
|
|
194
|
-
status: data.status! as WorkflowStatus,
|
|
195
|
-
input: JSON.parse(data.input!),
|
|
196
|
-
output: data.output ? JSON.parse(data.output) : undefined,
|
|
197
|
-
error: data.error ? JSON.parse(data.error) : undefined,
|
|
198
|
-
inline: data.inline === 'true' ? true : undefined,
|
|
199
|
-
graphHash: data.graphHash || undefined,
|
|
200
|
-
deterministic: data.deterministic === 'true' ? true : undefined,
|
|
201
|
-
plannedSteps: data.plannedSteps
|
|
202
|
-
? JSON.parse(data.plannedSteps)
|
|
203
|
-
: undefined,
|
|
204
|
-
wire: data.wire ? JSON.parse(data.wire) : { type: 'unknown' },
|
|
205
|
-
createdAt: new Date(Number(data.createdAt!)),
|
|
206
|
-
updatedAt: new Date(Number(data.updatedAt!)),
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private extractStepName(key: string, runId: string): string {
|
|
211
|
-
const prefix = `${this.keyPrefix}:step:${runId}:`
|
|
212
|
-
return key.slice(prefix.length)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private async scanKeys(pattern: string): Promise<string[]> {
|
|
216
|
-
const keys: string[] = []
|
|
217
|
-
let cursor = '0'
|
|
218
|
-
do {
|
|
219
|
-
const [newCursor, foundKeys] = await this.redis.scan(
|
|
220
|
-
cursor,
|
|
221
|
-
'MATCH',
|
|
222
|
-
pattern,
|
|
223
|
-
'COUNT',
|
|
224
|
-
100
|
|
225
|
-
)
|
|
226
|
-
cursor = newCursor
|
|
227
|
-
keys.push(...foundKeys)
|
|
228
|
-
} while (cursor !== '0')
|
|
229
|
-
return keys
|
|
230
|
-
}
|
|
231
|
-
}
|