@pikku/redis 0.11.1 → 0.12.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.
@@ -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'
@@ -201,7 +202,13 @@ export class RedisWorkflowService extends PikkuWorkflowService {
201
202
  )
202
203
  }
203
204
 
204
- async createRun(workflowName: string, input: any): Promise<string> {
205
+ async createRun(
206
+ workflowName: string,
207
+ input: any,
208
+ inline: boolean,
209
+ graphHash: string,
210
+ wire: WorkflowRunWire
211
+ ): Promise<string> {
205
212
  const id = randomUUID()
206
213
  const now = Date.now()
207
214
 
@@ -217,6 +224,12 @@ export class RedisWorkflowService extends PikkuWorkflowService {
217
224
  'running',
218
225
  'input',
219
226
  JSON.stringify(input),
227
+ 'inline',
228
+ inline ? 'true' : 'false',
229
+ 'graphHash',
230
+ graphHash,
231
+ 'wire',
232
+ JSON.stringify(wire),
220
233
  'createdAt',
221
234
  now.toString(),
222
235
  'updatedAt',
@@ -241,6 +254,9 @@ export class RedisWorkflowService extends PikkuWorkflowService {
241
254
  input: JSON.parse(data.input!),
242
255
  output: data.output ? JSON.parse(data.output) : undefined,
243
256
  error: data.error ? JSON.parse(data.error) : undefined,
257
+ inline: data.inline === 'true' ? true : undefined,
258
+ graphHash: data.graphHash || undefined,
259
+ wire: data.wire ? JSON.parse(data.wire) : { type: 'unknown' },
244
260
  createdAt: new Date(Number(data.createdAt!)),
245
261
  updatedAt: new Date(Number(data.updatedAt!)),
246
262
  }
@@ -695,6 +711,195 @@ export class RedisWorkflowService extends PikkuWorkflowService {
695
711
  }
696
712
  }
697
713
 
714
+ // ============================================================================
715
+ // Workflow Graph Methods
716
+ // ============================================================================
717
+
718
+ async getCompletedGraphState(runId: string): Promise<{
719
+ completedNodeIds: string[]
720
+ failedNodeIds: string[]
721
+ branchKeys: Record<string, string>
722
+ }> {
723
+ const completedNodeIds: string[] = []
724
+ const failedNodeIds: string[] = []
725
+ const branchKeys: Record<string, string> = {}
726
+
727
+ const pattern = `${this.keyPrefix}:step:${runId}:*`
728
+ let cursor = '0'
729
+
730
+ do {
731
+ const [newCursor, foundKeys] = await this.redis.scan(
732
+ cursor,
733
+ 'MATCH',
734
+ pattern,
735
+ 'COUNT',
736
+ 100
737
+ )
738
+ cursor = newCursor
739
+
740
+ for (const key of foundKeys) {
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(':')
755
+
756
+ if (status === 'succeeded') {
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)
766
+ }
767
+ }
768
+ }
769
+ } while (cursor !== '0')
770
+
771
+ return { completedNodeIds, failedNodeIds, branchKeys }
772
+ }
773
+
774
+ async getNodesWithoutSteps(
775
+ runId: string,
776
+ nodeIds: string[]
777
+ ): Promise<string[]> {
778
+ if (nodeIds.length === 0) return []
779
+
780
+ const result: string[] = []
781
+
782
+ for (const nodeId of nodeIds) {
783
+ const key = this.stepKey(runId, nodeId)
784
+ const exists = await this.redis.exists(key)
785
+ if (!exists) {
786
+ result.push(nodeId)
787
+ }
788
+ }
789
+
790
+ return result
791
+ }
792
+
793
+ async getNodeResults(
794
+ runId: string,
795
+ nodeIds: string[]
796
+ ): Promise<Record<string, any>> {
797
+ if (nodeIds.length === 0) return {}
798
+
799
+ const results: Record<string, any> = {}
800
+
801
+ for (const nodeId of nodeIds) {
802
+ const key = this.stepKey(runId, nodeId)
803
+ const data = await this.redis.hmget(key, 'status', 'result')
804
+ const [status, result] = data
805
+
806
+ if (status === 'succeeded' && result) {
807
+ results[nodeId] = JSON.parse(result)
808
+ }
809
+ }
810
+
811
+ return results
812
+ }
813
+
814
+ async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
815
+ // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
816
+ const parts = stepId.split(':')
817
+ const runId = parts[0]!
818
+ const stepName = parts.slice(1, -1).join(':')
819
+
820
+ const now = Date.now()
821
+ const key = this.stepKey(runId, stepName)
822
+
823
+ await this.redis.hmset(
824
+ key,
825
+ 'branchTaken',
826
+ branchKey,
827
+ 'updatedAt',
828
+ now.toString()
829
+ )
830
+ }
831
+
832
+ async updateRunState(
833
+ runId: string,
834
+ name: string,
835
+ value: unknown
836
+ ): Promise<void> {
837
+ const key = this.runKey(runId)
838
+ const now = Date.now()
839
+
840
+ // Get existing state or create empty object
841
+ const existingState = await this.redis.hget(key, 'state')
842
+ const state = existingState ? JSON.parse(existingState) : {}
843
+
844
+ // Update the specific field
845
+ state[name] = value
846
+
847
+ await this.redis.hmset(
848
+ key,
849
+ 'state',
850
+ JSON.stringify(state),
851
+ 'updatedAt',
852
+ now.toString()
853
+ )
854
+ }
855
+
856
+ async getRunState(runId: string): Promise<Record<string, unknown>> {
857
+ const key = this.runKey(runId)
858
+ const stateStr = await this.redis.hget(key, 'state')
859
+ if (!stateStr) {
860
+ return {}
861
+ }
862
+ return JSON.parse(stateStr)
863
+ }
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
+
698
903
  async close(): Promise<void> {
699
904
  if (this.ownsConnection) {
700
905
  await this.redis.quit()