@pikku/redis 0.11.2 → 0.12.1

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.
@@ -0,0 +1,227 @@
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
+ wire: data.wire ? JSON.parse(data.wire) : { type: 'unknown' },
201
+ createdAt: new Date(Number(data.createdAt!)),
202
+ updatedAt: new Date(Number(data.updatedAt!)),
203
+ }
204
+ }
205
+
206
+ private extractStepName(key: string, runId: string): string {
207
+ const prefix = `${this.keyPrefix}:step:${runId}:`
208
+ return key.slice(prefix.length)
209
+ }
210
+
211
+ private async scanKeys(pattern: string): Promise<string[]> {
212
+ const keys: string[] = []
213
+ let cursor = '0'
214
+ do {
215
+ const [newCursor, foundKeys] = await this.redis.scan(
216
+ cursor,
217
+ 'MATCH',
218
+ pattern,
219
+ 'COUNT',
220
+ 100
221
+ )
222
+ cursor = newCursor
223
+ keys.push(...foundKeys)
224
+ } while (cursor !== '0')
225
+ return keys
226
+ }
227
+ }
@@ -2,6 +2,7 @@ import type { SerializedError } from '@pikku/core'
2
2
  import {
3
3
  PikkuWorkflowService,
4
4
  type WorkflowRun,
5
+ type WorkflowRunWire,
5
6
  type StepState,
6
7
  type WorkflowStatus,
7
8
  } from '@pikku/core/workflow'
