@pikku/core 0.10.2 → 0.11.0

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