@pikku/core 0.12.4 → 0.12.6

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.
Files changed (235) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/dev/hot-reload.d.ts +10 -0
  3. package/dist/dev/hot-reload.js +158 -0
  4. package/dist/middleware-runner.d.ts +1 -0
  5. package/dist/middleware-runner.js +5 -0
  6. package/dist/permissions.d.ts +1 -0
  7. package/dist/permissions.js +5 -0
  8. package/dist/services/content-service.d.ts +2 -0
  9. package/dist/services/in-memory-workflow-service.d.ts +1 -0
  10. package/dist/services/in-memory-workflow-service.js +16 -11
  11. package/dist/services/workflow-service.d.ts +1 -0
  12. package/dist/wirings/ai-agent/ai-agent-prepare.js +16 -1
  13. package/dist/wirings/channel/channel-middleware-runner.d.ts +1 -0
  14. package/dist/wirings/channel/channel-middleware-runner.js +5 -0
  15. package/dist/wirings/http/http-runner.js +1 -1
  16. package/dist/wirings/workflow/pikku-workflow-service.d.ts +6 -0
  17. package/dist/wirings/workflow/pikku-workflow-service.js +51 -16
  18. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  19. package/package.json +5 -3
  20. package/src/crypto-utils.test.ts +214 -0
  21. package/src/crypto-utils.ts +213 -0
  22. package/src/dev/hot-reload.test.ts +484 -0
  23. package/src/dev/hot-reload.ts +212 -0
  24. package/src/errors/error-handler.ts +62 -0
  25. package/src/errors/error.test.ts +195 -0
  26. package/src/errors/errors.ts +374 -0
  27. package/src/errors/index.ts +2 -0
  28. package/src/factory-functions.test.ts +109 -0
  29. package/src/function/function-runner.test.ts +536 -0
  30. package/src/function/function-runner.ts +365 -0
  31. package/src/function/functions.types.ts +304 -0
  32. package/src/function/index.ts +5 -0
  33. package/src/handle-error.test.ts +424 -0
  34. package/src/handle-error.ts +69 -0
  35. package/src/index.ts +118 -0
  36. package/src/internal.ts +6 -0
  37. package/src/middleware/auth-apikey.test.ts +363 -0
  38. package/src/middleware/auth-apikey.ts +47 -0
  39. package/src/middleware/auth-bearer.test.ts +450 -0
  40. package/src/middleware/auth-bearer.ts +72 -0
  41. package/src/middleware/auth-cookie.test.ts +528 -0
  42. package/src/middleware/auth-cookie.ts +80 -0
  43. package/src/middleware/cors.test.ts +424 -0
  44. package/src/middleware/cors.ts +104 -0
  45. package/src/middleware/index.ts +5 -0
  46. package/src/middleware/remote-auth.test.ts +488 -0
  47. package/src/middleware/remote-auth.ts +68 -0
  48. package/src/middleware/timeout.ts +15 -0
  49. package/src/middleware-runner.test.ts +418 -0
  50. package/src/middleware-runner.ts +240 -0
  51. package/src/permissions.test.ts +434 -0
  52. package/src/permissions.ts +327 -0
  53. package/src/pikku-request.ts +23 -0
  54. package/src/pikku-response.ts +5 -0
  55. package/src/pikku-state.test.ts +224 -0
  56. package/src/pikku-state.ts +216 -0
  57. package/src/run-tests-script.test.ts +49 -0
  58. package/src/schema.test.ts +249 -0
  59. package/src/schema.ts +151 -0
  60. package/src/services/ai-agent-runner-service.ts +41 -0
  61. package/src/services/ai-run-state-service.ts +20 -0
  62. package/src/services/ai-storage-service.ts +39 -0
  63. package/src/services/content-service.ts +81 -0
  64. package/src/services/deployment-service.ts +22 -0
  65. package/src/services/gateway-service.ts +20 -0
  66. package/src/services/gopass-secrets.ts +98 -0
  67. package/src/services/in-memory-ai-run-state-service.ts +73 -0
  68. package/src/services/in-memory-trigger-service.ts +64 -0
  69. package/src/services/in-memory-workflow-service.test.ts +351 -0
  70. package/src/services/in-memory-workflow-service.ts +512 -0
  71. package/src/services/index.ts +56 -0
  72. package/src/services/jwt-service.ts +30 -0
  73. package/src/services/local-content.ts +120 -0
  74. package/src/services/local-gateway-service.ts +62 -0
  75. package/src/services/local-secrets.test.ts +80 -0
  76. package/src/services/local-secrets.ts +94 -0
  77. package/src/services/local-variables.test.ts +61 -0
  78. package/src/services/local-variables.ts +39 -0
  79. package/src/services/logger-console.test.ts +118 -0
  80. package/src/services/logger-console.ts +98 -0
  81. package/src/services/logger.ts +57 -0
  82. package/src/services/scheduler-service.ts +86 -0
  83. package/src/services/schema-service.ts +33 -0
  84. package/src/services/scoped-secret-service.test.ts +74 -0
  85. package/src/services/scoped-secret-service.ts +41 -0
  86. package/src/services/secret-service.ts +38 -0
  87. package/src/services/trigger-service.ts +17 -0
  88. package/src/services/typed-secret-service.test.ts +93 -0
  89. package/src/services/typed-secret-service.ts +70 -0
  90. package/src/services/typed-variables-service.test.ts +73 -0
  91. package/src/services/typed-variables-service.ts +81 -0
  92. package/src/services/user-session-service.test.ts +113 -0
  93. package/src/services/user-session-service.ts +86 -0
  94. package/src/services/variables-service.ts +11 -0
  95. package/src/services/workflow-service.ts +96 -0
  96. package/src/testing/index.ts +2 -0
  97. package/src/testing/service-tests.ts +873 -0
  98. package/src/time-utils.test.ts +56 -0
  99. package/src/time-utils.ts +107 -0
  100. package/src/types/core.types.ts +478 -0
  101. package/src/types/state.types.ts +188 -0
  102. package/src/utils/hash.test.ts +68 -0
  103. package/src/utils/hash.ts +26 -0
  104. package/src/utils.test.ts +350 -0
  105. package/src/utils.ts +129 -0
  106. package/src/version.test.ts +80 -0
  107. package/src/version.ts +25 -0
  108. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +469 -0
  109. package/src/wirings/ai-agent/ai-agent-helpers.test.ts +152 -0
  110. package/src/wirings/ai-agent/ai-agent-helpers.ts +76 -0
  111. package/src/wirings/ai-agent/ai-agent-memory.ts +310 -0
  112. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +115 -0
  113. package/src/wirings/ai-agent/ai-agent-model-config.ts +43 -0
  114. package/src/wirings/ai-agent/ai-agent-prepare.ts +659 -0
  115. package/src/wirings/ai-agent/ai-agent-registry.test.ts +37 -0
  116. package/src/wirings/ai-agent/ai-agent-registry.ts +82 -0
  117. package/src/wirings/ai-agent/ai-agent-runner.test.ts +319 -0
  118. package/src/wirings/ai-agent/ai-agent-runner.ts +773 -0
  119. package/src/wirings/ai-agent/ai-agent-stream.test.ts +859 -0
  120. package/src/wirings/ai-agent/ai-agent-stream.ts +1153 -0
  121. package/src/wirings/ai-agent/ai-agent.types.ts +382 -0
  122. package/src/wirings/ai-agent/index.ts +37 -0
  123. package/src/wirings/channel/channel-common.ts +90 -0
  124. package/src/wirings/channel/channel-handler.ts +250 -0
  125. package/src/wirings/channel/channel-middleware-runner.ts +126 -0
  126. package/src/wirings/channel/channel-runner.ts +166 -0
  127. package/src/wirings/channel/channel-store.ts +29 -0
  128. package/src/wirings/channel/channel.types.ts +172 -0
  129. package/src/wirings/channel/define-channel-routes.ts +25 -0
  130. package/src/wirings/channel/eventhub-service.ts +34 -0
  131. package/src/wirings/channel/eventhub-store.ts +13 -0
  132. package/src/wirings/channel/index.ts +24 -0
  133. package/src/wirings/channel/local/index.ts +3 -0
  134. package/src/wirings/channel/local/local-channel-handler.ts +81 -0
  135. package/src/wirings/channel/local/local-channel-runner.test.ts +215 -0
  136. package/src/wirings/channel/local/local-channel-runner.ts +210 -0
  137. package/src/wirings/channel/local/local-eventhub-service.test.ts +95 -0
  138. package/src/wirings/channel/local/local-eventhub-service.ts +101 -0
  139. package/src/wirings/channel/log-channels.ts +20 -0
  140. package/src/wirings/channel/pikku-abstract-channel-handler.test.ts +54 -0
  141. package/src/wirings/channel/pikku-abstract-channel-handler.ts +45 -0
  142. package/src/wirings/channel/serverless/index.ts +5 -0
  143. package/src/wirings/channel/serverless/serverless-channel-runner.ts +289 -0
  144. package/src/wirings/cli/channel/cli-channel-runner.ts +136 -0
  145. package/src/wirings/cli/channel/index.ts +1 -0
  146. package/src/wirings/cli/cli-runner.test.ts +403 -0
  147. package/src/wirings/cli/cli-runner.ts +529 -0
  148. package/src/wirings/cli/cli.types.ts +358 -0
  149. package/src/wirings/cli/command-parser.test.ts +445 -0
  150. package/src/wirings/cli/command-parser.ts +504 -0
  151. package/src/wirings/cli/define-cli-commands.ts +24 -0
  152. package/src/wirings/cli/index.ts +19 -0
  153. package/src/wirings/gateway/gateway-runner.test.ts +474 -0
  154. package/src/wirings/gateway/gateway-runner.ts +411 -0
  155. package/src/wirings/gateway/gateway.types.ts +149 -0
  156. package/src/wirings/gateway/index.ts +13 -0
  157. package/src/wirings/http/http-routes.test.ts +322 -0
  158. package/src/wirings/http/http-routes.ts +197 -0
  159. package/src/wirings/http/http-runner.test.ts +144 -0
  160. package/src/wirings/http/http-runner.ts +588 -0
  161. package/src/wirings/http/http.types.ts +369 -0
  162. package/src/wirings/http/index.ts +28 -0
  163. package/src/wirings/http/log-http-routes.ts +22 -0
  164. package/src/wirings/http/pikku-fetch-http-request.test.ts +237 -0
  165. package/src/wirings/http/pikku-fetch-http-request.ts +170 -0
  166. package/src/wirings/http/pikku-fetch-http-response.test.ts +82 -0
  167. package/src/wirings/http/pikku-fetch-http-response.ts +147 -0
  168. package/src/wirings/http/routers/http-router.ts +13 -0
  169. package/src/wirings/http/routers/path-to-regex.test.ts +319 -0
  170. package/src/wirings/http/routers/path-to-regex.ts +126 -0
  171. package/src/wirings/http/web-request.test.ts +236 -0
  172. package/src/wirings/http/web-request.ts +104 -0
  173. package/src/wirings/mcp/index.ts +27 -0
  174. package/src/wirings/mcp/mcp-endpoint-registry.test.ts +389 -0
  175. package/src/wirings/mcp/mcp-endpoint-registry.ts +159 -0
  176. package/src/wirings/mcp/mcp-runner.ts +323 -0
  177. package/src/wirings/mcp/mcp.types.ts +216 -0
  178. package/src/wirings/node/index.ts +2 -0
  179. package/src/wirings/node/node.types.ts +23 -0
  180. package/src/wirings/oauth2/index.ts +3 -0
  181. package/src/wirings/oauth2/oauth2-client.test.ts +929 -0
  182. package/src/wirings/oauth2/oauth2-client.ts +335 -0
  183. package/src/wirings/oauth2/oauth2.types.ts +69 -0
  184. package/src/wirings/oauth2/wire-oauth2-credential.ts +23 -0
  185. package/src/wirings/queue/index.ts +31 -0
  186. package/src/wirings/queue/queue-runner.test.ts +710 -0
  187. package/src/wirings/queue/queue-runner.ts +192 -0
  188. package/src/wirings/queue/queue.types.ts +189 -0
  189. package/src/wirings/queue/register-queue-helper.ts +60 -0
  190. package/src/wirings/queue/validate-worker-config.test.ts +108 -0
  191. package/src/wirings/queue/validate-worker-config.ts +116 -0
  192. package/src/wirings/rpc/index.ts +4 -0
  193. package/src/wirings/rpc/rpc-runner.ts +460 -0
  194. package/src/wirings/rpc/rpc-types.ts +56 -0
  195. package/src/wirings/rpc/wire-addon.ts +21 -0
  196. package/src/wirings/scheduler/index.ts +11 -0
  197. package/src/wirings/scheduler/log-schedulers.ts +20 -0
  198. package/src/wirings/scheduler/scheduler-runner.test.ts +660 -0
  199. package/src/wirings/scheduler/scheduler-runner.ts +133 -0
  200. package/src/wirings/scheduler/scheduler.types.ts +53 -0
  201. package/src/wirings/secret/index.ts +9 -0
  202. package/src/wirings/secret/secret.types.ts +32 -0
  203. package/src/wirings/secret/validate-secret-definitions.test.ts +140 -0
  204. package/src/wirings/secret/validate-secret-definitions.ts +82 -0
  205. package/src/wirings/trigger/index.ts +10 -0
  206. package/src/wirings/trigger/pikku-trigger-service.ts +112 -0
  207. package/src/wirings/trigger/trigger-runner.test.ts +79 -0
  208. package/src/wirings/trigger/trigger-runner.ts +135 -0
  209. package/src/wirings/trigger/trigger.types.ts +178 -0
  210. package/src/wirings/variable/index.ts +8 -0
  211. package/src/wirings/variable/validate-variable-definitions.test.ts +91 -0
  212. package/src/wirings/variable/validate-variable-definitions.ts +69 -0
  213. package/src/wirings/variable/variable.types.ts +22 -0
  214. package/src/wirings/workflow/dsl/index.ts +31 -0
  215. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +320 -0
  216. package/src/wirings/workflow/dsl/workflow-runner.ts +28 -0
  217. package/src/wirings/workflow/graph/graph-node.ts +222 -0
  218. package/src/wirings/workflow/graph/graph-runner.test.ts +487 -0
  219. package/src/wirings/workflow/graph/graph-runner.ts +816 -0
  220. package/src/wirings/workflow/graph/graph-validation.test.ts +170 -0
  221. package/src/wirings/workflow/graph/graph-validation.ts +237 -0
  222. package/src/wirings/workflow/graph/index.ts +19 -0
  223. package/src/wirings/workflow/graph/template.test.ts +49 -0
  224. package/src/wirings/workflow/graph/template.ts +42 -0
  225. package/src/wirings/workflow/graph/wire-workflow-graph.ts +29 -0
  226. package/src/wirings/workflow/graph/workflow-graph.types.ts +104 -0
  227. package/src/wirings/workflow/index.ts +82 -0
  228. package/src/wirings/workflow/pikku-workflow-service.test.ts +200 -0
  229. package/src/wirings/workflow/pikku-workflow-service.ts +1229 -0
  230. package/src/wirings/workflow/workflow-helpers.test.ts +129 -0
  231. package/src/wirings/workflow/workflow-helpers.ts +79 -0
  232. package/src/wirings/workflow/workflow.types.ts +276 -0
  233. package/tsconfig.json +14 -0
  234. package/tsconfig.tsbuildinfo +1 -0
  235. package/lcov.info +0 -18891