@@ -204,7 +205,9 @@ export class RedisWorkflowService extends PikkuWorkflowService {
204
205
  async createRun(
205
206
  workflowName: string,
206
207
  input: any,
207
- inline?: boolean
208
+ inline: boolean,
209
+ graphHash: string,
210
+ wire: WorkflowRunWire
208
211
  ): Promise<string> {
209
212
  const id = randomUUID()
210
213
  const now = Date.now()
@@ -223,6 +226,10 @@ export class RedisWorkflowService extends PikkuWorkflowService {
223
226
  JSON.stringify(input),
224
227
  'inline',
225
228
  inline ? 'true' : 'false',
229
+ 'graphHash',
230
+ graphHash,
231
+ 'wire',
232
+ JSON.stringify(wire),
226
233
  'createdAt',
227
234
  now.toString(),
228
235
  'updatedAt',
@@ -248,6 +255,8 @@ export class RedisWorkflowService extends PikkuWorkflowService {
248
255
  output: data.output ? JSON.parse(data.output) : undefined,
249
256
  error: data.error ? JSON.parse(data.error) : undefined,
250
257
  inline: data.inline === 'true' ? true : undefined,
258
+ graphHash: data.graphHash || undefined,
259
+ wire: data.wire ? JSON.parse(data.wire) : { type: 'unknown' },
251
260
  createdAt: new Date(Number(data.createdAt!)),
252
261
  updatedAt: new Date(Number(data.updatedAt!)),
253
262
  }
@@ -708,13 +717,14 @@ export class RedisWorkflowService extends PikkuWorkflowService {
708
717
 
709
718
  async getCompletedGraphState(runId: string): Promise<{
710
719
  completedNodeIds: string[]
720
+ failedNodeIds: string[]
711
721
  branchKeys: Record<string, string>
712
722
  }> {
713
723
  const completedNodeIds: string[] = []
724
+ const failedNodeIds: string[] = []
714
725
  const branchKeys: Record<string, string> = {}
715
726
 
716
- // Scan for step keys with 'node:' prefix
717
- const pattern = `${this.keyPrefix}:step:${runId}:node:*`
727
+ const pattern = `${this.keyPrefix}:step:${runId}:*`
718
728
  let cursor = '0'
719
729
 
720
730
  do {
@@ -727,28 +737,38 @@ export class RedisWorkflowService extends PikkuWorkflowService {
727
737
  )
728
738
  cursor = newCursor
729
739
 
730
- // Check each key for succeeded status and branch_taken
731
740
  for (const key of foundKeys) {
732
- const data = await this.redis.hmget(key, 'status', 'branchTaken')
733
- const [status, branchTaken] = data
741
+ const data = await this.redis.hmget(
742
+ key,
743
+ 'status',
744
+ 'branchTaken',
745
+ 'attemptCount',
746
+ 'retries'
747
+ )
748
+ const [status, branchTaken, attemptCount, retries] = data
749
+
750
+ const parts = key.split(':')
751
+ const stepIndex = parts.lastIndexOf('step')
752
+ if (stepIndex === -1 || stepIndex + 1 >= parts.length) continue
753
+ if (parts[stepIndex + 1] !== runId) continue
754
+ const nodeId = parts.slice(stepIndex + 2).join(':')
734
755
 
735
756
  if (status === 'succeeded') {
736
- // Extract node ID from key: workflows:step:runId:node:nodeId
737
- const parts = key.split(':')
738
- const nodeIndex = parts.indexOf('node')
739
- if (nodeIndex !== -1 && nodeIndex < parts.length - 1) {
740
- const nodeId = parts.slice(nodeIndex + 1).join(':')
741
- completedNodeIds.push(nodeId)
742
-
743
- if (branchTaken) {
744
- branchKeys[nodeId] = branchTaken
745
- }
757
+ completedNodeIds.push(nodeId)
758
+ if (branchTaken) {
759
+ branchKeys[nodeId] = branchTaken
760
+ }
761
+ } else if (status === 'failed') {
762
+ const maxAttempts = (parseInt(retries || '0', 10) || 0) + 1
763
+ const attempts = parseInt(attemptCount || '0', 10) || 0
764
+ if (attempts >= maxAttempts) {
765
+ failedNodeIds.push(nodeId)
746
766
  }
747
767
  }
748
768
  }
749
769
  } while (cursor !== '0')
750
770
 
751
- return { completedNodeIds, branchKeys }
771
+ return { completedNodeIds, failedNodeIds, branchKeys }
752
772
  }
753
773
 
754
774
  async getNodesWithoutSteps(
@@ -760,7 +780,7 @@ export class RedisWorkflowService extends PikkuWorkflowService {
760
780
  const result: string[] = []
761
781
 
762
782
  for (const nodeId of nodeIds) {
763
- const key = this.stepKey(runId, `node:${nodeId}`)
783
+ const key = this.stepKey(runId, nodeId)
764
784
  const exists = await this.redis.exists(key)
765
785
  if (!exists) {
766
786
  result.push(nodeId)
@@ -779,7 +799,7 @@ export class RedisWorkflowService extends PikkuWorkflowService {
779
799
  const results: Record<string, any> = {}
780
800
 
781
801
  for (const nodeId of nodeIds) {
782
- const key = this.stepKey(runId, `node:${nodeId}`)
802
+ const key = this.stepKey(runId, nodeId)
783
803
  const data = await this.redis.hmget(key, 'status', 'result')
784
804
  const [status, result] = data
785
805
 
@@ -842,6 +862,44 @@ export class RedisWorkflowService extends PikkuWorkflowService {
842
862
  return JSON.parse(stateStr)
843
863
  }
844
864
 
865
+ private versionKey(name: string, graphHash: string): string {
866
+ return `${this.keyPrefix}:version:${name}:${graphHash}`
867
+ }
868
+
869
+ async upsertWorkflowVersion(
870
+ name: string,
871
+ graphHash: string,
872
+ graph: any,
873
+ source: string
874
+ ): Promise<void> {
875
+ const key = this.versionKey(name, graphHash)
876
+ const exists = await this.redis.exists(key)
877
+ if (!exists) {
878
+ await this.redis.hmset(
879
+ key,
880
+ 'graph',
881
+ JSON.stringify(graph),
882
+ 'source',
883
+ source,
884
+ 'createdAt',
885
+ Date.now().toString()
886
+ )
887
+ }
888
+ }
889
+
890
+ async getWorkflowVersion(
891
+ name: string,
892
+ graphHash: string
893
+ ): Promise<{ graph: any; source: string } | null> {
894
+ const key = this.versionKey(name, graphHash)
895
+ const data = await this.redis.hgetall(key)
896
+ if (!data.graph) return null
897
+ return {
898
+ graph: JSON.parse(data.graph),
899
+ source: data.source!,
900
+ }
901
+ }
902
+
845
903
  async close(): Promise<void> {
846
904
  if (this.ownsConnection) {
847
905
  await this.redis.quit()