@pikku/core 0.10.2 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/errors/errors.d.ts +1 -1
  3. package/dist/errors/errors.js +3 -3
  4. package/dist/function/function-runner.d.ts +6 -7
  5. package/dist/function/function-runner.js +35 -16
  6. package/dist/function/functions.types.d.ts +23 -30
  7. package/dist/function/functions.types.js +9 -6
  8. package/dist/handle-error.d.ts +1 -1
  9. package/dist/handle-error.js +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +2 -0
  12. package/dist/middleware/auth-apikey.d.ts +1 -0
  13. package/dist/middleware/auth-apikey.js +3 -3
  14. package/dist/middleware/auth-bearer.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.js +3 -3
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/auth-cookie.js +5 -5
  18. package/dist/middleware/timeout.d.ts +1 -0
  19. package/dist/middleware/timeout.js +3 -2
  20. package/dist/middleware-runner.d.ts +3 -6
  21. package/dist/middleware-runner.js +11 -11
  22. package/dist/permissions.d.ts +6 -6
  23. package/dist/permissions.js +15 -15
  24. package/dist/pikku-state.d.ts +7 -2
  25. package/dist/pikku-state.js +4 -0
  26. package/dist/services/index.d.ts +1 -0
  27. package/dist/services/index.js +1 -0
  28. package/dist/services/scheduler-service.d.ts +63 -0
  29. package/dist/services/scheduler-service.js +6 -0
  30. package/dist/services/user-session-service.d.ts +7 -3
  31. package/dist/services/user-session-service.js +8 -1
  32. package/dist/time-utils.d.ts +14 -0
  33. package/dist/time-utils.js +62 -0
  34. package/dist/types/core.types.d.ts +48 -35
  35. package/dist/types/core.types.js +3 -13
  36. package/dist/utils.d.ts +1 -1
  37. package/dist/utils.js +3 -3
  38. package/dist/wirings/channel/channel-common.js +2 -3
  39. package/dist/wirings/channel/channel-handler.d.ts +3 -2
  40. package/dist/wirings/channel/channel-handler.js +6 -9
  41. package/dist/wirings/channel/channel-runner.d.ts +2 -2
  42. package/dist/wirings/channel/channel.types.d.ts +10 -5
  43. package/dist/wirings/channel/local/local-channel-runner.d.ts +1 -1
  44. package/dist/wirings/channel/local/local-channel-runner.js +19 -26
  45. package/dist/wirings/channel/serverless/serverless-channel-runner.d.ts +1 -1
  46. package/dist/wirings/channel/serverless/serverless-channel-runner.js +41 -46
  47. package/dist/wirings/cli/channel/cli-channel-runner.js +1 -1
  48. package/dist/wirings/cli/cli-runner.d.ts +5 -5
  49. package/dist/wirings/cli/cli-runner.js +11 -29
  50. package/dist/wirings/cli/cli.types.d.ts +15 -11
  51. package/dist/wirings/http/http-runner.d.ts +6 -6
  52. package/dist/wirings/http/http-runner.js +25 -39
  53. package/dist/wirings/http/http.types.d.ts +5 -10
  54. package/dist/wirings/mcp/mcp-runner.d.ts +2 -2
  55. package/dist/wirings/mcp/mcp-runner.js +12 -19
  56. package/dist/wirings/mcp/mcp.types.d.ts +6 -0
  57. package/dist/wirings/queue/queue-runner.d.ts +3 -3
  58. package/dist/wirings/queue/queue-runner.js +7 -14
  59. package/dist/wirings/queue/queue.types.d.ts +4 -13
  60. package/dist/wirings/rpc/index.d.ts +1 -1
  61. package/dist/wirings/rpc/index.js +1 -1
  62. package/dist/wirings/rpc/rpc-runner.d.ts +13 -7
  63. package/dist/wirings/rpc/rpc-runner.js +50 -26
  64. package/dist/wirings/rpc/rpc-types.d.ts +1 -0
  65. package/dist/wirings/scheduler/scheduler-runner.d.ts +3 -3
  66. package/dist/wirings/scheduler/scheduler-runner.js +13 -24
  67. package/dist/wirings/scheduler/scheduler.types.d.ts +3 -8
  68. package/dist/wirings/workflow/index.d.ts +15 -0
  69. package/dist/wirings/workflow/index.js +14 -0
  70. package/dist/wirings/workflow/pikku-workflow-service.d.ts +160 -0
  71. package/dist/wirings/workflow/pikku-workflow-service.js +430 -0
  72. package/dist/wirings/workflow/workflow-runner.d.ts +34 -0
  73. package/dist/wirings/workflow/workflow-runner.js +65 -0
  74. package/dist/wirings/workflow/workflow.types.d.ts +332 -0
  75. package/dist/wirings/workflow/workflow.types.js +1 -0
  76. package/package.json +3 -4
  77. package/src/errors/errors.ts +3 -3
  78. package/src/factory-functions.test.ts +9 -36
  79. package/src/function/function-runner.test.ts +57 -68
  80. package/src/function/function-runner.ts +51 -28
  81. package/src/function/function-runner.ts.bak +188 -0
  82. package/src/function/functions.types.ts +69 -51
  83. package/src/handle-error.ts +1 -1
  84. package/src/index.ts +2 -0
  85. package/src/middleware/auth-apikey.test.ts +360 -0
  86. package/src/middleware/auth-apikey.ts +3 -7
  87. package/src/middleware/auth-bearer.test.ts +447 -0
  88. package/src/middleware/auth-bearer.ts +27 -33
  89. package/src/middleware/auth-cookie.test.ts +525 -0
  90. package/src/middleware/auth-cookie.ts +6 -6
  91. package/src/middleware/timeout.ts +4 -2
  92. package/src/middleware-runner.test.ts +58 -127
  93. package/src/middleware-runner.ts +13 -15
  94. package/src/permissions.test.ts +41 -45
  95. package/src/permissions.ts +21 -20
  96. package/src/pikku-state.ts +14 -2
  97. package/src/services/index.ts +1 -0
  98. package/src/services/scheduler-service.ts +76 -0
  99. package/src/services/user-session-service.ts +14 -4
  100. package/src/time-utils.ts +75 -0
  101. package/src/types/core.types.ts +92 -51
  102. package/src/utils.ts +4 -4
  103. package/src/wirings/channel/channel-common.ts +4 -7
  104. package/src/wirings/channel/channel-handler.ts +16 -22
  105. package/src/wirings/channel/channel-runner.ts +2 -2
  106. package/src/wirings/channel/channel.types.ts +14 -11
  107. package/src/wirings/channel/local/local-channel-runner.test.ts +5 -3
  108. package/src/wirings/channel/local/local-channel-runner.ts +23 -38
  109. package/src/wirings/channel/serverless/serverless-channel-runner.ts +51 -77
  110. package/src/wirings/cli/channel/cli-channel-runner.ts +1 -1
  111. package/src/wirings/cli/cli-runner.test.ts +7 -7
  112. package/src/wirings/cli/cli-runner.ts +25 -63
  113. package/src/wirings/cli/cli.types.ts +22 -16
  114. package/src/wirings/http/http-runner.test.ts +8 -8
  115. package/src/wirings/http/http-runner.ts +29 -56
  116. package/src/wirings/http/http.types.ts +5 -14
  117. package/src/wirings/mcp/mcp-runner.ts +23 -48
  118. package/src/wirings/mcp/mcp.types.ts +6 -0
  119. package/src/wirings/queue/queue-runner.test.ts +641 -0
  120. package/src/wirings/queue/queue-runner.ts +10 -24
  121. package/src/wirings/queue/queue.types.ts +5 -15
  122. package/src/wirings/rpc/index.ts +1 -1
  123. package/src/wirings/rpc/rpc-runner.ts +71 -32
  124. package/src/wirings/rpc/rpc-types.ts +1 -0
  125. package/src/wirings/scheduler/scheduler-runner.test.ts +627 -0
  126. package/src/wirings/scheduler/scheduler-runner.ts +24 -50
  127. package/src/wirings/scheduler/scheduler.types.ts +3 -9
  128. package/src/wirings/workflow/index.ts +43 -0
  129. package/src/wirings/workflow/pikku-workflow-service.ts +746 -0
  130. package/src/wirings/workflow/workflow-runner.ts +72 -0
  131. package/src/wirings/workflow/workflow.types.ts +422 -0
  132. package/tsconfig.tsbuildinfo +1 -1
  133. package/dist/services/file-channel-store.d.ts +0 -19
  134. package/dist/services/file-channel-store.js +0 -69
  135. package/dist/services/file-eventhub-store.d.ts +0 -17
  136. package/dist/services/file-eventhub-store.js +0 -71
  137. package/src/services/file-channel-store.ts +0 -86
  138. package/src/services/file-eventhub-store.ts +0 -90
