@pikku/core 0.12.4 → 0.12.5

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