@@ -0,0 +1,1229 @@
1
+ import { runPikkuFunc } from '../../function/function-runner.js'
2
+ import {
3
+ getSingletonServices,
4
+ getCreateWireServices,
5
+ pikkuState,
6
+ } from '../../pikku-state.js'
7
+ import { getDurationInMilliseconds } from '../../time-utils.js'
8
+ import type { PikkuWire, SerializedError } from '../../types/core.types.js'
9
+ import type { QueueService } from '../queue/queue.types.js'
10
+ import type {
11
+ PikkuWorkflowWire,
12
+ StepState,
13
+ WorkflowRun,
14
+ WorkflowRunWire,
15
+ WorkflowStatus,
16
+ WorkflowVersionStatus,
17
+ WorkflowServiceConfig,
18
+ WorkflowStepOptions,
19
+ } from './workflow.types.js'
20
+ import {
21
+ continueGraph,
22
+ executeGraphStep,
23
+ runWorkflowGraph,
24
+ runFromMeta,
25
+ } from './graph/graph-runner.js'
26
+ import type { WorkflowService } from '../../services/workflow-service.js'
27
+ import { PikkuError, addError } from '../../errors/error-handler.js'
28
+ import { RPCNotFoundError } from '../rpc/rpc-runner.js'
29
+
30
+ /**
31
+ * Exception thrown when workflow needs to pause for async step
32
+ */
33
+ export class WorkflowAsyncException extends Error {
34
+ constructor(
35
+ public readonly runId: string,
36
+ public readonly stepName: string
37
+ ) {
38
+ super(`Workflow paused at step: ${stepName}`)
39
+ this.name = 'WorkflowAsyncException'
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Exception thrown when workflow is cancelled
45
+ */
46
+ export class WorkflowCancelledException extends Error {
47
+ constructor(
48
+ public readonly runId: string,
49
+ public readonly reason?: string
50
+ ) {
51
+ super(reason || 'Workflow cancelled')
52
+ this.name = 'WorkflowCancelledException'
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Exception thrown when workflow is suspended
58
+ */
59
+ export class WorkflowSuspendedException extends Error {
60
+ constructor(
61
+ public readonly runId: string,
62
+ public readonly reason: string
63
+ ) {
64
+ super(reason || 'Workflow suspended')
65
+ this.name = 'WorkflowSuspendedException'
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Error class for workflow not found
71
+ */
72
+ export class WorkflowNotFoundError extends PikkuError {
73
+ constructor(name: string) {
74
+ super(`Workflow not found: ${name}`)
75
+ }
76
+ }
77
+ addError(WorkflowNotFoundError, {
78
+ status: 404,
79
+ message: 'Workflow not found.',
80
+ })
81
+
82
+ export class WorkflowRunNotFoundError extends PikkuError {
83
+ constructor(runId: string) {
84
+ super(`Workflow run not found: ${runId}`)
85
+ }
86
+ }
87
+ addError(WorkflowRunNotFoundError, {
88
+ status: 404,
89
+ message: 'Workflow run not found.',
90
+ })
91
+
92
+ export class WorkflowServiceNotInitialized extends Error {}
93
+ export class WorkflowStepNameNotString extends Error {
94
+ constructor(stepName: any) {
95
+ super(`Workflow step name must be a string. Received: ${typeof stepName}`)
96
+ }
97
+ }
98
+
99
+ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
100
+ 'completed',
101
+ 'failed',
102
+ 'cancelled',
103
+ 'suspended',
104
+ ])
105
+
106
+ /**
107
+ * Abstract workflow state service
108
+ * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
109
+ * Combines orchestration and step execution
110
+ */
111
+ export abstract class PikkuWorkflowService implements WorkflowService {
112
+ private inlineRuns = new Set<string>()
113
+
114
+ constructor() {}
115
+
116
+ /**
117
+ * Check if a run is executing inline (without queues)
118
+ */
119
+ protected isInline(runId: string): boolean {
120
+ return this.inlineRuns.has(runId)
121
+ }
122
+
123
+ /**
124
+ * Register a run as inline (for graph-runner to use)
125
+ */
126
+ public registerInlineRun(runId: string): void {
127
+ this.inlineRuns.add(runId)
128
+ }
129
+
130
+ /**
131
+ * Unregister a run from inline tracking
132
+ */
133
+ public unregisterInlineRun(runId: string): void {
134
+ this.inlineRuns.delete(runId)
135
+ }
136
+
137
+ public async registerWorkflowVersions(): Promise<void> {
138
+ const allMeta = pikkuState(null, 'workflows', 'meta')
139
+ for (const [name, meta] of Object.entries(allMeta)) {
140
+ if (!meta.graphHash) continue
141
+ await this.upsertWorkflowVersion(name, meta.graphHash, meta, meta.source)
142
+ }
143
+ }
144
+
145
+ abstract createRun(
146
+ workflowName: string,
147
+ input: any,
148
+ inline: boolean,
149
+ graphHash: string,
150
+ wire: WorkflowRunWire
151
+ ): Promise<string>
152
+
153
+ /**
154
+ * Get a workflow run by ID
155
+ * @param id - Run ID
156
+ * @returns Workflow run or null if not found
157
+ */
158
+ abstract getRun(id: string): Promise<WorkflowRun | null>
159
+
160
+ /**
161
+ * Get workflow run history (all step attempts in chronological order)
162
+ * @param runId - Run ID
163
+ * @returns Array of step states with step names, ordered oldest to newest
164
+ */
165
+ abstract getRunHistory(
166
+ runId: string
167
+ ): Promise<Array<StepState & { stepName: string }>>
168
+
169
+ /**
170
+ * Update workflow run status
171
+ * @param id - Run ID
172
+ * @param status - New status
173
+ */
174
+ abstract updateRunStatus(
175
+ id: string,
176
+ status: WorkflowStatus,
177
+ output?: any,
178
+ error?: SerializedError
179
+ ): Promise<void>
180
+
181
+ /**
182
+ * Insert initial step state (called by orchestrator)
183
+ * Creates pending step in both workflow_step and workflow_step_history
184
+ * @param runId - Run ID
185
+ * @param stepName - Step cache key
186
+ * @param rpcName - RPC function name
187
+ * @param data - Step input data
188
+ * @param stepOptions - Step options (retries, retryDelay)
189
+ * @returns Step state with generated stepId
190
+ */
191
+ abstract insertStepState(
192
+ runId: string,
193
+ stepName: string,
194
+ rpcName: string | null,
195
+ data: any,
196
+ stepOptions?: WorkflowStepOptions
197
+ ): Promise<StepState>
198
+
199
+ /**
200
+ * Get step state by cache key (read-only)
201
+ * @param runId - Run ID
202
+ * @param stepName - Step cache key (from workflow.do)
203
+ * @returns Step state with attemptCount calculated from history
204
+ */
205
+ abstract getStepState(runId: string, stepName: string): Promise<StepState>
206
+
207
+ /**
208
+ * Mark step as running
209
+ * Updates both workflow_step and workflow_step_history
210
+ * @param stepId - Step ID
211
+ */
212
+ abstract setStepRunning(stepId: string): Promise<void>
213
+
214
+ /**
215
+ * Mark step as scheduled (queued for execution)
216
+ * Updates both workflow_step and workflow_step_history
217
+ * @param stepId - Step ID
218
+ */
219
+ abstract setStepScheduled(stepId: string): Promise<void>
220
+
221
+ /**
222
+ * Store step result and mark as succeeded
223
+ * Updates both workflow_step and workflow_step_history
224
+ * @param stepId - Step ID
225
+ * @param result - Step result
226
+ */
227
+ abstract setStepResult(stepId: string, result: any): Promise<void>
228
+
229
+ /**
230
+ * Set the child workflow run ID on a step
231
+ * @param stepId - Step ID
232
+ * @param childRunId - Child workflow run ID
233
+ */
234
+ abstract setStepChildRunId(stepId: string, childRunId: string): Promise<void>
235
+
236
+ /**
237
+ * Store step error and mark as failed
238
+ * Updates both workflow_step and workflow_step_history
239
+ * @param stepId - Step ID
240
+ * @param error - Error object
241
+ */
242
+ abstract setStepError(stepId: string, error: Error): Promise<void>
243
+
244
+ /**
245
+ * Create a new retry attempt for a failed step
246
+ * Inserts new pending step in both workflow_step and workflow_step_history
247
+ * Resets status to 'pending' with new stepId
248
+ * Copies metadata (rpcName, data, retries, retryDelay) from failed attempt
249
+ * @param failedStepId - Failed step ID to copy from
250
+ * @returns New step state for the retry attempt
251
+ */
252
+ abstract createRetryAttempt(
253
+ failedStepId: string,
254
+ status: 'pending' | 'running'
255
+ ): Promise<StepState>
256
+
257
+ /**
258
+ * Execute function within a run lock to prevent concurrent modifications
259
+ * @param id - Run ID
260
+ * @param fn - Function to execute
261
+ * @returns Function result
262
+ */
263
+ abstract withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>
264
+
265
+ /**
266
+ * Execute function within a step lock to prevent concurrent step execution
267
+ * @param runId - Run ID
268
+ * @param stepName - Step name
269
+ * @param fn - Function to execute
270
+ * @returns Function result
271
+ */
272
+ abstract withStepLock<T>(
273
+ runId: string,
274
+ stepName: string,
275
+ fn: () => Promise<T>
276
+ ): Promise<T>
277
+
278
+ /**
279
+ * Close any open connections
280
+ */
281
+ abstract close(): Promise<void>
282
+
283
+ // ============================================================================
284
+ // Workflow Graph Methods
285
+ // ============================================================================
286
+
287
+ /**
288
+ * Get completed graph state (lightweight - no results)
289
+ * @param runId - Run ID
290
+ * @returns Completed node IDs and their branch keys
291
+ */
292
+ abstract getCompletedGraphState(runId: string): Promise<{
293
+ completedNodeIds: string[]
294
+ failedNodeIds: string[]
295
+ branchKeys: Record<string, string>
296
+ }>
297
+
298
+ /**
299
+ * Filter candidate nodes to only those without existing steps
300
+ * @param runId - Run ID
301
+ * @param nodeIds - Candidate node IDs to check
302
+ * @returns Node IDs that don't have a step yet
303
+ */
304
+ abstract getNodesWithoutSteps(
305
+ runId: string,
306
+ nodeIds: string[]
307
+ ): Promise<string[]>
308
+
309
+ /**
310
+ * Get results for specific nodes
311
+ * @param runId - Run ID
312
+ * @param nodeIds - Node IDs to fetch results for
313
+ * @returns Map of nodeId to result
314
+ */
315
+ abstract getNodeResults(
316
+ runId: string,
317
+ nodeIds: string[]
318
+ ): Promise<Record<string, any>>
319
+
320
+ /**
321
+ * Set the branch key for a graph node step
322
+ * @param stepId - Step ID
323
+ * @param branchKey - Branch key selected by graph.branch()
324
+ */
325
+ abstract setBranchTaken(stepId: string, branchKey: string): Promise<void>
326
+
327
+ /**
328
+ * Update a state variable in the workflow run's state
329
+ * @param runId - Run ID
330
+ * @param name - Variable name
331
+ * @param value - Value to store
332
+ */
333
+ abstract updateRunState(
334
+ runId: string,
335
+ name: string,
336
+ value: unknown
337
+ ): Promise<void>
338
+
339
+ /**
340
+ * Get the entire state object for a workflow run
341
+ * @param runId - Run ID
342
+ * @returns The state object with all variables
343
+ */
344
+ abstract getRunState(runId: string): Promise<Record<string, unknown>>
345
+
346
+ abstract upsertWorkflowVersion(
347
+ name: string,
348
+ graphHash: string,
349
+ graph: any,
350
+ source: string,
351
+ status?: WorkflowVersionStatus
352
+ ): Promise<void>
353
+
354
+ abstract updateWorkflowVersionStatus(
355
+ name: string,
356
+ graphHash: string,
357
+ status: WorkflowVersionStatus
358
+ ): Promise<void>
359
+
360
+ abstract getWorkflowVersion(
361
+ name: string,
362
+ graphHash: string
363
+ ): Promise<{ graph: any; source: string } | null>
364
+
365
+ abstract getAIGeneratedWorkflows(
366
+ agentName?: string
367
+ ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>>
368
+
369
+ // ============================================================================
370
+ // Workflow Lifecycle Methods
371
+ // ============================================================================
372
+
373
+ /**
374
+ * Resume a paused workflow by triggering the orchestrator
375
+ * @param runId - Run ID
376
+ */
377
+ public async resumeWorkflow(runId: string): Promise<void> {
378
+ const queueService = this.verifyQueueService()
379
+ await queueService.add(this.getConfig().orchestratorQueueName, { runId })
380
+ }
381
+
382
+ public async queueStepWorker(
383
+ runId: string,
384
+ stepName: string,
385
+ rpcName: string,
386
+ data: any
387
+ ): Promise<void> {
388
+ const queueService = this.verifyQueueService()
389
+ await queueService.add(this.getConfig().stepWorkerQueueName, {
390
+ runId,
391
+ stepName,
392
+ rpcName,
393
+ data,
394
+ })
395
+ }
396
+
397
+ /**
398
+ * Execute a workflow sleep step completion
399
+ * Sets the step result to null and resumes the workflow
400
+ * @param data - Sleeper input data
401
+ */
402
+ public async executeWorkflowSleepCompleted(
403
+ runId: string,
404
+ stepId: string
405
+ ): Promise<void> {
406
+ await this.setStepResult(stepId, null)
407
+ await this.resumeWorkflow(runId)
408
+ }
409
+
410
+ /**
411
+ * Schedule orchestrator retry with delay
412
+ * @param runId - Run ID
413
+ * @param retryDelay - Delay in milliseconds or duration string (optional)
414
+ */
415
+ protected async scheduleOrchestratorRetry(
416
+ runId: string,
417
+ retryDelay?: number | string
418
+ ): Promise<void> {
419
+ const queueService = this.verifyQueueService()
420
+ await queueService.add(
421
+ this.getConfig().orchestratorQueueName,
422
+ { runId },
423
+ retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined
424
+ )
425
+ }
426
+
427
+ /**
428
+ * Start a new workflow run
429
+ * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
430
+ * @param options.inline - If true, execute workflow directly without queue service
431
+ * @param options.startNode - Starting node ID for graph workflows (from wire config)
432
+ */
433
+ public async startWorkflow<I>(
434
+ name: string,
435
+ input: I,
436
+ wire: WorkflowRunWire,
437
+ rpcService: any,
438
+ options?: { inline?: boolean; startNode?: string }
439
+ ): Promise<{ runId: string }> {
440
+ // Check meta to determine workflow type
441
+ const meta = pikkuState(null, 'workflows', 'meta')
442
+ const workflowMeta = meta[name]
443
+
444
+ if (!workflowMeta) {
445
+ throw new WorkflowNotFoundError(name)
446
+ }
447
+
448
+ if (workflowMeta.source === 'graph' || workflowMeta.source === 'ai-agent') {
449
+ const shouldInline =
450
+ options?.inline || !getSingletonServices()?.queueService
451
+ return runWorkflowGraph(
452
+ this,
453
+ name,
454
+ input,
455
+ rpcService,
456
+ shouldInline,
457
+ options?.startNode,
458
+ wire
459
+ )
460
+ }
461
+
462
+ // DSL workflow - check registration exists
463
+ const registrations = pikkuState(null, 'workflows', 'registrations')
464
+ const workflow = registrations.get(name)
465
+
466
+ if (!workflow) {
467
+ throw new WorkflowNotFoundError(name)
468
+ }
469
+
470
+ if (!workflowMeta.graphHash) {
471
+ throw new Error(`Missing workflow graphHash for '${name}'`)
472
+ }
473
+
474
+ const shouldInline =
475
+ options?.inline || !getSingletonServices()?.queueService
476
+
477
+ const runId = await this.createRun(
478
+ name,
479
+ input,
480
+ shouldInline,
481
+ workflowMeta.graphHash,
482
+ wire
483
+ )
484
+
485
+ if (shouldInline) {
486
+ this.inlineRuns.add(runId)
487
+ this.runWorkflowJob(runId, rpcService)
488
+ .catch((err) => {
489
+ getSingletonServices()!.logger.error(
490
+ `Workflow ${name} (run ${runId}) failed:`,
491
+ err
492
+ )
493
+ })
494
+ .finally(() => {
495
+ this.inlineRuns.delete(runId)
496
+ })
497
+ } else {
498
+ await this.resumeWorkflow(runId)
499
+ }
500
+
501
+ return { runId }
502
+ }
503
+
504
+ public async runToCompletion<I>(
505
+ name: string,
506
+ input: I,
507
+ rpcService: any,
508
+ options?: { pollIntervalMs?: number; wire?: WorkflowRunWire }
509
+ ): Promise<any> {
510
+ const pollInterval = options?.pollIntervalMs ?? 1000
511
+ const { runId } = await this.startWorkflow(
512
+ name,
513
+ input,
514
+ options?.wire ?? { type: 'internal' },
515
+ rpcService,
516
+ { inline: true }
517
+ )
518
+ while (true) {
519
+ const run = await this.getRun(runId)
520
+ if (!run) {
521
+ throw new WorkflowRunNotFoundError(runId)
522
+ }
523
+ if (WORKFLOW_END_STATES.has(run.status)) {
524
+ if (run.status === 'failed') {
525
+ throw new Error(run.error?.message || 'Workflow failed')
526
+ }
527
+ if (run.status === 'cancelled') {
528
+ throw new Error('Workflow was cancelled')
529
+ }
530
+ return run.output
531
+ }
532
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
533
+ }
534
+ }
535
+
536
+ public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
537
+ const run = await this.getRun(runId)
538
+ if (!run) {
539
+ throw new WorkflowRunNotFoundError(runId)
540
+ }
541
+
542
+ const meta = pikkuState(null, 'workflows', 'meta')
543
+ const workflowMeta = meta[run.workflow]
544
+
545
+ if (
546
+ run.graphHash &&
547
+ workflowMeta?.graphHash &&
548
+ run.graphHash !== workflowMeta.graphHash
549
+ ) {
550
+ await this.runVersionMismatchFallback(run, workflowMeta, rpcService)
551
+ return
552
+ }
553
+
554
+ if (workflowMeta?.source === 'graph') {
555
+ await continueGraph(this, runId, run.workflow)
556
+ return
557
+ }
558
+
559
+ const registrations = pikkuState(null, 'workflows', 'registrations')
560
+ const workflow = registrations.get(run.workflow)
561
+ if (!workflow) {
562
+ throw new WorkflowNotFoundError(run.workflow)
563
+ }
564
+
565
+ await this.withRunLock(runId, async () => {
566
+ const workflowWire = this.createWorkflowWire(
567
+ run.workflow,
568
+ runId,
569
+ rpcService
570
+ )
571
+ const wire: PikkuWire = {
572
+ workflow: workflowWire,
573
+ session: rpcService.wire?.session,
574
+ rpc: rpcService.wire?.rpc,
575
+ }
576
+ try {
577
+ const result = await runPikkuFunc(
578
+ 'workflow',
579
+ workflowMeta.name,
580
+ workflowMeta.pikkuFuncId,
581
+ {
582
+ singletonServices: getSingletonServices()!,
583
+ wire,
584
+ createWireServices: getCreateWireServices(),
585
+ data: () => run.input,
586
+ }
587
+ )
588
+
589
+ await this.updateRunStatus(runId, 'completed', result)
590
+ } catch (error: any) {
591
+ if (error instanceof WorkflowAsyncException) {
592
+ throw error
593
+ }
594
+
595
+ if (error instanceof WorkflowCancelledException) {
596
+ await this.updateRunStatus(runId, 'cancelled', undefined, {
597
+ message: error.message || 'Workflow cancelled',
598
+ stack: '',
599
+ code: 'WORKFLOW_CANCELLED',
600
+ })
601
+ throw error
602
+ }
603
+
604
+ if (error instanceof WorkflowSuspendedException) {
605
+ await this.updateRunStatus(runId, 'suspended', undefined, {
606
+ message: error.message || 'Workflow suspended',
607
+ stack: '',
608
+ code: 'WORKFLOW_SUSPENDED',
609
+ })
610
+ throw error
611
+ }
612
+
613
+ await this.updateRunStatus(runId, 'failed', undefined, {
614
+ message: error.message,
615
+ stack: error.stack,
616
+ code: error.code,
617
+ })
618
+
619
+ throw error
620
+ }
621
+ })
622
+ }
623
+
624
+ private async runVersionMismatchFallback(
625
+ run: WorkflowRun,
626
+ currentMeta: { source: string },
627
+ rpcService: any
628
+ ): Promise<void> {
629
+ const source = currentMeta.source
630
+
631
+ if (source === 'complex') {
632
+ await this.updateRunStatus(run.id, 'failed', undefined, {
633
+ message: `Workflow '${run.workflow}' definition changed. Complex workflows with inline steps cannot be migrated.`,
634
+ stack: '',
635
+ code: 'VERSION_CONFLICT',
636
+ })
637
+ return
638
+ }
639
+
640
+ const version = await this.getWorkflowVersion(run.workflow, run.graphHash!)
641
+ if (!version) {
642
+ await this.updateRunStatus(run.id, 'failed', undefined, {
643
+ message: `Workflow '${run.workflow}' version '${run.graphHash}' not found. Cannot resume with changed definition.`,
644
+ stack: '',
645
+ code: 'VERSION_NOT_FOUND',
646
+ })
647
+ return
648
+ }
649
+
650
+ await runFromMeta(this, run.id, version.graph, rpcService)
651
+ }
652
+
653
+ /**
654
+ * Execute a single workflow step (called by worker)
655
+ * Handles idempotency, RPC execution, result storage, retry logic, and orchestrator triggering
656
+ */
657
+ public async executeWorkflowStep(
658
+ runId: string,
659
+ stepName: string,
660
+ rpcName: string,
661
+ data: any,
662
+ rpcService: any
663
+ ): Promise<void> {
664
+ // Use step-level lock to prevent concurrent execution of same step
665
+ await this.withStepLock(runId, stepName, async () => {
666
+ // Get step state
667
+ let stepState = await this.getStepState(runId, stepName)
668
+
669
+ // Idempotency - if already succeeded, resume orchestrator and return
670
+ if (stepState.status === 'succeeded') {
671
+ await this.resumeWorkflow(runId)
672
+ return
673
+ }
674
+
675
+ // Log warning if already running (race condition)
676
+ if (stepState.status === 'running') {
677
+ return
678
+ }
679
+
680
+ // If status is 'failed', this is a retry - create new attempt history
681
+ if (stepState.status === 'failed') {
682
+ stepState = await this.createRetryAttempt(stepState.stepId, 'running')
683
+ }
684
+
685
+ if (stepState.status === 'pending') {
686
+ // Mark pending step as running before execution
687
+ await this.setStepRunning(stepState.stepId)
688
+ }
689
+
690
+ try {
691
+ let result: any
692
+
693
+ const run = await this.getRun(runId)
694
+ if (!run) {
695
+ throw new Error(`Workflow run not found: ${runId}`)
696
+ }
697
+
698
+ const meta = pikkuState(null, 'workflows', 'meta')
699
+ const workflowMeta = meta[run.workflow]
700
+
701
+ if (workflowMeta?.nodes && stepName in workflowMeta.nodes) {
702
+ result = await executeGraphStep(
703
+ this,
704
+ rpcService,
705
+ runId,
706
+ stepState.stepId,
707
+ stepName,
708
+ rpcName,
709
+ data,
710
+ run.workflow
711
+ )
712
+ } else {
713
+ result = await rpcService.rpcWithWire(rpcName, data, {
714
+ workflowStep: {
715
+ runId,
716
+ stepId: stepState.stepId,
717
+ attemptCount: stepState.attemptCount,
718
+ },
719
+ })
720
+ }
721
+
722
+ // Store result and mark succeeded
723
+ await this.setStepResult(stepState.stepId, result)
724
+
725
+ // Resume orchestrator to continue workflow
726
+ await this.resumeWorkflow(runId)
727
+ } catch (error: any) {
728
+ if (error instanceof RPCNotFoundError) {
729
+ await this.setStepError(stepState.stepId, error)
730
+ await this.updateRunStatus(runId, 'suspended', undefined, {
731
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
732
+ code: 'RPC_NOT_FOUND',
733
+ })
734
+ return
735
+ }
736
+
737
+ // Store error and mark failed
738
+ await this.setStepError(stepState.stepId, error)
739
+
740
+ const maxAttempts = (stepState.retries ?? 0) + 1
741
+ const retriesExhausted = stepState.attemptCount >= maxAttempts
742
+
743
+ if (retriesExhausted) {
744
+ // No more retries - resume orchestrator to mark workflow as failed
745
+ await this.resumeWorkflow(runId)
746
+ }
747
+
748
+ // Always throw so queue knows the job failed and can retry if needed
749
+ throw error
750
+ }
751
+ })
752
+ }
753
+
754
+ /**
755
+ * Orchestrate workflow execution (called by orchestrator)
756
+ * Runs workflow job and handles async exceptions
757
+ */
758
+ public async orchestrateWorkflow(
759
+ runId: string,
760
+ rpcService: any
761
+ ): Promise<void> {
762
+ try {
763
+ // Run workflow job (replays with caching)
764
+ await this.runWorkflowJob(runId, rpcService)
765
+ } catch (error: any) {
766
+ if (
767
+ error.name === 'WorkflowAsyncException' ||
768
+ error.name === 'WorkflowCancelledException' ||
769
+ error.name === 'WorkflowSuspendedException'
770
+ ) {
771
+ return
772
+ }
773
+
774
+ await this.updateRunStatus(runId, 'failed', undefined, {
775
+ message: error.message,
776
+ stack: error.stack,
777
+ code: error.code,
778
+ })
779
+
780
+ throw error
781
+ }
782
+ }
783
+
784
+ private verifyQueueService(): QueueService {
785
+ if (!getSingletonServices()?.queueService) {
786
+ throw new Error(
787
+ 'QueueService not configured. Remote workflows require a queue service.'
788
+ )
789
+ }
790
+
791
+ return getSingletonServices()!.queueService!
792
+ }
793
+
794
+ private async rpcStep(
795
+ runId: string,
796
+ stepName: string,
797
+ rpcName: string,
798
+ data: any,
799
+ rpcService: any,
800
+ stepOptions?: WorkflowStepOptions
801
+ ): Promise<any> {
802
+ // Check if step already exists
803
+ let stepState: StepState
804
+ try {
805
+ stepState = await this.getStepState(runId, stepName)
806
+ } catch {
807
+ // Step doesn't exist - create it
808
+ stepState = await this.insertStepState(
809
+ runId,
810
+ stepName,
811
+ rpcName,
812
+ data,
813
+ stepOptions
814
+ )
815
+ }
816
+
817
+ if (stepState.status === 'succeeded') {
818
+ // Return cached result
819
+ return stepState.result
820
+ }
821
+
822
+ if (stepState.status === 'failed') {
823
+ // Step failed with retries exhausted - throw error to fail the workflow
824
+ const error = new Error(
825
+ stepState.error?.message ||
826
+ `Step '${stepName}' failed after exhausting all retries`
827
+ )
828
+ // Preserve original error properties if available
829
+ if (stepState.error) {
830
+ Object.assign(error, stepState.error)
831
+ }
832
+ throw error
833
+ }
834
+
835
+ if (stepState.status === 'scheduled') {
836
+ // Step is already scheduled, pause workflow
837
+ throw new WorkflowAsyncException(runId, stepName)
838
+ }
839
+
840
+ // Step is pending - schedule it
841
+ await this.setStepScheduled(stepState.stepId)
842
+
843
+ // Enqueue step worker (unless inline mode)
844
+ if (!this.isInline(runId) && getSingletonServices()!.queueService) {
845
+ // Map step retry options to queue job options
846
+ const retries = stepOptions?.retries ?? 0
847
+ const retryDelay = stepOptions?.retryDelay
848
+
849
+ await getSingletonServices()!.queueService!.add(
850
+ this.getConfig().stepWorkerQueueName,
851
+ {
852
+ runId,
853
+ stepName,
854
+ rpcName,
855
+ data,
856
+ },
857
+ {
858
+ // attempts includes initial attempt, retries doesn't
859
+ attempts: retries + 1,
860
+ // Map retry delay to backoff
861
+ backoff:
862
+ typeof retryDelay === 'number'
863
+ ? { type: 'fixed', delay: retryDelay }
864
+ : retryDelay === 'exponential'
865
+ ? 'exponential'
866
+ : undefined,
867
+ }
868
+ )
869
+ // Pause workflow - step will callback when done
870
+ throw new WorkflowAsyncException(runId, stepName)
871
+ } else {
872
+ // Inline or no queue service - execute locally with retry loop
873
+ const retries = stepOptions?.retries ?? 0
874
+ const retryDelay = stepOptions?.retryDelay
875
+ let currentStepState = stepState
876
+
877
+ while (true) {
878
+ try {
879
+ await this.setStepRunning(currentStepState.stepId)
880
+ // Check if the name refers to a workflow
881
+ const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
882
+ let result: any
883
+ if (workflowMeta) {
884
+ const childWire = {
885
+ type: 'workflow',
886
+ id: rpcName,
887
+ parentRunId: runId,
888
+ }
889
+ const { runId: childRunId } = await this.startWorkflow(
890
+ rpcName,
891
+ data,
892
+ childWire,
893
+ rpcService,
894
+ { inline: true }
895
+ )
896
+ await this.setStepChildRunId(currentStepState.stepId, childRunId)
897
+ // Poll until child workflow completes
898
+ while (true) {
899
+ const childRun = await this.getRun(childRunId)
900
+ if (!childRun) {
901
+ throw new WorkflowRunNotFoundError(childRunId)
902
+ }
903
+ if (WORKFLOW_END_STATES.has(childRun.status)) {
904
+ if (childRun.status === 'failed') {
905
+ throw new Error(
906
+ childRun.error?.message || 'Sub-workflow failed'
907
+ )
908
+ }
909
+ if (childRun.status === 'cancelled') {
910
+ throw new Error('Sub-workflow was cancelled')
911
+ }
912
+ result = childRun.output
913
+ break
914
+ }
915
+ await new Promise((resolve) => setTimeout(resolve, 500))
916
+ }
917
+ } else {
918
+ result = await rpcService.rpcWithWire(rpcName, data, {
919
+ workflowStep: {
920
+ runId,
921
+ stepId: currentStepState.stepId,
922
+ attemptCount: currentStepState.attemptCount,
923
+ },
924
+ })
925
+ }
926
+ await this.setStepResult(currentStepState.stepId, result)
927
+ return result
928
+ } catch (error: any) {
929
+ if (error instanceof RPCNotFoundError) {
930
+ await this.updateRunStatus(runId, 'suspended', undefined, {
931
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
932
+ code: 'RPC_NOT_FOUND',
933
+ })
934
+ throw error
935
+ }
936
+
937
+ // Record the error (marks step as failed)
938
+ await this.setStepError(currentStepState.stepId, error)
939
+
940
+ // Check if we should retry
941
+ if (currentStepState.attemptCount < retries) {
942
+ // Create a new pending retry attempt
943
+ currentStepState = await this.createRetryAttempt(
944
+ currentStepState.stepId,
945
+ 'pending'
946
+ )
947
+
948
+ // Wait for retry delay if specified
949
+ if (retryDelay) {
950
+ await new Promise((resolve) =>
951
+ setTimeout(resolve, getDurationInMilliseconds(retryDelay))
952
+ )
953
+ }
954
+ // Continue loop to retry
955
+ } else {
956
+ // No more retries, fail the workflow
957
+ throw error
958
+ }
959
+ }
960
+ }
961
+ }
962
+ }
963
+
964
+ private async inlineStep(
965
+ runId: string,
966
+ stepName: string,
967
+ fn: Function,
968
+ stepOptions?: WorkflowStepOptions
969
+ ): Promise<any> {
970
+ // Check if step already exists
971
+ let stepState: StepState
972
+ try {
973
+ stepState = await this.getStepState(runId, stepName)
974
+ } catch {
975
+ // Step doesn't exist - create it (inline, no RPC)
976
+ stepState = await this.insertStepState(
977
+ runId,
978
+ stepName,
979
+ null,
980
+ null,
981
+ stepOptions
982
+ )
983
+ }
984
+
985
+ if (stepState.status === 'succeeded') {
986
+ // Return cached result
987
+ return stepState.result
988
+ }
989
+
990
+ // Execute inline function
991
+ const retries = stepOptions?.retries ?? this.getConfig().retries
992
+ const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
993
+ let currentStepState = stepState
994
+
995
+ // Check if we're running inline (in-memory) or remote (queue-based)
996
+ if (this.isInline(runId)) {
997
+ // Inline mode - execute with retry loop
998
+ while (true) {
999
+ try {
1000
+ await this.setStepRunning(currentStepState.stepId)
1001
+ const result = await fn()
1002
+ await this.setStepResult(currentStepState.stepId, result)
1003
+ return result
1004
+ } catch (error: any) {
1005
+ // Record the error (marks step as failed)
1006
+ await this.setStepError(currentStepState.stepId, error)
1007
+
1008
+ // Check if we should retry
1009
+ if (currentStepState.attemptCount < retries) {
1010
+ // Create a new pending retry attempt
1011
+ currentStepState = await this.createRetryAttempt(
1012
+ currentStepState.stepId,
1013
+ 'pending'
1014
+ )
1015
+
1016
+ // Wait for retry delay if specified
1017
+ if (retryDelay) {
1018
+ await new Promise((resolve) =>
1019
+ setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1020
+ )
1021
+ }
1022
+ // Continue loop to retry
1023
+ } else {
1024
+ // No more retries, fail the workflow
1025
+ throw error
1026
+ }
1027
+ }
1028
+ }
1029
+ } else {
1030
+ // Remote mode - use queue-based retry
1031
+ try {
1032
+ await this.setStepRunning(currentStepState.stepId)
1033
+ const result = await fn()
1034
+ await this.setStepResult(currentStepState.stepId, result)
1035
+ return result
1036
+ } catch (error: any) {
1037
+ // Record the error (marks step as failed)
1038
+ await this.setStepError(currentStepState.stepId, error)
1039
+
1040
+ // Check if we should retry
1041
+ if (currentStepState.attemptCount < retries) {
1042
+ // Create a new pending retry attempt (copies metadata from failed step)
1043
+ await this.createRetryAttempt(currentStepState.stepId, 'pending')
1044
+
1045
+ // Schedule orchestrator to retry after delay
1046
+ await this.scheduleOrchestratorRetry(runId, retryDelay)
1047
+
1048
+ // Pause workflow - orchestrator will replay and pick up new attempt
1049
+ throw new WorkflowAsyncException(runId, stepName)
1050
+ }
1051
+
1052
+ // No more retries, fail the workflow
1053
+ throw error
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ private async sleepStep(runId: string, stepName: string, duration: number) {
1059
+ // Check if step already exists
1060
+ let stepState: StepState
1061
+ try {
1062
+ stepState = await this.getStepState(runId, stepName)
1063
+ } catch {
1064
+ // Step doesn't exist - create it (sleep step, no RPC)
1065
+ stepState = await this.insertStepState(runId, stepName, null, {
1066
+ duration,
1067
+ })
1068
+ }
1069
+
1070
+ if (stepState.status === 'succeeded') {
1071
+ // Sleep already completed, return immediately
1072
+ return
1073
+ }
1074
+
1075
+ if (stepState.status === 'scheduled') {
1076
+ // Sleep is already scheduled, pause workflow
1077
+ throw new WorkflowAsyncException(runId, stepName)
1078
+ }
1079
+
1080
+ // Step is pending - schedule it
1081
+ await this.setStepScheduled(stepState.stepId)
1082
+
1083
+ // Check if inline mode or no scheduler service
1084
+ if (!this.isInline(runId) && getSingletonServices()!.schedulerService) {
1085
+ // Remote mode - schedule sleep via scheduler service
1086
+ await getSingletonServices()!.schedulerService!.scheduleRPC(
1087
+ duration,
1088
+ this.getConfig().sleeperRPCName,
1089
+ {
1090
+ runId,
1091
+ stepId: stepState.stepId,
1092
+ }
1093
+ )
1094
+ // Pause workflow - sleep will callback when done
1095
+ throw new WorkflowAsyncException(runId, stepName)
1096
+ } else {
1097
+ // Inline mode - use setTimeout with actual duration
1098
+ await new Promise((resolve) =>
1099
+ setTimeout(resolve, getDurationInMilliseconds(duration))
1100
+ )
1101
+ await this.setStepResult(stepState.stepId, null)
1102
+ return
1103
+ }
1104
+ }
1105
+
1106
+ private getSuspendStepName(reason: string): string {
1107
+ if (!reason) {
1108
+ return '__workflow_suspend'
1109
+ }
1110
+ return '__workflow_suspend'
1111
+ }
1112
+
1113
+ private async suspendStep(runId: string, reason: string): Promise<void> {
1114
+ const stepName = this.getSuspendStepName(reason)
1115
+ await this.withStepLock(runId, stepName, async () => {
1116
+ let stepState: StepState
1117
+ try {
1118
+ stepState = await this.getStepState(runId, stepName)
1119
+ } catch {
1120
+ stepState = await this.insertStepState(
1121
+ runId,
1122
+ stepName,
1123
+ 'pikkuWorkflowSuspend',
1124
+ {
1125
+ reason,
1126
+ }
1127
+ )
1128
+ }
1129
+ if (!stepState.stepId) {
1130
+ stepState = await this.insertStepState(
1131
+ runId,
1132
+ stepName,
1133
+ 'pikkuWorkflowSuspend',
1134
+ {
1135
+ reason,
1136
+ }
1137
+ )
1138
+ }
1139
+
1140
+ if (stepState.status === 'succeeded') {
1141
+ return
1142
+ }
1143
+
1144
+ if (stepState.status === 'pending') {
1145
+ await this.setStepRunning(stepState.stepId)
1146
+ }
1147
+
1148
+ await this.setStepResult(stepState.stepId, {
1149
+ reason,
1150
+ suspendedAt: new Date().toISOString(),
1151
+ })
1152
+ throw new WorkflowSuspendedException(runId, reason)
1153
+ })
1154
+ }
1155
+
1156
+ private createWorkflowWire(
1157
+ name: string,
1158
+ runId: string,
1159
+ rpcService: any
1160
+ ): PikkuWorkflowWire {
1161
+ const workflowWire: PikkuWorkflowWire = {
1162
+ name,
1163
+ runId,
1164
+ getRun: async () => (await this.getRun(runId)) as WorkflowRun,
1165
+
1166
+ // Implement workflow.do() - RPC form
1167
+ do: async (
1168
+ stepName: string,
1169
+ rpcNameOrFn: any,
1170
+ dataOrOptions?: any,
1171
+ options?: any
1172
+ ) => {
1173
+ this.verifyStepName(stepName)
1174
+ if (typeof rpcNameOrFn === 'string') {
1175
+ return await this.rpcStep(
1176
+ runId,
1177
+ stepName,
1178
+ rpcNameOrFn,
1179
+ dataOrOptions,
1180
+ rpcService,
1181
+ options
1182
+ )
1183
+ } else {
1184
+ return await this.inlineStep(
1185
+ runId,
1186
+ stepName,
1187
+ rpcNameOrFn,
1188
+ dataOrOptions
1189
+ )
1190
+ }
1191
+ },
1192
+
1193
+ // Implement workflow.sleep()
1194
+ sleep: async (stepName: string, duration: string | number) => {
1195
+ this.verifyStepName(stepName)
1196
+ await this.sleepStep(
1197
+ runId,
1198
+ stepName,
1199
+ getDurationInMilliseconds(duration)
1200
+ )
1201
+ },
1202
+
1203
+ suspend: async (reason: string) => {
1204
+ await this.suspendStep(runId, reason || 'Workflow suspended')
1205
+ },
1206
+ }
1207
+ return workflowWire
1208
+ }
1209
+
1210
+ private verifyStepName(stepName: string) {
1211
+ if (typeof stepName !== 'string') {
1212
+ throw new WorkflowStepNameNotString(stepName)
1213
+ }
1214
+ }
1215
+
1216
+ private getConfig(): WorkflowServiceConfig {
1217
+ const singletonServices = getSingletonServices()
1218
+ const workflow = singletonServices.config?.workflow
1219
+ return {
1220
+ retries: workflow?.retries ?? 0,
1221
+ retryDelay: workflow?.retryDelay ?? 0,
1222
+ orchestratorQueueName:
1223
+ workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
1224
+ stepWorkerQueueName:
1225
+ workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
1226
+ sleeperRPCName: workflow?.sleeperRPCName ?? 'pikkuWorkflowSleeper',
1227
+ }
1228
+ }
1229
+ }