@jagreehal/workflow 1.4.0 → 1.5.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.
package/docs/advanced.md CHANGED
@@ -232,3 +232,898 @@ const result = await run.strict<User, AppError>(
232
232
  ```
233
233
 
234
234
  Prefer `createWorkflow` for automatic error type inference.
235
+
236
+ ## Circuit Breaker
237
+
238
+ Prevent cascading failures by tracking step failure rates and short-circuiting calls when a threshold is exceeded:
239
+
240
+ ```typescript
241
+ import {
242
+ createCircuitBreaker,
243
+ isCircuitOpenError,
244
+ circuitBreakerPresets,
245
+ ok, // Import ok for Result-returning operations
246
+ } from '@jagreehal/workflow';
247
+
248
+ // Create a circuit breaker with custom config (name is required)
249
+ const breaker = createCircuitBreaker('external-api', {
250
+ failureThreshold: 5, // Open after 5 failures
251
+ resetTimeout: 30000, // Try again after 30 seconds
252
+ halfOpenMax: 3, // Allow 3 test requests in half-open state
253
+ windowSize: 60000, // Count failures within this window (1 minute)
254
+ });
255
+
256
+ // Or use a preset
257
+ const criticalBreaker = createCircuitBreaker('critical-service', circuitBreakerPresets.critical);
258
+ const lenientBreaker = createCircuitBreaker('lenient-service', circuitBreakerPresets.lenient);
259
+
260
+ // Option 1: execute() throws CircuitOpenError if circuit is open
261
+ try {
262
+ const data = await breaker.execute(async () => {
263
+ return await fetchFromExternalApi();
264
+ });
265
+ console.log('Got data:', data);
266
+ } catch (error) {
267
+ if (isCircuitOpenError(error)) {
268
+ console.log(`Circuit is open, retry after ${error.retryAfterMs}ms`);
269
+ } else {
270
+ console.log('Operation failed:', error);
271
+ }
272
+ }
273
+
274
+ // Option 2: executeResult() returns a Result instead of throwing
275
+ const result = await breaker.executeResult(async () => {
276
+ // Your Result-returning operation
277
+ return ok(await fetchFromExternalApi());
278
+ });
279
+
280
+ if (!result.ok) {
281
+ if (isCircuitOpenError(result.error)) {
282
+ console.log('Circuit is open, try again later');
283
+ }
284
+ }
285
+
286
+ // Check circuit state (no arguments needed)
287
+ const stats = breaker.getStats();
288
+ console.log(stats.state); // 'CLOSED' | 'OPEN' | 'HALF_OPEN'
289
+ console.log(stats.failureCount);
290
+ console.log(stats.successCount);
291
+ console.log(stats.halfOpenSuccesses);
292
+ ```
293
+
294
+ ## Saga / Compensation Pattern
295
+
296
+ Define compensating actions for steps that need rollback on downstream failures:
297
+
298
+ ```typescript
299
+ import { createSagaWorkflow, isSagaCompensationError } from '@jagreehal/workflow';
300
+
301
+ // Create saga with deps (like createWorkflow) - error types inferred automatically
302
+ const checkoutSaga = createSagaWorkflow(
303
+ { reserveInventory, chargeCard, sendConfirmation },
304
+ { onEvent: (event) => console.log(event) }
305
+ );
306
+
307
+ const result = await checkoutSaga(async (saga, deps) => {
308
+ // Reserve inventory with compensation
309
+ const reservation = await saga.step(
310
+ () => deps.reserveInventory(items),
311
+ {
312
+ name: 'reserve-inventory',
313
+ compensate: (res) => releaseInventory(res.reservationId),
314
+ }
315
+ );
316
+
317
+ // Charge card with compensation
318
+ const payment = await saga.step(
319
+ () => deps.chargeCard(amount),
320
+ {
321
+ name: 'charge-card',
322
+ compensate: (p) => refundPayment(p.transactionId),
323
+ }
324
+ );
325
+
326
+ // If sendConfirmation fails, compensations run in reverse order:
327
+ // 1. refundPayment(payment.transactionId)
328
+ // 2. releaseInventory(reservation.reservationId)
329
+ await saga.step(
330
+ () => deps.sendConfirmation(email),
331
+ { name: 'send-confirmation' }
332
+ );
333
+
334
+ return { reservation, payment };
335
+ });
336
+
337
+ // Check for compensation errors
338
+ if (!result.ok && isSagaCompensationError(result.error)) {
339
+ console.log('Saga failed, compensations may have partially succeeded');
340
+ console.log(result.error.compensationErrors);
341
+ }
342
+ ```
343
+
344
+ ### Low-level runSaga
345
+
346
+ For explicit error typing without deps-based inference:
347
+
348
+ ```typescript
349
+ import { runSaga } from '@jagreehal/workflow';
350
+
351
+ const result = await runSaga<CheckoutResult, CheckoutError>(async (saga) => {
352
+ const reservation = await saga.step(
353
+ () => reserveInventory(items),
354
+ { compensate: (res) => releaseInventory(res.id) }
355
+ );
356
+
357
+ // tryStep for catching throws
358
+ const payment = await saga.tryStep(
359
+ () => externalPaymentApi.charge(amount),
360
+ {
361
+ error: 'PAYMENT_FAILED' as const,
362
+ compensate: (p) => externalPaymentApi.refund(p.txId),
363
+ }
364
+ );
365
+
366
+ return { reservation, payment };
367
+ });
368
+ ```
369
+
370
+ ## Rate Limiting / Concurrency Control
371
+
372
+ Control throughput for steps that hit rate-limited APIs:
373
+
374
+ ```typescript
375
+ import {
376
+ createRateLimiter,
377
+ createConcurrencyLimiter,
378
+ createCombinedLimiter,
379
+ rateLimiterPresets,
380
+ } from '@jagreehal/workflow';
381
+
382
+ // Token bucket rate limiter (requires name and config)
383
+ const rateLimiter = createRateLimiter('api-calls', {
384
+ maxPerSecond: 10, // Maximum operations per second
385
+ burstCapacity: 20, // Allow brief spikes (default: maxPerSecond * 2)
386
+ strategy: 'wait', // 'wait' (default) or 'reject'
387
+ });
388
+
389
+ // Concurrency limiter
390
+ const concurrencyLimiter = createConcurrencyLimiter('db-pool', {
391
+ maxConcurrent: 5, // Max 5 concurrent operations
392
+ maxQueueSize: 100, // Queue up to 100 waiting requests
393
+ strategy: 'queue', // 'queue' (default) or 'reject'
394
+ });
395
+
396
+ // Use presets for common scenarios
397
+ const apiLimiter = createRateLimiter('external-api', rateLimiterPresets.api);
398
+ // rateLimiterPresets.api: { maxPerSecond: 10, burstCapacity: 20, strategy: 'wait' }
399
+ // rateLimiterPresets.external: { maxPerSecond: 5, burstCapacity: 10, strategy: 'wait' }
400
+ // rateLimiterPresets.database: for ConcurrencyLimiter - { maxConcurrent: 10, strategy: 'queue', maxQueueSize: 100 }
401
+
402
+ // Wrap operations with execute() method
403
+ const data = await rateLimiter.execute(async () => {
404
+ return await callExternalApi();
405
+ });
406
+
407
+ // For Result-returning operations
408
+ const result = await rateLimiter.executeResult(async () => {
409
+ return ok(await callExternalApi());
410
+ });
411
+
412
+ // Use with batch operations
413
+ const results = await concurrencyLimiter.executeAll(
414
+ ids.map(id => async () => fetchItem(id))
415
+ );
416
+
417
+ // Combined limiter (both rate and concurrency)
418
+ const combined = createCombinedLimiter('api', {
419
+ rate: { maxPerSecond: 10 },
420
+ concurrency: { maxConcurrent: 3 },
421
+ });
422
+
423
+ const data = await combined.execute(async () => callApi());
424
+
425
+ // Get limiter statistics
426
+ const stats = rateLimiter.getStats();
427
+ console.log(stats.availableTokens); // Current available tokens
428
+ console.log(stats.waitingCount); // Requests waiting for tokens
429
+ ```
430
+
431
+ ## Workflow Versioning and Migration
432
+
433
+ Handle schema changes when resuming workflows persisted with older step shapes:
434
+
435
+ ```typescript
436
+ import {
437
+ createVersionedStateLoader,
438
+ createVersionedState,
439
+ parseVersionedState,
440
+ stringifyVersionedState,
441
+ migrateState,
442
+ createKeyRenameMigration,
443
+ createKeyRemoveMigration,
444
+ createValueTransformMigration,
445
+ composeMigrations,
446
+ } from '@jagreehal/workflow';
447
+
448
+ // Define migrations from each version to the next
449
+ // Key is source version, migration transforms to version + 1
450
+ const migrations = {
451
+ // Migrate from v1 to v2: rename keys
452
+ 1: createKeyRenameMigration({
453
+ 'user:fetch': 'user:load',
454
+ 'order:create': 'order:submit',
455
+ }),
456
+
457
+ // Migrate from v2 to v3: multiple transformations
458
+ 2: composeMigrations([
459
+ createKeyRemoveMigration(['deprecated:step']),
460
+ createValueTransformMigration({
461
+ 'user:load': (entry) => ({
462
+ ...entry,
463
+ result: entry.result.ok
464
+ ? { ok: true, value: { ...entry.result.value, newField: 'default' } }
465
+ : entry.result,
466
+ }),
467
+ }),
468
+ ]),
469
+ };
470
+
471
+ // Create a versioned state loader
472
+ const loader = createVersionedStateLoader({
473
+ version: 3, // Current workflow version
474
+ migrations,
475
+ strictVersioning: true, // Fail if state is from newer version
476
+ });
477
+
478
+ // Load state from storage and parse it
479
+ const json = await db.loadWorkflowState(runId);
480
+ const versionedState = parseVersionedState(json);
481
+
482
+ // Migrate to current version
483
+ const result = await loader(versionedState);
484
+ if (!result.ok) {
485
+ // Handle migration error or version incompatibility
486
+ console.error(result.error);
487
+ }
488
+
489
+ // Use migrated state with workflow
490
+ const workflow = createWorkflow(deps, { resumeState: result.value });
491
+
492
+ // When saving state, create versioned state
493
+ import { createStepCollector } from '@jagreehal/workflow';
494
+
495
+ const collector = createStepCollector();
496
+ // ... run workflow with collector ...
497
+
498
+ const versionedState = createVersionedState(collector.getState(), 3);
499
+ const serialized = stringifyVersionedState(versionedState);
500
+ await db.saveWorkflowState(runId, serialized);
501
+ ```
502
+
503
+ ## Conditional Step Execution
504
+
505
+ Declarative guards for steps that should only run under certain conditions:
506
+
507
+ ```typescript
508
+ import { when, unless, whenOr, unlessOr, createConditionalHelpers } from '@jagreehal/workflow';
509
+
510
+ const result = await workflow(async (step) => {
511
+ const user = await step(() => fetchUser(id), { key: 'user' });
512
+
513
+ // Only runs if condition is true, returns undefined if skipped
514
+ const premium = await when(
515
+ user.isPremium,
516
+ () => step(() => fetchPremiumData(user.id), { key: 'premium' }),
517
+ { name: 'check-premium', reason: 'User is not premium' }
518
+ );
519
+
520
+ // Skips if condition is true, returns undefined if skipped
521
+ const trial = await unless(
522
+ user.isPremium,
523
+ () => step(() => fetchTrialLimits(user.id), { key: 'trial' }),
524
+ { name: 'check-trial', reason: 'User is premium' }
525
+ );
526
+
527
+ // With default value instead of undefined
528
+ const limits = await whenOr(
529
+ user.isPremium,
530
+ () => step(() => fetchPremiumLimits(user.id), { key: 'premium-limits' }),
531
+ { maxRequests: 100, maxStorage: 1000 }, // default for non-premium
532
+ { name: 'check-premium-limits', reason: 'Using default limits' }
533
+ );
534
+
535
+ return { user, premium, trial, limits };
536
+ });
537
+ ```
538
+
539
+ ### With Event Emission
540
+
541
+ Use `createConditionalHelpers` to emit `step_skipped` events for visualization and debugging:
542
+
543
+ ```typescript
544
+ const result = await run(async (step) => {
545
+ const ctx = { workflowId: 'my-workflow', onEvent: console.log };
546
+ const { when, whenOr } = createConditionalHelpers(ctx);
547
+
548
+ const user = await step(fetchUser(id));
549
+
550
+ // Emits step_skipped event when condition is false
551
+ const premium = await when(
552
+ user.isPremium,
553
+ () => step(() => fetchPremiumData(user.id)),
554
+ { name: 'premium-data', reason: 'User is not premium' }
555
+ );
556
+
557
+ return { user, premium };
558
+ }, { onEvent, workflowId });
559
+ ```
560
+
561
+ ## Webhook / Event Trigger Adapters
562
+
563
+ Expose workflows as HTTP endpoints or event consumers:
564
+
565
+ ```typescript
566
+ import {
567
+ createWebhookHandler,
568
+ createSimpleHandler,
569
+ createResultMapper,
570
+ createExpressHandler,
571
+ validationError,
572
+ requireFields,
573
+ } from '@jagreehal/workflow';
574
+
575
+ // Create a webhook handler for a workflow
576
+ const handler = createWebhookHandler(
577
+ checkoutWorkflow,
578
+ async (step, deps, input: CheckoutInput) => {
579
+ const charge = await step(() => deps.chargeCard(input.amount));
580
+ await step(() => deps.sendEmail(input.email, charge.receiptUrl));
581
+ return { chargeId: charge.id };
582
+ },
583
+ {
584
+ validateInput: (req) => {
585
+ const validation = requireFields(['amount', 'email'])(req.body);
586
+ if (!validation.ok) return validation;
587
+ return ok({ amount: req.body.amount, email: req.body.email });
588
+ },
589
+ mapResult: createResultMapper([
590
+ { error: 'CARD_DECLINED', status: 402, message: 'Payment failed' },
591
+ { error: 'INVALID_EMAIL', status: 400, message: 'Invalid email address' },
592
+ ]),
593
+ }
594
+ );
595
+
596
+ // Use with Express
597
+ import express from 'express';
598
+ const app = express();
599
+ app.post('/checkout', createExpressHandler(handler));
600
+
601
+ // Or manually
602
+ app.post('/checkout', async (req, res) => {
603
+ const response = await handler({
604
+ method: req.method,
605
+ path: req.path,
606
+ headers: req.headers,
607
+ body: req.body,
608
+ query: req.query,
609
+ params: req.params,
610
+ });
611
+ res.status(response.status).json(response.body);
612
+ });
613
+ ```
614
+
615
+ ### Event Triggers (for message queues)
616
+
617
+ ```typescript
618
+ import { createEventHandler } from '@jagreehal/workflow';
619
+
620
+ const handler = createEventHandler(
621
+ checkoutWorkflow,
622
+ async (step, deps, payload: CheckoutPayload) => {
623
+ const charge = await step(() => deps.chargeCard(payload.amount));
624
+ return { chargeId: charge.id };
625
+ },
626
+ {
627
+ validatePayload: (event) => {
628
+ if (!event.payload.amount) {
629
+ return err(validationError('Missing amount'));
630
+ }
631
+ return ok(event.payload);
632
+ },
633
+ mapResult: (result) => ({
634
+ success: result.ok,
635
+ ack: result.ok || !isRetryableError(result.error),
636
+ error: result.ok ? undefined : { type: String(result.error) },
637
+ }),
638
+ }
639
+ );
640
+
641
+ // Use with SQS, RabbitMQ, etc.
642
+ queue.consume(async (message) => {
643
+ const result = await handler({
644
+ id: message.id,
645
+ type: message.type,
646
+ payload: message.body,
647
+ });
648
+ if (result.ack) await message.ack();
649
+ else await message.nack();
650
+ });
651
+ ```
652
+
653
+ ## Policy-Driven Step Middleware
654
+
655
+ Reusable bundles of `StepOptions` (retry, timeout, cache keys) that can be composed and applied per-workflow or per-step:
656
+
657
+ ```typescript
658
+ import {
659
+ mergePolicies,
660
+ createPolicyApplier,
661
+ withPolicy,
662
+ withPolicies,
663
+ retryPolicies,
664
+ timeoutPolicies,
665
+ servicePolicies,
666
+ createPolicyRegistry,
667
+ stepOptions,
668
+ } from '@jagreehal/workflow';
669
+
670
+ // Use pre-built service policies
671
+ const user = await step(
672
+ () => fetchUser(id),
673
+ withPolicy(servicePolicies.httpApi, { name: 'fetch-user' })
674
+ );
675
+
676
+ // Combine multiple policies
677
+ const data = await step(
678
+ () => fetchData(),
679
+ withPolicies([timeoutPolicies.api, retryPolicies.standard], 'fetch-data')
680
+ );
681
+
682
+ // Create a policy applier for consistent defaults
683
+ const applyPolicy = createPolicyApplier(
684
+ timeoutPolicies.api,
685
+ retryPolicies.transient
686
+ );
687
+
688
+ const result = await step(
689
+ () => callApi(),
690
+ applyPolicy({ name: 'api-call', key: 'cache:api' })
691
+ );
692
+
693
+ // Use the fluent builder API
694
+ const options = stepOptions()
695
+ .name('fetch-user')
696
+ .key('user:123')
697
+ .timeout(5000)
698
+ .retries(3)
699
+ .build();
700
+
701
+ // Create a policy registry for organization-wide policies
702
+ const registry = createPolicyRegistry();
703
+ registry.register('api', servicePolicies.httpApi);
704
+ registry.register('db', servicePolicies.database);
705
+ registry.register('cache', servicePolicies.cache);
706
+
707
+ const user = await step(
708
+ () => fetchUser(id),
709
+ registry.apply('api', { name: 'fetch-user' })
710
+ );
711
+ ```
712
+
713
+ ### Available Presets
714
+
715
+ ```typescript
716
+ // Retry policies
717
+ retryPolicies.none // No retry
718
+ retryPolicies.transient // 3 attempts, fast backoff
719
+ retryPolicies.standard // 3 attempts, moderate backoff
720
+ retryPolicies.aggressive // 5 attempts, longer backoff
721
+ retryPolicies.fixed(3, 1000) // 3 attempts, 1s fixed delay
722
+ retryPolicies.linear(3, 100) // 3 attempts, linear backoff
723
+
724
+ // Timeout policies
725
+ timeoutPolicies.fast // 1 second
726
+ timeoutPolicies.api // 5 seconds
727
+ timeoutPolicies.extended // 30 seconds
728
+ timeoutPolicies.long // 2 minutes
729
+ timeoutPolicies.ms(3000) // Custom milliseconds
730
+
731
+ // Service policies (combined retry + timeout)
732
+ servicePolicies.httpApi // 5s timeout, 3 retries
733
+ servicePolicies.database // 30s timeout, 2 retries
734
+ servicePolicies.cache // 1s timeout, no retry
735
+ servicePolicies.messageQueue // 30s timeout, 5 retries
736
+ servicePolicies.fileSystem // 2min timeout, 3 retries
737
+ servicePolicies.rateLimited // 10s timeout, 5 linear retries
738
+ ```
739
+
740
+ ## Pluggable Persistence Adapters
741
+
742
+ First-class adapters for `StepCache` and `ResumeState` with JSON-safe serialization:
743
+
744
+ ```typescript
745
+ import {
746
+ createMemoryCache,
747
+ createFileCache,
748
+ createKVCache,
749
+ createStatePersistence,
750
+ createHydratingCache,
751
+ stringifyState,
752
+ parseState,
753
+ } from '@jagreehal/workflow';
754
+
755
+ // In-memory cache with TTL and LRU eviction
756
+ const cache = createMemoryCache({
757
+ maxSize: 1000, // Max entries
758
+ ttl: 60000, // 1 minute TTL
759
+ });
760
+
761
+ const workflow = createWorkflow(deps, { cache });
762
+
763
+ // File-based persistence
764
+ import * as fs from 'fs/promises';
765
+
766
+ const fileCache = createFileCache({
767
+ directory: './workflow-cache',
768
+ fs: {
769
+ readFile: (path) => fs.readFile(path, 'utf-8'),
770
+ writeFile: (path, data) => fs.writeFile(path, data, 'utf-8'),
771
+ unlink: fs.unlink,
772
+ exists: async (path) => fs.access(path).then(() => true).catch(() => false),
773
+ readdir: fs.readdir,
774
+ mkdir: fs.mkdir,
775
+ },
776
+ });
777
+
778
+ await fileCache.init();
779
+
780
+ // Redis/DynamoDB adapter
781
+ const kvCache = createKVCache({
782
+ store: {
783
+ get: (key) => redis.get(key),
784
+ set: (key, value, opts) => redis.set(key, value, { EX: opts?.ttl }),
785
+ delete: (key) => redis.del(key).then(n => n > 0),
786
+ exists: (key) => redis.exists(key).then(n => n > 0),
787
+ keys: (pattern) => redis.keys(pattern),
788
+ },
789
+ prefix: 'myapp:workflow:',
790
+ ttl: 3600, // 1 hour
791
+ });
792
+
793
+ // State persistence for workflow resumption
794
+ const persistence = createStatePersistence(kvStore, 'workflow:state:');
795
+
796
+ await persistence.save('run-123', resumeState, { userId: 'user-1' });
797
+ const loaded = await persistence.load('run-123');
798
+ const allRuns = await persistence.list();
799
+
800
+ // Hydrating cache (loads from persistent storage on first access)
801
+ const hydratingCache = createHydratingCache(
802
+ createMemoryCache(),
803
+ persistence,
804
+ 'run-123'
805
+ );
806
+ await hydratingCache.hydrate();
807
+ ```
808
+
809
+ ### JSON-safe Serialization
810
+
811
+ ```typescript
812
+ import {
813
+ serializeResult,
814
+ deserializeResult,
815
+ serializeState,
816
+ deserializeState,
817
+ stringifyState,
818
+ parseState,
819
+ } from '@jagreehal/workflow';
820
+
821
+ // Serialize Results with Error causes preserved
822
+ const serialized = serializeResult(result);
823
+ const restored = deserializeResult(serialized);
824
+
825
+ // Serialize entire workflow state
826
+ const json = stringifyState(resumeState, { userId: 'user-1' });
827
+ const state = parseState(json);
828
+ ```
829
+
830
+ ## Devtools
831
+
832
+ Developer tools for workflow debugging, visualization, and analysis:
833
+
834
+ ```typescript
835
+ import {
836
+ createDevtools,
837
+ renderDiff,
838
+ createConsoleLogger,
839
+ quickVisualize,
840
+ } from '@jagreehal/workflow';
841
+
842
+ // Create devtools instance
843
+ const devtools = createDevtools({
844
+ workflowName: 'checkout',
845
+ logEvents: true,
846
+ maxHistory: 10,
847
+ });
848
+
849
+ // Use with workflow
850
+ const workflow = createWorkflow(deps, {
851
+ onEvent: devtools.handleEvent,
852
+ });
853
+
854
+ await workflow(async (step) => {
855
+ const user = await step(() => fetchUser(id), { name: 'fetch-user' });
856
+ const charge = await step(() => chargeCard(100), { name: 'charge-card' });
857
+ return { user, charge };
858
+ });
859
+
860
+ // Render visualizations
861
+ console.log(devtools.render()); // ASCII visualization
862
+ console.log(devtools.renderMermaid()); // Mermaid diagram
863
+ console.log(devtools.renderTimeline()); // Timeline view
864
+
865
+ // Get timeline data
866
+ const timeline = devtools.getTimeline();
867
+ // [{ name: 'fetch-user', startMs: 0, endMs: 50, status: 'success' }, ...]
868
+
869
+ // Compare runs
870
+ const diff = devtools.diffWithPrevious();
871
+ if (diff) {
872
+ console.log(renderDiff(diff));
873
+ }
874
+
875
+ // Export/import runs
876
+ const json = devtools.exportRun();
877
+ devtools.importRun(json);
878
+
879
+ // Simple console logging
880
+ const workflow2 = createWorkflow(deps, {
881
+ onEvent: createConsoleLogger({ prefix: '[checkout]', colors: true }),
882
+ });
883
+ ```
884
+
885
+ ### Timeline Output Example
886
+
887
+ ```
888
+ Timeline:
889
+ ────────────────────────────────────────────────────────────────
890
+ fetch-user |██████ | 50ms
891
+ charge-card | ████████████ | 120ms
892
+ send-email | ████ | 30ms
893
+ ────────────────────────────────────────────────────────────────
894
+ ```
895
+
896
+ ## HITL Orchestration Helpers
897
+
898
+ Production-ready helpers for human-in-the-loop approval workflows:
899
+
900
+ ```typescript
901
+ import {
902
+ createHITLOrchestrator,
903
+ createMemoryApprovalStore,
904
+ createMemoryWorkflowStateStore,
905
+ createApprovalWebhookHandler,
906
+ createApprovalChecker,
907
+ } from '@jagreehal/workflow';
908
+
909
+ // Create orchestrator with stores
910
+ const orchestrator = createHITLOrchestrator({
911
+ approvalStore: createMemoryApprovalStore(),
912
+ workflowStateStore: createMemoryWorkflowStateStore(),
913
+ defaultExpirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
914
+ });
915
+
916
+ // Execute workflow that may pause for approval
917
+ // IMPORTANT: The factory must pass onEvent to createWorkflow for HITL tracking!
918
+ const result = await orchestrator.execute(
919
+ 'order-approval',
920
+ ({ resumeState, onEvent }) => createWorkflow(deps, { resumeState, onEvent }),
921
+ async (step, deps, input) => {
922
+ const order = await step(() => deps.createOrder(input));
923
+ const approval = await step(
924
+ () => deps.requireApproval(order.id),
925
+ { key: `approval:${order.id}` }
926
+ );
927
+ await step(() => deps.processOrder(order.id));
928
+ return { orderId: order.id };
929
+ },
930
+ { items: [...], total: 500 }
931
+ );
932
+
933
+ if (result.status === 'paused') {
934
+ console.log(`Workflow paused, waiting for: ${result.pendingApprovals}`);
935
+ console.log(`Run ID: ${result.runId}`);
936
+ }
937
+
938
+ // Grant approval (with optional auto-resume)
939
+ const { resumedWorkflows } = await orchestrator.grantApproval(
940
+ `approval:${orderId}`,
941
+ { approvedBy: 'manager@example.com' },
942
+ { autoResume: true }
943
+ );
944
+
945
+ // Or poll for approval
946
+ const status = await orchestrator.pollApproval(`approval:${orderId}`, {
947
+ intervalMs: 1000,
948
+ timeoutMs: 60000,
949
+ });
950
+
951
+ // Resume manually
952
+ const resumed = await orchestrator.resume(
953
+ runId,
954
+ (resumeState) => createWorkflow(deps, { resumeState }),
955
+ workflowFn
956
+ );
957
+ ```
958
+
959
+ ### Webhook Handler for Approvals
960
+
961
+ ```typescript
962
+ import { createApprovalWebhookHandler } from '@jagreehal/workflow';
963
+ import express from 'express';
964
+
965
+ const handleApproval = createApprovalWebhookHandler(approvalStore);
966
+
967
+ const app = express();
968
+ app.post('/api/approvals', async (req, res) => {
969
+ const result = await handleApproval({
970
+ key: req.body.key,
971
+ action: req.body.action, // 'approve' | 'reject' | 'cancel'
972
+ value: req.body.value,
973
+ reason: req.body.reason,
974
+ actorId: req.user.id,
975
+ });
976
+ res.json(result);
977
+ });
978
+ ```
979
+
980
+ ## Deterministic Workflow Testing Harness
981
+
982
+ Test workflows with scripted step outcomes:
983
+
984
+ ```typescript
985
+ import {
986
+ createWorkflowHarness,
987
+ createMockFn,
988
+ createTestClock,
989
+ createSnapshot,
990
+ compareSnapshots,
991
+ okOutcome,
992
+ errOutcome,
993
+ } from '@jagreehal/workflow';
994
+
995
+ // Create test harness
996
+ const harness = createWorkflowHarness(
997
+ { fetchUser, chargeCard },
998
+ { clock: createTestClock().now }
999
+ );
1000
+
1001
+ // Script step outcomes
1002
+ harness.script([
1003
+ okOutcome({ id: '1', name: 'Alice' }),
1004
+ okOutcome({ transactionId: 'tx_123' }),
1005
+ ]);
1006
+
1007
+ // Or script specific steps by name
1008
+ harness.scriptStep('fetch-user', okOutcome({ id: '1', name: 'Alice' }));
1009
+ harness.scriptStep('charge-card', errOutcome('CARD_DECLINED'));
1010
+
1011
+ // Run workflow
1012
+ const result = await harness.run(async (step, { fetchUser, chargeCard }) => {
1013
+ const user = await step(() => fetchUser('1'), 'fetch-user');
1014
+ const charge = await step(() => chargeCard(100), 'charge-card');
1015
+ return { user, charge };
1016
+ });
1017
+
1018
+ // Assert results
1019
+ expect(result.ok).toBe(false);
1020
+ expect(harness.assertSteps(['fetch-user', 'charge-card']).passed).toBe(true);
1021
+ expect(harness.assertStepCalled('fetch-user').passed).toBe(true);
1022
+ expect(harness.assertStepNotCalled('refund').passed).toBe(true);
1023
+
1024
+ // Get invocation details
1025
+ const invocations = harness.getInvocations();
1026
+ console.log(invocations[0].name); // 'fetch-user'
1027
+ console.log(invocations[0].durationMs); // 0 (deterministic clock)
1028
+ console.log(invocations[0].result); // { ok: true, value: { id: '1', name: 'Alice' } }
1029
+
1030
+ // Reset for next test
1031
+ harness.reset();
1032
+ ```
1033
+
1034
+ ### Mock Functions
1035
+
1036
+ ```typescript
1037
+ import { createMockFn, ok, err } from '@jagreehal/workflow';
1038
+
1039
+ const fetchUser = createMockFn<User, 'NOT_FOUND'>();
1040
+
1041
+ // Set default return
1042
+ fetchUser.returns(ok({ id: '1', name: 'Alice' }));
1043
+
1044
+ // Or queue return values
1045
+ fetchUser.returnsOnce(ok({ id: '1', name: 'Alice' }));
1046
+ fetchUser.returnsOnce(err('NOT_FOUND'));
1047
+
1048
+ // Check calls
1049
+ console.log(fetchUser.getCallCount()); // 2
1050
+ console.log(fetchUser.getCalls()); // [[arg1], [arg2]]
1051
+
1052
+ fetchUser.reset();
1053
+ ```
1054
+
1055
+ ### Snapshot Testing
1056
+
1057
+ ```typescript
1058
+ import { createSnapshot, compareSnapshots } from '@jagreehal/workflow';
1059
+
1060
+ // Create snapshot from a run (events are optional, from external sources)
1061
+ const snapshot1 = createSnapshot(
1062
+ harness.getInvocations(),
1063
+ result
1064
+ );
1065
+
1066
+ // Run again and compare
1067
+ harness.reset();
1068
+ harness.script([...newOutcomes]); // script() resets state automatically
1069
+ const result2 = await harness.run(workflowFn);
1070
+ const snapshot2 = createSnapshot(harness.getInvocations(), result2);
1071
+
1072
+ const { equal, differences } = compareSnapshots(snapshot1, snapshot2);
1073
+ if (!equal) {
1074
+ console.log('Differences:', differences);
1075
+ }
1076
+ ```
1077
+
1078
+ ## OpenTelemetry Integration (Autotel)
1079
+
1080
+ First-class OpenTelemetry metrics from the event stream:
1081
+
1082
+ ```typescript
1083
+ import { createAutotelAdapter, createAutotelEventHandler, withAutotelTracing } from '@jagreehal/workflow';
1084
+
1085
+ // Create an adapter that tracks metrics
1086
+ const autotel = createAutotelAdapter({
1087
+ serviceName: 'checkout-service',
1088
+ createStepSpans: true, // Create spans for each step
1089
+ recordMetrics: true, // Record step metrics
1090
+ recordRetryEvents: true, // Record retry events
1091
+ markErrorsOnSpan: true, // Mark errors on spans
1092
+ defaultAttributes: { // Custom attributes for all spans
1093
+ environment: 'production',
1094
+ },
1095
+ });
1096
+
1097
+ // Use adapter's handleEvent directly with workflow
1098
+ const workflow = createWorkflow(deps, {
1099
+ onEvent: autotel.handleEvent,
1100
+ });
1101
+
1102
+ // Access collected metrics
1103
+ const metrics = autotel.getMetrics();
1104
+ console.log(metrics.stepDurations); // Array of { name, durationMs, success }
1105
+ console.log(metrics.retryCount); // Total retry count
1106
+ console.log(metrics.errorCount); // Total error count
1107
+ console.log(metrics.cacheHits); // Cache hit count
1108
+ console.log(metrics.cacheMisses); // Cache miss count
1109
+
1110
+ // Or use the simpler event handler for debug logging
1111
+ const workflow2 = createWorkflow(deps, {
1112
+ onEvent: createAutotelEventHandler({
1113
+ serviceName: 'checkout',
1114
+ includeStepDetails: true,
1115
+ }),
1116
+ });
1117
+ // Set AUTOTEL_DEBUG=true to see console output
1118
+
1119
+ // Wrap with autotel tracing for actual OpenTelemetry spans
1120
+ import { trace } from 'autotel';
1121
+
1122
+ const traced = withAutotelTracing(trace, { serviceName: 'checkout' });
1123
+
1124
+ const result = await traced('process-order', async () => {
1125
+ return workflow(async (step) => {
1126
+ // ... workflow logic
1127
+ });
1128
+ }, { orderId: '123' }); // Optional attributes
1129
+ ```