@donkeylabs/server 2.0.19 → 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.
@@ -0,0 +1,758 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import {
3
+ workflow,
4
+ createWorkflows,
5
+ WorkflowBuilder,
6
+ MemoryWorkflowAdapter,
7
+ type WorkflowDefinition,
8
+ } from "./workflows";
9
+ import { z } from "zod";
10
+
11
+ describe("WorkflowBuilder", () => {
12
+ describe("isolated()", () => {
13
+ it("should default to isolated: true", () => {
14
+ const wf = workflow("test")
15
+ .task("step1", {
16
+ handler: async () => ({ result: "done" }),
17
+ })
18
+ .build();
19
+
20
+ expect(wf.isolated).toBe(true);
21
+ });
22
+
23
+ it("should set isolated to false when called with false", () => {
24
+ const wf = workflow("test")
25
+ .isolated(false)
26
+ .task("step1", {
27
+ handler: async () => ({ result: "done" }),
28
+ })
29
+ .build();
30
+
31
+ expect(wf.isolated).toBe(false);
32
+ });
33
+
34
+ it("should set isolated to true when called with true", () => {
35
+ const wf = workflow("test")
36
+ .isolated(true)
37
+ .task("step1", {
38
+ handler: async () => ({ result: "done" }),
39
+ })
40
+ .build();
41
+
42
+ expect(wf.isolated).toBe(true);
43
+ });
44
+
45
+ it("should set isolated to true when called without argument", () => {
46
+ const wf = workflow("test")
47
+ .isolated()
48
+ .task("step1", {
49
+ handler: async () => ({ result: "done" }),
50
+ })
51
+ .build();
52
+
53
+ expect(wf.isolated).toBe(true);
54
+ });
55
+
56
+ it("should allow chaining with other methods", () => {
57
+ const wf = workflow("test")
58
+ .isolated(false)
59
+ .timeout(5000)
60
+ .defaultRetry({ maxAttempts: 3 })
61
+ .task("step1", {
62
+ handler: async () => ({ result: "done" }),
63
+ })
64
+ .build();
65
+
66
+ expect(wf.isolated).toBe(false);
67
+ expect(wf.timeout).toBe(5000);
68
+ expect(wf.defaultRetry?.maxAttempts).toBe(3);
69
+ });
70
+ });
71
+
72
+ describe("task()", () => {
73
+ it("should create task step with handler", () => {
74
+ const wf = workflow("test")
75
+ .task("process", {
76
+ handler: async (input, ctx) => ({ processed: true }),
77
+ })
78
+ .build();
79
+
80
+ expect(wf.steps.size).toBe(1);
81
+ const step = wf.steps.get("process");
82
+ expect(step?.type).toBe("task");
83
+ expect(step?.name).toBe("process");
84
+ });
85
+
86
+ it("should create task step with input and output schemas", () => {
87
+ const inputSchema = z.object({ id: z.string() });
88
+ const outputSchema = z.object({ name: z.string() });
89
+
90
+ const wf = workflow("test")
91
+ .task("lookup", {
92
+ inputSchema,
93
+ outputSchema,
94
+ handler: async (input) => ({ name: "John" }),
95
+ })
96
+ .build();
97
+
98
+ const step = wf.steps.get("lookup") as any;
99
+ expect(step.inputSchema).toBe(inputSchema);
100
+ expect(step.outputSchema).toBe(outputSchema);
101
+ });
102
+ });
103
+
104
+ describe("auto-linking steps", () => {
105
+ it("should auto-link sequential steps", () => {
106
+ const wf = workflow("test")
107
+ .task("step1", { handler: async () => 1 })
108
+ .task("step2", { handler: async () => 2 })
109
+ .task("step3", { handler: async () => 3 })
110
+ .build();
111
+
112
+ expect(wf.startAt).toBe("step1");
113
+ expect(wf.steps.get("step1")?.next).toBe("step2");
114
+ expect(wf.steps.get("step2")?.next).toBe("step3");
115
+ expect(wf.steps.get("step3")?.end).toBe(true);
116
+ });
117
+
118
+ it("should respect explicit next", () => {
119
+ const wf = workflow("test")
120
+ .task("step1", { handler: async () => 1, next: "step3" })
121
+ .task("step2", { handler: async () => 2 })
122
+ .task("step3", { handler: async () => 3 })
123
+ .build();
124
+
125
+ expect(wf.steps.get("step1")?.next).toBe("step3");
126
+ });
127
+ });
128
+ });
129
+
130
+ describe("Workflows Service", () => {
131
+ let workflows: ReturnType<typeof createWorkflows>;
132
+ let adapter: MemoryWorkflowAdapter;
133
+
134
+ beforeEach(() => {
135
+ adapter = new MemoryWorkflowAdapter();
136
+ workflows = createWorkflows({ adapter });
137
+ });
138
+
139
+ afterEach(async () => {
140
+ await workflows.stop();
141
+ });
142
+
143
+ describe("register()", () => {
144
+ it("should register a workflow definition", () => {
145
+ const wf = workflow("test-workflow")
146
+ .isolated(false)
147
+ .task("step1", { handler: async () => "done" })
148
+ .build();
149
+
150
+ workflows.register(wf);
151
+
152
+ // Can start the workflow (would throw if not registered)
153
+ expect(async () => {
154
+ await workflows.start("test-workflow", {});
155
+ }).not.toThrow();
156
+ });
157
+
158
+ it("should throw if workflow is already registered", () => {
159
+ const wf = workflow("duplicate")
160
+ .isolated(false)
161
+ .task("step1", { handler: async () => "done" })
162
+ .build();
163
+
164
+ workflows.register(wf);
165
+
166
+ expect(() => {
167
+ workflows.register(wf);
168
+ }).toThrow('Workflow "duplicate" is already registered');
169
+ });
170
+
171
+ it("should accept modulePath option for isolated workflows", () => {
172
+ const wf = workflow("isolated-test")
173
+ .task("step1", { handler: async () => "done" })
174
+ .build();
175
+
176
+ // Should not throw when modulePath is provided
177
+ expect(() => {
178
+ workflows.register(wf, { modulePath: import.meta.url });
179
+ }).not.toThrow();
180
+ });
181
+ });
182
+
183
+ describe("start()", () => {
184
+ it("should throw if workflow is not registered", async () => {
185
+ await expect(
186
+ workflows.start("nonexistent", {})
187
+ ).rejects.toThrow('Workflow "nonexistent" is not registered');
188
+ });
189
+
190
+ it("should create pending instance", async () => {
191
+ const wf = workflow("simple")
192
+ .isolated(false)
193
+ .task("step1", {
194
+ handler: async (input) => {
195
+ // Slow task to catch it in pending/running state
196
+ await new Promise((r) => setTimeout(r, 100));
197
+ return { result: "done" };
198
+ },
199
+ })
200
+ .build();
201
+
202
+ workflows.register(wf);
203
+ const instanceId = await workflows.start("simple", { data: "test" });
204
+
205
+ expect(instanceId).toMatch(/^wf_/);
206
+
207
+ // Instance should exist
208
+ const instance = await workflows.getInstance(instanceId);
209
+ expect(instance).not.toBeNull();
210
+ expect(instance?.workflowName).toBe("simple");
211
+ expect(instance?.input).toEqual({ data: "test" });
212
+ });
213
+ });
214
+
215
+ describe("inline execution (isolated=false)", () => {
216
+ it("should execute workflow steps sequentially", async () => {
217
+ const executionOrder: string[] = [];
218
+
219
+ const wf = workflow("sequential")
220
+ .isolated(false)
221
+ .task("first", {
222
+ handler: async () => {
223
+ executionOrder.push("first");
224
+ return { order: 1 };
225
+ },
226
+ })
227
+ .task("second", {
228
+ handler: async (_, ctx) => {
229
+ executionOrder.push("second");
230
+ return { order: 2, prev: ctx.prev };
231
+ },
232
+ })
233
+ .build();
234
+
235
+ workflows.register(wf);
236
+ const instanceId = await workflows.start("sequential", {});
237
+
238
+ // Wait for completion
239
+ await new Promise((r) => setTimeout(r, 200));
240
+
241
+ const instance = await workflows.getInstance(instanceId);
242
+ expect(instance?.status).toBe("completed");
243
+ expect(executionOrder).toEqual(["first", "second"]);
244
+ expect(instance?.output).toEqual({ order: 2, prev: { order: 1 } });
245
+ });
246
+
247
+ it("should pass workflow input to first step", async () => {
248
+ let receivedInput: any;
249
+
250
+ const wf = workflow("input-test")
251
+ .isolated(false)
252
+ .task("check-input", {
253
+ handler: async (input) => {
254
+ receivedInput = input;
255
+ return { received: true };
256
+ },
257
+ })
258
+ .build();
259
+
260
+ workflows.register(wf);
261
+ await workflows.start("input-test", { userId: "123", action: "test" });
262
+
263
+ await new Promise((r) => setTimeout(r, 100));
264
+
265
+ expect(receivedInput).toEqual({ userId: "123", action: "test" });
266
+ });
267
+
268
+ it("should handle step failures", async () => {
269
+ const wf = workflow("failing")
270
+ .isolated(false)
271
+ .task("fail-step", {
272
+ handler: async () => {
273
+ throw new Error("Intentional failure");
274
+ },
275
+ })
276
+ .build();
277
+
278
+ workflows.register(wf);
279
+ const instanceId = await workflows.start("failing", {});
280
+
281
+ await new Promise((r) => setTimeout(r, 100));
282
+
283
+ const instance = await workflows.getInstance(instanceId);
284
+ expect(instance?.status).toBe("failed");
285
+ expect(instance?.error).toContain("Intentional failure");
286
+ });
287
+ });
288
+
289
+ describe("cancel()", () => {
290
+ it("should cancel a running workflow", async () => {
291
+ const wf = workflow("cancellable")
292
+ .isolated(false)
293
+ .task("slow-step", {
294
+ handler: async () => {
295
+ await new Promise((r) => setTimeout(r, 5000));
296
+ return { done: true };
297
+ },
298
+ })
299
+ .build();
300
+
301
+ workflows.register(wf);
302
+ const instanceId = await workflows.start("cancellable", {});
303
+
304
+ // Give it time to start
305
+ await new Promise((r) => setTimeout(r, 50));
306
+
307
+ const cancelled = await workflows.cancel(instanceId);
308
+ expect(cancelled).toBe(true);
309
+
310
+ const instance = await workflows.getInstance(instanceId);
311
+ expect(instance?.status).toBe("cancelled");
312
+ });
313
+
314
+ it("should return false for non-running workflow", async () => {
315
+ const wf = workflow("fast")
316
+ .isolated(false)
317
+ .task("quick", { handler: async () => "done" })
318
+ .build();
319
+
320
+ workflows.register(wf);
321
+ const instanceId = await workflows.start("fast", {});
322
+
323
+ await new Promise((r) => setTimeout(r, 100));
324
+
325
+ // Already completed
326
+ const cancelled = await workflows.cancel(instanceId);
327
+ expect(cancelled).toBe(false);
328
+ });
329
+ });
330
+
331
+ describe("getInstances()", () => {
332
+ it("should return instances by workflow name", async () => {
333
+ const wf = workflow("multiple")
334
+ .isolated(false)
335
+ .task("step", { handler: async () => "done" })
336
+ .build();
337
+
338
+ workflows.register(wf);
339
+
340
+ await workflows.start("multiple", { id: 1 });
341
+ await workflows.start("multiple", { id: 2 });
342
+ await workflows.start("multiple", { id: 3 });
343
+
344
+ await new Promise((r) => setTimeout(r, 100));
345
+
346
+ const instances = await workflows.getInstances("multiple");
347
+ expect(instances.length).toBe(3);
348
+ });
349
+
350
+ it("should filter by status", async () => {
351
+ const wf = workflow("mixed-status")
352
+ .isolated(false)
353
+ .task("step", { handler: async () => "done" })
354
+ .build();
355
+
356
+ const failingWf = workflow("mixed-status-fail")
357
+ .isolated(false)
358
+ .task("step", {
359
+ handler: async () => {
360
+ throw new Error("fail");
361
+ },
362
+ })
363
+ .build();
364
+
365
+ workflows.register(wf);
366
+ workflows.register(failingWf);
367
+
368
+ await workflows.start("mixed-status", {});
369
+ await workflows.start("mixed-status-fail", {});
370
+
371
+ await new Promise((r) => setTimeout(r, 100));
372
+
373
+ const completed = await workflows.getInstances("mixed-status", "completed");
374
+ expect(completed.length).toBe(1);
375
+
376
+ const failed = await workflows.getInstances("mixed-status-fail", "failed");
377
+ expect(failed.length).toBe(1);
378
+ });
379
+ });
380
+
381
+ describe("getAllInstances()", () => {
382
+ it("should return all instances with filtering", async () => {
383
+ const wf1 = workflow("wf1").isolated(false).task("s", { handler: async () => 1 }).build();
384
+ const wf2 = workflow("wf2").isolated(false).task("s", { handler: async () => 2 }).build();
385
+
386
+ workflows.register(wf1);
387
+ workflows.register(wf2);
388
+
389
+ await workflows.start("wf1", {});
390
+ await workflows.start("wf1", {});
391
+ await workflows.start("wf2", {});
392
+
393
+ await new Promise((r) => setTimeout(r, 100));
394
+
395
+ const all = await workflows.getAllInstances();
396
+ expect(all.length).toBe(3);
397
+
398
+ const wf1Only = await workflows.getAllInstances({ workflowName: "wf1" });
399
+ expect(wf1Only.length).toBe(2);
400
+
401
+ const withLimit = await workflows.getAllInstances({ limit: 2 });
402
+ expect(withLimit.length).toBe(2);
403
+ });
404
+ });
405
+ });
406
+
407
+ describe("WorkflowDefinition", () => {
408
+ it("should include isolated field in built definition", () => {
409
+ const isolatedWf = workflow("isolated").task("s", { handler: async () => 1 }).build();
410
+ const inlineWf = workflow("inline").isolated(false).task("s", { handler: async () => 1 }).build();
411
+
412
+ expect(isolatedWf.isolated).toBe(true);
413
+ expect(inlineWf.isolated).toBe(false);
414
+ });
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
+ });