@donkeylabs/server 2.0.20 → 2.0.21
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/package.json +1 -1
- package/src/core/index.ts +6 -0
- package/src/core/workflow-executor.ts +104 -334
- package/src/core/workflow-socket.ts +2 -0
- package/src/core/workflow-state-machine.ts +593 -0
- package/src/core/workflows.test.ts +343 -0
- package/src/core/workflows.ts +234 -887
|
@@ -413,3 +413,346 @@ describe("WorkflowDefinition", () => {
|
|
|
413
413
|
expect(inlineWf.isolated).toBe(false);
|
|
414
414
|
});
|
|
415
415
|
});
|
|
416
|
+
|
|
417
|
+
describe("Choice steps (inline)", () => {
|
|
418
|
+
let workflows: ReturnType<typeof createWorkflows>;
|
|
419
|
+
let adapter: MemoryWorkflowAdapter;
|
|
420
|
+
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
adapter = new MemoryWorkflowAdapter();
|
|
423
|
+
workflows = createWorkflows({ adapter });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
afterEach(async () => {
|
|
427
|
+
await workflows.stop();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("should register workflow with choice step (no restriction)", () => {
|
|
431
|
+
const wf = workflow("with-choice")
|
|
432
|
+
.isolated(false)
|
|
433
|
+
.task("start", { handler: async () => ({ type: "express" }) })
|
|
434
|
+
.choice("route", {
|
|
435
|
+
choices: [
|
|
436
|
+
{ condition: (ctx) => ctx.prev?.type === "express", next: "fast-path" },
|
|
437
|
+
{ condition: (ctx) => ctx.prev?.type === "standard", next: "slow-path" },
|
|
438
|
+
],
|
|
439
|
+
default: "slow-path",
|
|
440
|
+
})
|
|
441
|
+
.task("fast-path", { handler: async () => ({ speed: "fast" }), end: true })
|
|
442
|
+
.task("slow-path", { handler: async () => ({ speed: "slow" }), end: true })
|
|
443
|
+
.build();
|
|
444
|
+
|
|
445
|
+
// Should not throw - choice is allowed now
|
|
446
|
+
expect(() => workflows.register(wf)).not.toThrow();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("should execute choice step and follow matching branch", async () => {
|
|
450
|
+
const wf = workflow("choice-test")
|
|
451
|
+
.isolated(false)
|
|
452
|
+
.task("start", { handler: async () => ({ type: "express" }) })
|
|
453
|
+
.choice("route", {
|
|
454
|
+
choices: [
|
|
455
|
+
{ condition: (ctx) => ctx.prev?.type === "express", next: "fast-path" },
|
|
456
|
+
],
|
|
457
|
+
default: "slow-path",
|
|
458
|
+
})
|
|
459
|
+
.task("fast-path", { handler: async () => ({ speed: "fast" }), end: true })
|
|
460
|
+
.task("slow-path", { handler: async () => ({ speed: "slow" }), end: true })
|
|
461
|
+
.build();
|
|
462
|
+
|
|
463
|
+
workflows.register(wf);
|
|
464
|
+
const instanceId = await workflows.start("choice-test", {});
|
|
465
|
+
|
|
466
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
467
|
+
|
|
468
|
+
const instance = await workflows.getInstance(instanceId);
|
|
469
|
+
expect(instance?.status).toBe("completed");
|
|
470
|
+
expect(instance?.output).toEqual({ speed: "fast" });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("should use default when no choice matches", async () => {
|
|
474
|
+
const wf = workflow("choice-default")
|
|
475
|
+
.isolated(false)
|
|
476
|
+
.task("start", { handler: async () => ({ type: "unknown" }) })
|
|
477
|
+
.choice("route", {
|
|
478
|
+
choices: [
|
|
479
|
+
{ condition: (ctx) => ctx.prev?.type === "express", next: "fast-path" },
|
|
480
|
+
],
|
|
481
|
+
default: "slow-path",
|
|
482
|
+
})
|
|
483
|
+
.task("fast-path", { handler: async () => ({ speed: "fast" }), end: true })
|
|
484
|
+
.task("slow-path", { handler: async () => ({ speed: "slow" }), end: true })
|
|
485
|
+
.build();
|
|
486
|
+
|
|
487
|
+
workflows.register(wf);
|
|
488
|
+
const instanceId = await workflows.start("choice-default", {});
|
|
489
|
+
|
|
490
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
491
|
+
|
|
492
|
+
const instance = await workflows.getInstance(instanceId);
|
|
493
|
+
expect(instance?.status).toBe("completed");
|
|
494
|
+
expect(instance?.output).toEqual({ speed: "slow" });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("should fail when no choice matches and no default", async () => {
|
|
498
|
+
const wf = workflow("choice-no-default")
|
|
499
|
+
.isolated(false)
|
|
500
|
+
.task("start", { handler: async () => ({ type: "unknown" }) })
|
|
501
|
+
.choice("route", {
|
|
502
|
+
choices: [
|
|
503
|
+
{ condition: (ctx) => ctx.prev?.type === "express", next: "fast-path" },
|
|
504
|
+
],
|
|
505
|
+
})
|
|
506
|
+
.task("fast-path", { handler: async () => ({ speed: "fast" }), end: true })
|
|
507
|
+
.build();
|
|
508
|
+
|
|
509
|
+
workflows.register(wf);
|
|
510
|
+
const instanceId = await workflows.start("choice-no-default", {});
|
|
511
|
+
|
|
512
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
513
|
+
|
|
514
|
+
const instance = await workflows.getInstance(instanceId);
|
|
515
|
+
expect(instance?.status).toBe("failed");
|
|
516
|
+
expect(instance?.error).toContain("No choice condition matched");
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe("Parallel steps (inline)", () => {
|
|
521
|
+
let workflows: ReturnType<typeof createWorkflows>;
|
|
522
|
+
let adapter: MemoryWorkflowAdapter;
|
|
523
|
+
|
|
524
|
+
beforeEach(() => {
|
|
525
|
+
adapter = new MemoryWorkflowAdapter();
|
|
526
|
+
workflows = createWorkflows({ adapter });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
afterEach(async () => {
|
|
530
|
+
await workflows.stop();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("should register workflow with parallel step (no restriction)", () => {
|
|
534
|
+
const branch1 = workflow.branch("branch-a")
|
|
535
|
+
.task("a1", { handler: async () => ({ branch: "a" }) })
|
|
536
|
+
.build();
|
|
537
|
+
|
|
538
|
+
const branch2 = workflow.branch("branch-b")
|
|
539
|
+
.task("b1", { handler: async () => ({ branch: "b" }) })
|
|
540
|
+
.build();
|
|
541
|
+
|
|
542
|
+
const wf = workflow("with-parallel")
|
|
543
|
+
.isolated(false)
|
|
544
|
+
.parallel("fan-out", { branches: [branch1, branch2] })
|
|
545
|
+
.build();
|
|
546
|
+
|
|
547
|
+
expect(() => workflows.register(wf)).not.toThrow();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("should execute parallel branches and aggregate results", async () => {
|
|
551
|
+
const branch1 = workflow.branch("branch-a")
|
|
552
|
+
.task("a1", { handler: async () => ({ result: "a" }) })
|
|
553
|
+
.build();
|
|
554
|
+
|
|
555
|
+
const branch2 = workflow.branch("branch-b")
|
|
556
|
+
.task("b1", { handler: async () => ({ result: "b" }) })
|
|
557
|
+
.build();
|
|
558
|
+
|
|
559
|
+
const wf = workflow("parallel-test")
|
|
560
|
+
.isolated(false)
|
|
561
|
+
.parallel("fan-out", { branches: [branch1, branch2] })
|
|
562
|
+
.build();
|
|
563
|
+
|
|
564
|
+
workflows.register(wf);
|
|
565
|
+
const instanceId = await workflows.start("parallel-test", {});
|
|
566
|
+
|
|
567
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
568
|
+
|
|
569
|
+
const instance = await workflows.getInstance(instanceId);
|
|
570
|
+
expect(instance?.status).toBe("completed");
|
|
571
|
+
expect(instance?.output).toEqual({
|
|
572
|
+
"branch-a": { result: "a" },
|
|
573
|
+
"branch-b": { result: "b" },
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("should fail-fast when a branch fails (default)", async () => {
|
|
578
|
+
const branch1 = workflow.branch("branch-ok")
|
|
579
|
+
.task("ok", {
|
|
580
|
+
handler: async () => {
|
|
581
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
582
|
+
return { ok: true };
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
.build();
|
|
586
|
+
|
|
587
|
+
const branch2 = workflow.branch("branch-fail")
|
|
588
|
+
.task("fail", {
|
|
589
|
+
handler: async () => {
|
|
590
|
+
throw new Error("Branch failure");
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
.build();
|
|
594
|
+
|
|
595
|
+
const wf = workflow("parallel-fail-fast")
|
|
596
|
+
.isolated(false)
|
|
597
|
+
.parallel("fan-out", { branches: [branch1, branch2] })
|
|
598
|
+
.build();
|
|
599
|
+
|
|
600
|
+
workflows.register(wf);
|
|
601
|
+
const instanceId = await workflows.start("parallel-fail-fast", {});
|
|
602
|
+
|
|
603
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
604
|
+
|
|
605
|
+
const instance = await workflows.getInstance(instanceId);
|
|
606
|
+
expect(instance?.status).toBe("failed");
|
|
607
|
+
expect(instance?.error).toContain("Branch failure");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("should collect all results with wait-all and report errors", async () => {
|
|
611
|
+
const branch1 = workflow.branch("branch-ok-wa")
|
|
612
|
+
.task("ok", { handler: async () => ({ ok: true }) })
|
|
613
|
+
.build();
|
|
614
|
+
|
|
615
|
+
const branch2 = workflow.branch("branch-fail-wa")
|
|
616
|
+
.task("fail", {
|
|
617
|
+
handler: async () => {
|
|
618
|
+
throw new Error("Branch error");
|
|
619
|
+
},
|
|
620
|
+
})
|
|
621
|
+
.build();
|
|
622
|
+
|
|
623
|
+
const wf = workflow("parallel-wait-all")
|
|
624
|
+
.isolated(false)
|
|
625
|
+
.parallel("fan-out", {
|
|
626
|
+
branches: [branch1, branch2],
|
|
627
|
+
onError: "wait-all",
|
|
628
|
+
})
|
|
629
|
+
.build();
|
|
630
|
+
|
|
631
|
+
workflows.register(wf);
|
|
632
|
+
const instanceId = await workflows.start("parallel-wait-all", {});
|
|
633
|
+
|
|
634
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
635
|
+
|
|
636
|
+
const instance = await workflows.getInstance(instanceId);
|
|
637
|
+
expect(instance?.status).toBe("failed");
|
|
638
|
+
expect(instance?.error).toContain("Parallel branches failed");
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe("Retry logic", () => {
|
|
643
|
+
let workflows: ReturnType<typeof createWorkflows>;
|
|
644
|
+
let adapter: MemoryWorkflowAdapter;
|
|
645
|
+
|
|
646
|
+
beforeEach(() => {
|
|
647
|
+
adapter = new MemoryWorkflowAdapter();
|
|
648
|
+
workflows = createWorkflows({ adapter });
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
afterEach(async () => {
|
|
652
|
+
await workflows.stop();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("should retry step on failure with exponential backoff", async () => {
|
|
656
|
+
let attempts = 0;
|
|
657
|
+
|
|
658
|
+
const wf = workflow("retry-test")
|
|
659
|
+
.isolated(false)
|
|
660
|
+
.task("flaky", {
|
|
661
|
+
handler: async () => {
|
|
662
|
+
attempts++;
|
|
663
|
+
if (attempts < 3) {
|
|
664
|
+
throw new Error("Temporary failure");
|
|
665
|
+
}
|
|
666
|
+
return { success: true };
|
|
667
|
+
},
|
|
668
|
+
retry: {
|
|
669
|
+
maxAttempts: 3,
|
|
670
|
+
intervalMs: 50,
|
|
671
|
+
backoffRate: 2,
|
|
672
|
+
},
|
|
673
|
+
})
|
|
674
|
+
.build();
|
|
675
|
+
|
|
676
|
+
workflows.register(wf);
|
|
677
|
+
const instanceId = await workflows.start("retry-test", {});
|
|
678
|
+
|
|
679
|
+
// Wait long enough for retries (50ms + 100ms + execution time)
|
|
680
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
681
|
+
|
|
682
|
+
const instance = await workflows.getInstance(instanceId);
|
|
683
|
+
expect(instance?.status).toBe("completed");
|
|
684
|
+
expect(attempts).toBe(3);
|
|
685
|
+
expect(instance?.output).toEqual({ success: true });
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("should fail after exhausting retries", async () => {
|
|
689
|
+
let attempts = 0;
|
|
690
|
+
|
|
691
|
+
const wf = workflow("retry-exhaust")
|
|
692
|
+
.isolated(false)
|
|
693
|
+
.task("always-fail", {
|
|
694
|
+
handler: async () => {
|
|
695
|
+
attempts++;
|
|
696
|
+
throw new Error("Permanent failure");
|
|
697
|
+
},
|
|
698
|
+
retry: {
|
|
699
|
+
maxAttempts: 2,
|
|
700
|
+
intervalMs: 50,
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
.build();
|
|
704
|
+
|
|
705
|
+
workflows.register(wf);
|
|
706
|
+
const instanceId = await workflows.start("retry-exhaust", {});
|
|
707
|
+
|
|
708
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
709
|
+
|
|
710
|
+
const instance = await workflows.getInstance(instanceId);
|
|
711
|
+
expect(instance?.status).toBe("failed");
|
|
712
|
+
expect(instance?.error).toContain("Permanent failure");
|
|
713
|
+
expect(attempts).toBe(2);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
describe("Metadata persistence", () => {
|
|
718
|
+
let workflows: ReturnType<typeof createWorkflows>;
|
|
719
|
+
let adapter: MemoryWorkflowAdapter;
|
|
720
|
+
|
|
721
|
+
beforeEach(() => {
|
|
722
|
+
adapter = new MemoryWorkflowAdapter();
|
|
723
|
+
workflows = createWorkflows({ adapter });
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
afterEach(async () => {
|
|
727
|
+
await workflows.stop();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("should persist metadata across steps", async () => {
|
|
731
|
+
let secondStepMetadata: any;
|
|
732
|
+
|
|
733
|
+
const wf = workflow("metadata-test")
|
|
734
|
+
.isolated(false)
|
|
735
|
+
.task("set-meta", {
|
|
736
|
+
handler: async (_, ctx) => {
|
|
737
|
+
await ctx.setMetadata("tracking", { id: "abc-123" });
|
|
738
|
+
return { done: true };
|
|
739
|
+
},
|
|
740
|
+
})
|
|
741
|
+
.task("read-meta", {
|
|
742
|
+
handler: async (_, ctx) => {
|
|
743
|
+
secondStepMetadata = ctx.getMetadata("tracking");
|
|
744
|
+
return { meta: secondStepMetadata };
|
|
745
|
+
},
|
|
746
|
+
})
|
|
747
|
+
.build();
|
|
748
|
+
|
|
749
|
+
workflows.register(wf);
|
|
750
|
+
const instanceId = await workflows.start("metadata-test", {});
|
|
751
|
+
|
|
752
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
753
|
+
|
|
754
|
+
const instance = await workflows.getInstance(instanceId);
|
|
755
|
+
expect(instance?.status).toBe("completed");
|
|
756
|
+
expect(secondStepMetadata).toEqual({ id: "abc-123" });
|
|
757
|
+
});
|
|
758
|
+
});
|