@@ -0,0 +1,746 @@
1
+ import { runPikkuFunc } from '../../function/function-runner.js'
2
+ import { pikkuState } from '../../pikku-state.js'
3
+ import { getDurationInMilliseconds } from '../../time-utils.js'
4
+ import {
5
+ CoreConfig,
6
+ CoreSingletonServices,
7
+ CreateWireServices,
8
+ PikkuWire,
9
+ SerializedError,
10
+ } from '../../types/core.types.js'
11
+ import { QueueService } from '../queue/queue.types.js'
12
+ import {
13
+ WorkflowAsyncException,
14
+ WorkflowCancelledException,
15
+ WorkflowNotFoundError,
16
+ WorkflowRunNotFound,
17
+ } from './workflow-runner.js'
18
+ import type {
19
+ PikkuWorkflowWire,
20
+ StepState,
21
+ WorkflowRun,
22
+ WorkflowStatus,
23
+ WorkflowService,
24
+ WorkflowServiceConfig,
25
+ WorkflowStepOptions,
26
+ } from './workflow.types.js'
27
+
28
+ export class WorkflowServiceNotInitialized extends Error {}
29
+ export class WorkflowStepNameNotString extends Error {
30
+ constructor(stepName: any) {
31
+ super(`Workflow step name must be a string. Received: ${typeof stepName}`)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Abstract workflow state service
37
+ * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
38
+ * Combines orchestration and step execution
39
+ */
40
+ export abstract class PikkuWorkflowService implements WorkflowService {
41
+ private config: WorkflowServiceConfig | undefined
42
+ private singletonServices: CoreSingletonServices | undefined
43
+ private createWireServices: CreateWireServices | undefined
44
+
45
+ constructor() {}
46
+
47
+ public setServices(
48
+ singletonServices: CoreSingletonServices,
49
+ createWireServices: CreateWireServices,
50
+ { workflow }: CoreConfig
51
+ ) {
52
+ this.singletonServices = singletonServices
53
+ this.createWireServices = createWireServices
54
+ this.config = {
55
+ retries: workflow?.retries ?? 0,
56
+ retryDelay: workflow?.retryDelay ?? 0,
57
+ orchestratorQueueName:
58
+ workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
59
+ stepWorkerQueueName:
60
+ workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
61
+ sleeperRPCName: workflow?.sleeperRPCName ?? 'pikkuWorkflowStepSleeper',
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Create a new workflow run
67
+ * @param name - Workflow name
68
+ * @param input - Input data for the workflow
69
+ * @returns Run ID
70
+ */
71
+ abstract createRun(workflowName: string, input: any): Promise<string>
72
+
73
+ /**
74
+ * Get a workflow run by ID
75
+ * @param id - Run ID
76
+ * @returns Workflow run or null if not found
77
+ */
78
+ abstract getRun(id: string): Promise<WorkflowRun | null>
79
+
80
+ /**
81
+ * Get workflow run history (all step attempts in chronological order)
82
+ * @param runId - Run ID
83
+ * @returns Array of step states with step names, ordered oldest to newest
84
+ */
85
+ abstract getRunHistory(
86
+ runId: string
87
+ ): Promise<Array<StepState & { stepName: string }>>
88
+
89
+ /**
90
+ * Update workflow run status
91
+ * @param id - Run ID
92
+ * @param status - New status
93
+ */
94
+ abstract updateRunStatus(
95
+ id: string,
96
+ status: WorkflowStatus,
97
+ output?: any,
98
+ error?: SerializedError
99
+ ): Promise<void>
100
+
101
+ /**
102
+ * Insert initial step state (called by orchestrator)
103
+ * Creates pending step in both workflow_step and workflow_step_history
104
+ * @param runId - Run ID
105
+ * @param stepName - Step cache key
106
+ * @param rpcName - RPC function name
107
+ * @param data - Step input data
108
+ * @param stepOptions - Step options (retries, retryDelay)
109
+ * @returns Step state with generated stepId
110
+ */
111
+ abstract insertStepState(
112
+ runId: string,
113
+ stepName: string,
114
+ rpcName: string | null,
115
+ data: any,
116
+ stepOptions?: WorkflowStepOptions
117
+ ): Promise<StepState>
118
+
119
+ /**
120
+ * Get step state by cache key (read-only)
121
+ * @param runId - Run ID
122
+ * @param stepName - Step cache key (from workflow.do)
123
+ * @returns Step state with attemptCount calculated from history
124
+ */
125
+ abstract getStepState(runId: string, stepName: string): Promise<StepState>
126
+
127
+ /**
128
+ * Mark step as running
129
+ * Updates both workflow_step and workflow_step_history
130
+ * @param stepId - Step ID
131
+ */
132
+ abstract setStepRunning(stepId: string): Promise<void>
133
+
134
+ /**
135
+ * Mark step as scheduled (queued for execution)
136
+ * Updates both workflow_step and workflow_step_history
137
+ * @param stepId - Step ID
138
+ */
139
+ abstract setStepScheduled(stepId: string): Promise<void>
140
+
141
+ /**
142
+ * Store step result and mark as succeeded
143
+ * Updates both workflow_step and workflow_step_history
144
+ * @param stepId - Step ID
145
+ * @param result - Step result
146
+ */
147
+ abstract setStepResult(stepId: string, result: any): Promise<void>
148
+
149
+ /**
150
+ * Store step error and mark as failed
151
+ * Updates both workflow_step and workflow_step_history
152
+ * @param stepId - Step ID
153
+ * @param error - Error object
154
+ */
155
+ abstract setStepError(stepId: string, error: Error): Promise<void>
156
+
157
+ /**
158
+ * Create a new retry attempt for a failed step
159
+ * Inserts new pending step in both workflow_step and workflow_step_history
160
+ * Resets status to 'pending' with new stepId
161
+ * Copies metadata (rpcName, data, retries, retryDelay) from failed attempt
162
+ * @param failedStepId - Failed step ID to copy from
163
+ * @returns New step state for the retry attempt
164
+ */
165
+ abstract createRetryAttempt(
166
+ failedStepId: string,
167
+ status: 'pending' | 'running'
168
+ ): Promise<StepState>
169
+
170
+ /**
171
+ * Execute function within a run lock to prevent concurrent modifications
172
+ * @param id - Run ID
173
+ * @param fn - Function to execute
174
+ * @returns Function result
175
+ */
176
+ abstract withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>
177
+
178
+ /**
179
+ * Execute function within a step lock to prevent concurrent step execution
180
+ * @param runId - Run ID
181
+ * @param stepName - Step name
182
+ * @param fn - Function to execute
183
+ * @returns Function result
184
+ */
185
+ abstract withStepLock<T>(
186
+ runId: string,
187
+ stepName: string,
188
+ fn: () => Promise<T>
189
+ ): Promise<T>
190
+
191
+ /**
192
+ * Close any open connections
193
+ */
194
+ abstract close(): Promise<void>
195
+
196
+ // ============================================================================
197
+ // Workflow Lifecycle Methods
198
+ // ============================================================================
199
+
200
+ /**
201
+ * Resume a paused workflow by triggering the orchestrator
202
+ * @param runId - Run ID
203
+ */
204
+ public async resumeWorkflow(runId: string): Promise<void> {
205
+ const queueService = this.verifyQueueService()
206
+ await queueService.add(this.getConfig().orchestratorQueueName, { runId })
207
+ }
208
+
209
+ /**
210
+ * Execute a workflow sleep step completion
211
+ * Sets the step result to null and resumes the workflow
212
+ * @param data - Sleeper input data
213
+ */
214
+ public async executeWorkflowSleep(
215
+ runId: string,
216
+ stepId: string
217
+ ): Promise<void> {
218
+ await this.setStepResult(stepId, null)
219
+ await this.resumeWorkflow(runId)
220
+ }
221
+
222
+ /**
223
+ * Schedule orchestrator retry with delay
224
+ * @param runId - Run ID
225
+ * @param retryDelay - Delay in milliseconds or duration string (optional)
226
+ */
227
+ protected async scheduleOrchestratorRetry(
228
+ runId: string,
229
+ retryDelay?: number | string
230
+ ): Promise<void> {
231
+ const queueService = this.verifyQueueService()
232
+ await queueService.add(
233
+ this.getConfig().orchestratorQueueName,
234
+ { runId },
235
+ retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined
236
+ )
237
+ }
238
+
239
+ /**
240
+ * Start a new workflow run
241
+ */
242
+ public async startWorkflow<I>(
243
+ name: string,
244
+ input: I,
245
+ rpcService: any
246
+ ): Promise<{ runId: string }> {
247
+ const registrations = pikkuState('workflows', 'registrations')
248
+ const workflow = registrations.get(name)
249
+
250
+ if (!workflow) {
251
+ throw new WorkflowNotFoundError(name)
252
+ }
253
+
254
+ // Create workflow run in state
255
+ const runId = await this.createRun(name, input)
256
+
257
+ // If queue service is available, use remote execution (queue-based)
258
+ // Otherwise, execute directly (inline/synchronous)
259
+ if (this.singletonServices?.queueService) {
260
+ // Queue orchestrator to start the workflow
261
+ await this.resumeWorkflow(runId)
262
+ } else {
263
+ // No queue service - execute directly via runWorkflowJob
264
+ await this.runWorkflowJob(runId, rpcService)
265
+ }
266
+
267
+ return { runId }
268
+ }
269
+
270
+ public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
271
+ const run = await this.getRun(runId)
272
+ if (!run) {
273
+ throw new WorkflowRunNotFound(runId)
274
+ }
275
+
276
+ const registrations = pikkuState('workflows', 'registrations')
277
+ const workflow = registrations.get(run.workflow)
278
+ if (!workflow) {
279
+ throw new WorkflowNotFoundError(run.workflow)
280
+ }
281
+
282
+ await this.withRunLock(runId, async () => {
283
+ const meta = pikkuState('workflows', 'meta')
284
+ const workflowMeta = meta[run.workflow]
285
+
286
+ const workflowWire = this.createWorkflowWire(
287
+ run.workflow,
288
+ runId,
289
+ rpcService
290
+ )
291
+ const wire: PikkuWire = { workflow: workflowWire }
292
+ try {
293
+ const result = await runPikkuFunc(
294
+ 'workflow',
295
+ workflowMeta.workflowName,
296
+ workflowMeta.pikkuFuncName,
297
+ {
298
+ singletonServices: this.singletonServices!,
299
+ wire,
300
+ createWireServices: this.createWireServices,
301
+ data: () => run.input,
302
+ }
303
+ )
304
+
305
+ await this.updateRunStatus(runId, 'completed', result)
306
+ } catch (error: any) {
307
+ if (error instanceof WorkflowAsyncException) {
308
+ // Normal - workflow paused for step execution
309
+ throw error
310
+ }
311
+
312
+ // Check if it's a WorkflowCancelledException
313
+ if (error instanceof WorkflowCancelledException) {
314
+ // Workflow was cancelled - status already updated, just rethrow
315
+ throw error
316
+ }
317
+
318
+ await this.updateRunStatus(runId, 'failed', undefined, {
319
+ message: error.message,
320
+ stack: error.stack,
321
+ code: error.code,
322
+ })
323
+
324
+ throw error
325
+ }
326
+ })
327
+ }
328
+
329
+ /**
330
+ * Execute a single workflow step (called by worker)
331
+ * Handles idempotency, RPC execution, result storage, retry logic, and orchestrator triggering
332
+ */
333
+ public async executeWorkflowStep(
334
+ runId: string,
335
+ stepName: string,
336
+ rpcName: string,
337
+ data: any,
338
+ rpcService: any
339
+ ): Promise<void> {
340
+ // Use step-level lock to prevent concurrent execution of same step
341
+ await this.withStepLock(runId, stepName, async () => {
342
+ // Get step state
343
+ let stepState = await this.getStepState(runId, stepName)
344
+
345
+ // Idempotency - if already succeeded, resume orchestrator and return
346
+ if (stepState.status === 'succeeded') {
347
+ await this.resumeWorkflow(runId)
348
+ return
349
+ }
350
+
351
+ // Log warning if already running (race condition)
352
+ if (stepState.status === 'running') {
353
+ return
354
+ }
355
+
356
+ // If status is 'failed', this is a retry - create new attempt history
357
+ if (stepState.status === 'failed') {
358
+ stepState = await this.createRetryAttempt(stepState.stepId, 'running')
359
+ }
360
+
361
+ if (stepState.status !== 'pending') {
362
+ // Mark step as running (pending or failed status)
363
+ await this.setStepRunning(stepState.stepId)
364
+ }
365
+
366
+ try {
367
+ // Execute RPC with workflow step context
368
+ const result = await rpcService.rpcWithWire(rpcName, data, {
369
+ workflowStep: {
370
+ runId,
371
+ stepId: stepState.stepId,
372
+ attemptCount: stepState.attemptCount,
373
+ },
374
+ })
375
+
376
+ // Store result and mark succeeded
377
+ await this.setStepResult(stepState.stepId, result)
378
+
379
+ // Resume orchestrator to continue workflow
380
+ try {
381
+ await this.resumeWorkflow(runId)
382
+ } catch (resumeError: any) {
383
+ throw resumeError
384
+ }
385
+ } catch (error: any) {
386
+ // Store error and mark failed
387
+ await this.setStepError(stepState.stepId, error)
388
+
389
+ const maxAttempts = (stepState.retries ?? 0) + 1
390
+ const retriesExhausted = stepState.attemptCount >= maxAttempts
391
+
392
+ if (retriesExhausted) {
393
+ // No more retries - resume orchestrator to mark workflow as failed
394
+ await this.resumeWorkflow(runId)
395
+ }
396
+
397
+ // Always throw so queue knows the job failed and can retry if needed
398
+ throw error
399
+ }
400
+ })
401
+ }
402
+
403
+ /**
404
+ * Orchestrate workflow execution (called by orchestrator)
405
+ * Runs workflow job and handles async exceptions
406
+ */
407
+ public async orchestrateWorkflow(
408
+ runId: string,
409
+ rpcService: any
410
+ ): Promise<void> {
411
+ try {
412
+ // Run workflow job (replays with caching)
413
+ await this.runWorkflowJob(runId, rpcService)
414
+ } catch (error: any) {
415
+ if (
416
+ error.name === 'WorkflowAsyncException' ||
417
+ error.name === 'WorkflowCancelledException'
418
+ ) {
419
+ return
420
+ }
421
+
422
+ await this.updateRunStatus(runId, 'failed', undefined, {
423
+ message: error.message,
424
+ stack: error.stack,
425
+ code: error.code,
426
+ })
427
+
428
+ throw error
429
+ }
430
+ }
431
+
432
+ private verifyQueueService(): QueueService {
433
+ if (!this.singletonServices?.queueService) {
434
+ throw new Error(
435
+ 'QueueService not configured. Remote workflows require a queue service.'
436
+ )
437
+ }
438
+
439
+ return this.singletonServices!.queueService
440
+ }
441
+
442
+ private async rpcStep(
443
+ runId: string,
444
+ stepName: string,
445
+ rpcName: string,
446
+ data: any,
447
+ rpcService: any,
448
+ stepOptions?: WorkflowStepOptions
449
+ ): Promise<any> {
450
+ // Check if step already exists
451
+ let stepState: StepState
452
+ try {
453
+ stepState = await this.getStepState(runId, stepName)
454
+ } catch (error: any) {
455
+ // Step doesn't exist - create it
456
+ stepState = await this.insertStepState(
457
+ runId,
458
+ stepName,
459
+ rpcName,
460
+ data,
461
+ stepOptions
462
+ )
463
+ }
464
+
465
+ if (stepState.status === 'succeeded') {
466
+ // Return cached result
467
+ return stepState.result
468
+ }
469
+
470
+ if (stepState.status === 'failed') {
471
+ // Step failed with retries exhausted - throw error to fail the workflow
472
+ const error = new Error(
473
+ stepState.error?.message ||
474
+ `Step '${stepName}' failed after exhausting all retries`
475
+ )
476
+ // Preserve original error properties if available
477
+ if (stepState.error) {
478
+ Object.assign(error, stepState.error)
479
+ }
480
+ throw error
481
+ }
482
+
483
+ if (stepState.status === 'scheduled') {
484
+ // Step is already scheduled, pause workflow
485
+ throw new WorkflowAsyncException(runId, stepName)
486
+ }
487
+
488
+ // Step is pending - schedule it
489
+ await this.setStepScheduled(stepState.stepId)
490
+
491
+ // Enqueue step worker
492
+ if (this.singletonServices!.queueService) {
493
+ // Map step retry options to queue job options
494
+ const retries = stepOptions?.retries ?? 0
495
+ const retryDelay = stepOptions?.retryDelay
496
+
497
+ await this.singletonServices!.queueService.add(
498
+ this.getConfig().stepWorkerQueueName,
499
+ {
500
+ runId,
501
+ stepName,
502
+ rpcName,
503
+ data,
504
+ },
505
+ {
506
+ // attempts includes initial attempt, retries doesn't
507
+ attempts: retries + 1,
508
+ // Map retry delay to backoff
509
+ backoff:
510
+ typeof retryDelay === 'number'
511
+ ? { type: 'fixed', delay: retryDelay }
512
+ : retryDelay === 'exponential'
513
+ ? 'exponential'
514
+ : undefined,
515
+ }
516
+ )
517
+ } else {
518
+ // No queue service - execute locally
519
+ const retries = stepOptions?.retries ?? 0
520
+ const retryDelay = stepOptions?.retryDelay
521
+ const attemptCount = stepState.attemptCount
522
+
523
+ try {
524
+ const result = await rpcService.rpcWithWire(rpcName, data, {
525
+ workflowStep: {
526
+ runId,
527
+ stepId: stepState.stepId,
528
+ attemptCount: stepState.attemptCount,
529
+ },
530
+ })
531
+ await this.setStepResult(stepState.stepId, result)
532
+ return result
533
+ } catch (error: any) {
534
+ // Record the error (marks step as failed)
535
+ await this.setStepError(stepState.stepId, error)
536
+
537
+ // Check if we should retry
538
+ if (attemptCount < retries) {
539
+ // Create a new pending retry attempt (copies metadata from failed step)
540
+ await this.createRetryAttempt(stepState.stepId, 'pending')
541
+
542
+ // Schedule orchestrator to retry after delay
543
+ await this.scheduleOrchestratorRetry(runId, retryDelay)
544
+
545
+ // Pause workflow - orchestrator will replay and pick up new attempt
546
+ throw new WorkflowAsyncException(runId, stepName)
547
+ }
548
+
549
+ // No more retries, fail the workflow
550
+ throw error
551
+ }
552
+ }
553
+
554
+ // Pause workflow - step will callback when done
555
+ throw new WorkflowAsyncException(runId, stepName)
556
+ }
557
+
558
+ private async inlineStep(
559
+ runId: string,
560
+ stepName: string,
561
+ fn: Function,
562
+ stepOptions?: WorkflowStepOptions
563
+ ): Promise<any> {
564
+ // Check if step already exists
565
+ let stepState: StepState
566
+ try {
567
+ stepState = await this.getStepState(runId, stepName)
568
+ } catch (error: any) {
569
+ // Step doesn't exist - create it (inline, no RPC)
570
+ stepState = await this.insertStepState(
571
+ runId,
572
+ stepName,
573
+ null, // No RPC for inline steps
574
+ null, // No data for inline steps
575
+ stepOptions
576
+ )
577
+ }
578
+
579
+ if (stepState.status === 'succeeded') {
580
+ // Return cached result
581
+ return stepState.result
582
+ }
583
+
584
+ // Execute inline function
585
+ const retries = stepOptions?.retries ?? this.getConfig().retries
586
+ const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
587
+ const attemptCount = stepState.attemptCount
588
+
589
+ try {
590
+ const result = await fn()
591
+ await this.setStepResult(stepState.stepId, result)
592
+ return result
593
+ } catch (error: any) {
594
+ // Record the error (marks step as failed)
595
+ await this.setStepError(stepState.stepId, error)
596
+
597
+ // Check if we should retry
598
+ if (attemptCount < retries) {
599
+ // Create a new pending retry attempt (copies metadata from failed step)
600
+ await this.createRetryAttempt(stepState.stepId, 'pending')
601
+
602
+ // Schedule orchestrator to retry after delay
603
+ await this.scheduleOrchestratorRetry(runId, retryDelay)
604
+
605
+ // Pause workflow - orchestrator will replay and pick up new attempt
606
+ throw new WorkflowAsyncException(runId, stepName)
607
+ }
608
+
609
+ // No more retries, fail the workflow
610
+ throw error
611
+ }
612
+ }
613
+
614
+ private async sleepStep(runId: string, stepName: string, duration: number) {
615
+ // Check if step already exists
616
+ let stepState: StepState
617
+ try {
618
+ stepState = await this.getStepState(runId, stepName)
619
+ } catch (error: any) {
620
+ // Step doesn't exist - create it (sleep step, no RPC)
621
+ stepState = await this.insertStepState(
622
+ runId,
623
+ stepName,
624
+ null, // No RPC for sleep steps
625
+ { duration }, // Store duration as data
626
+ undefined // No retry options for sleep
627
+ )
628
+ }
629
+
630
+ if (stepState.status === 'succeeded') {
631
+ // Sleep already completed, return immediately
632
+ return
633
+ }
634
+
635
+ if (stepState.status === 'scheduled') {
636
+ // Sleep is already scheduled, pause workflow
637
+ throw new WorkflowAsyncException(runId, stepName)
638
+ }
639
+
640
+ // Step is pending - schedule it
641
+ await this.setStepScheduled(stepState.stepId)
642
+
643
+ // Check if we have queue service (remote mode) or inline mode
644
+ if (this.singletonServices!.schedulerService) {
645
+ // Remote mode - enqueue sleep worker with delay
646
+ await this.singletonServices!.schedulerService.scheduleRPC(
647
+ duration,
648
+ this.getConfig().sleeperRPCName,
649
+ {
650
+ runId,
651
+ stepId: stepState.stepId,
652
+ }
653
+ )
654
+ } else {
655
+ // Inline mode - use setTimeout with actual duration
656
+ await new Promise((resolve) =>
657
+ setTimeout(resolve, getDurationInMilliseconds(duration))
658
+ )
659
+ await this.setStepResult(stepState.stepId, null)
660
+ return
661
+ }
662
+
663
+ // Pause workflow - sleep will callback when done
664
+ throw new WorkflowAsyncException(runId, stepName)
665
+ }
666
+
667
+ private async cancelWorkflow(runId: string, reason?: string) {
668
+ // Update workflow run status to cancelled
669
+ await this.updateRunStatus(runId, 'cancelled', undefined, {
670
+ message: reason || 'Workflow cancelled by user',
671
+ stack: '',
672
+ code: 'WORKFLOW_CANCELLED',
673
+ })
674
+
675
+ // Throw cancellation exception to stop workflow execution
676
+ throw new WorkflowCancelledException(runId, reason)
677
+ }
678
+
679
+ private createWorkflowWire(
680
+ workflowName: string,
681
+ runId: string,
682
+ rpcService: any
683
+ ): PikkuWorkflowWire {
684
+ const workflowWire: PikkuWorkflowWire = {
685
+ workflowName,
686
+ runId,
687
+ getRun: async () => (await this.getRun(runId)) as WorkflowRun,
688
+
689
+ // Implement workflow.do() - RPC form
690
+ do: async (
691
+ stepName: string,
692
+ rpcNameOrFn: any,
693
+ dataOrOptions?: any,
694
+ options?: any
695
+ ) => {
696
+ this.verifyStepName(stepName)
697
+ if (typeof rpcNameOrFn === 'string') {
698
+ return await this.rpcStep(
699
+ runId,
700
+ stepName,
701
+ rpcNameOrFn,
702
+ dataOrOptions,
703
+ rpcService,
704
+ options
705
+ )
706
+ } else {
707
+ return await this.inlineStep(
708
+ runId,
709
+ stepName,
710
+ rpcNameOrFn,
711
+ dataOrOptions
712
+ )
713
+ }
714
+ },
715
+
716
+ // Implement workflow.sleep()
717
+ sleep: async (stepName: string, duration: string | number) => {
718
+ this.verifyStepName(stepName)
719
+ await this.sleepStep(
720
+ runId,
721
+ stepName,
722
+ getDurationInMilliseconds(duration)
723
+ )
724
+ },
725
+
726
+ // Implement workflow.cancel()
727
+ cancel: async (reason?: string): Promise<void> => {
728
+ await this.cancelWorkflow(runId, reason)
729
+ },
730
+ }
731
+ return workflowWire
732
+ }
733
+
734
+ private verifyStepName(stepName: string) {
735
+ if (typeof stepName !== 'string') {
736
+ throw new WorkflowStepNameNotString(stepName)
737
+ }
738
+ }
739
+
740
+ private getConfig() {
741
+ if (!this.singletonServices || !this.config) {
742
+ throw new WorkflowServiceNotInitialized()
743
+ }
744
+ return this.config
745
+ }
746
+ }