@particle-academy/fancy-conformance 0.24.0 → 0.25.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/README.md CHANGED
@@ -70,6 +70,7 @@ verbatim.
70
70
  | `shared/flow-run-identity` | 25 | fancy-flow's run/step identity: the idempotency key a retrying connector sends, and when a retry may still reuse it |
71
71
  | `flow/graph-runs` | 23 | Whole-graph execution: the same `WorkflowSchema` in, the same `RunResult.outputs` out |
72
72
  | `flow/run-diagnostics` | 14 | The run-time warnings for a graph that delivers nothing: an edge naming a port its source can never publish, and a route taken on a path that did not resolve. Half the rows pin when to stay silent |
73
+ | `flow/durable-dispatch` | 14 | How a queued run hands out nodes: one at a time by default, in declaration order, with a paused gate keeping its slot; a cap and the whole frontier only when a host asks |
73
74
 
74
75
  Every case carries an `id`, a `title`, the suite version it arrived in, and —
75
76
  where it exists to catch something specific — a `notes` field saying what.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.0
1
+ 0.25.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI — so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,1199 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/durable-dispatch",
4
+ "cases": [
5
+ {
6
+ "id": "0001-a-fan-out-dispatches-one-node-at-a-time-by-default",
7
+ "title": "Serial: a trigger fanning out to three nodes hands out one node per settle, in declaration order.",
8
+ "since": "0.25.0",
9
+ "tags": [
10
+ "serial",
11
+ "default",
12
+ "fan-out"
13
+ ],
14
+ "input": {
15
+ "schema": {
16
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
17
+ "version": 1,
18
+ "graph": {
19
+ "nodes": [
20
+ {
21
+ "id": "t",
22
+ "kind": "manual_trigger",
23
+ "position": {
24
+ "x": 0,
25
+ "y": 0
26
+ }
27
+ },
28
+ {
29
+ "id": "a",
30
+ "kind": "log",
31
+ "position": {
32
+ "x": 0,
33
+ "y": 0
34
+ }
35
+ },
36
+ {
37
+ "id": "b",
38
+ "kind": "log",
39
+ "position": {
40
+ "x": 0,
41
+ "y": 0
42
+ }
43
+ },
44
+ {
45
+ "id": "c",
46
+ "kind": "log",
47
+ "position": {
48
+ "x": 0,
49
+ "y": 0
50
+ }
51
+ }
52
+ ],
53
+ "edges": [
54
+ {
55
+ "id": "t-a",
56
+ "source": "t",
57
+ "target": "a"
58
+ },
59
+ {
60
+ "id": "t-b",
61
+ "source": "t",
62
+ "target": "b"
63
+ },
64
+ {
65
+ "id": "t-c",
66
+ "source": "t",
67
+ "target": "c"
68
+ }
69
+ ]
70
+ }
71
+ },
72
+ "maxConcurrent": 1,
73
+ "pauses": [],
74
+ "publishes": {}
75
+ },
76
+ "expected": {
77
+ "trace": [
78
+ "dispatch t",
79
+ "complete t",
80
+ "dispatch a",
81
+ "complete a",
82
+ "dispatch b",
83
+ "complete b",
84
+ "dispatch c",
85
+ "complete c"
86
+ ],
87
+ "neverDispatched": []
88
+ },
89
+ "notes": "The default. Before fancy-flow-php 0.54.0 an unset limit dispatched the whole frontier here, as 0002 does."
90
+ },
91
+ {
92
+ "id": "0002-unlimited-dispatches-the-whole-ready-frontier",
93
+ "title": "maxConcurrent 0 is the opt-in to parallel: the whole ready frontier goes out in one batch.",
94
+ "since": "0.25.0",
95
+ "tags": [
96
+ "unlimited",
97
+ "fan-out"
98
+ ],
99
+ "input": {
100
+ "schema": {
101
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
102
+ "version": 1,
103
+ "graph": {
104
+ "nodes": [
105
+ {
106
+ "id": "t",
107
+ "kind": "manual_trigger",
108
+ "position": {
109
+ "x": 0,
110
+ "y": 0
111
+ }
112
+ },
113
+ {
114
+ "id": "a",
115
+ "kind": "log",
116
+ "position": {
117
+ "x": 0,
118
+ "y": 0
119
+ }
120
+ },
121
+ {
122
+ "id": "b",
123
+ "kind": "log",
124
+ "position": {
125
+ "x": 0,
126
+ "y": 0
127
+ }
128
+ },
129
+ {
130
+ "id": "c",
131
+ "kind": "log",
132
+ "position": {
133
+ "x": 0,
134
+ "y": 0
135
+ }
136
+ }
137
+ ],
138
+ "edges": [
139
+ {
140
+ "id": "t-a",
141
+ "source": "t",
142
+ "target": "a"
143
+ },
144
+ {
145
+ "id": "t-b",
146
+ "source": "t",
147
+ "target": "b"
148
+ },
149
+ {
150
+ "id": "t-c",
151
+ "source": "t",
152
+ "target": "c"
153
+ }
154
+ ]
155
+ }
156
+ },
157
+ "maxConcurrent": 0,
158
+ "pauses": [],
159
+ "publishes": {}
160
+ },
161
+ "expected": {
162
+ "trace": [
163
+ "dispatch t",
164
+ "complete t",
165
+ "dispatch a",
166
+ "dispatch b",
167
+ "dispatch c",
168
+ "complete a",
169
+ "complete b",
170
+ "complete c"
171
+ ],
172
+ "neverDispatched": []
173
+ }
174
+ },
175
+ {
176
+ "id": "0003-a-cap-of-two-fills-both-slots-then-waits",
177
+ "title": "A cap of 2 dispatches two siblings, and the third only once one of them settles.",
178
+ "since": "0.25.0",
179
+ "tags": [
180
+ "cap",
181
+ "fan-out"
182
+ ],
183
+ "input": {
184
+ "schema": {
185
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
186
+ "version": 1,
187
+ "graph": {
188
+ "nodes": [
189
+ {
190
+ "id": "t",
191
+ "kind": "manual_trigger",
192
+ "position": {
193
+ "x": 0,
194
+ "y": 0
195
+ }
196
+ },
197
+ {
198
+ "id": "a",
199
+ "kind": "log",
200
+ "position": {
201
+ "x": 0,
202
+ "y": 0
203
+ }
204
+ },
205
+ {
206
+ "id": "b",
207
+ "kind": "log",
208
+ "position": {
209
+ "x": 0,
210
+ "y": 0
211
+ }
212
+ },
213
+ {
214
+ "id": "c",
215
+ "kind": "log",
216
+ "position": {
217
+ "x": 0,
218
+ "y": 0
219
+ }
220
+ }
221
+ ],
222
+ "edges": [
223
+ {
224
+ "id": "t-a",
225
+ "source": "t",
226
+ "target": "a"
227
+ },
228
+ {
229
+ "id": "t-b",
230
+ "source": "t",
231
+ "target": "b"
232
+ },
233
+ {
234
+ "id": "t-c",
235
+ "source": "t",
236
+ "target": "c"
237
+ }
238
+ ]
239
+ }
240
+ },
241
+ "maxConcurrent": 2,
242
+ "pauses": [],
243
+ "publishes": {}
244
+ },
245
+ "expected": {
246
+ "trace": [
247
+ "dispatch t",
248
+ "complete t",
249
+ "dispatch a",
250
+ "dispatch b",
251
+ "complete a",
252
+ "dispatch c",
253
+ "complete b",
254
+ "complete c"
255
+ ],
256
+ "neverDispatched": []
257
+ }
258
+ },
259
+ {
260
+ "id": "0004-declaration-order-not-edge-order",
261
+ "title": "Siblings go out in the order the graph DECLARES them, not the order their edges are listed.",
262
+ "since": "0.25.0",
263
+ "tags": [
264
+ "serial",
265
+ "order"
266
+ ],
267
+ "input": {
268
+ "schema": {
269
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
270
+ "version": 1,
271
+ "graph": {
272
+ "nodes": [
273
+ {
274
+ "id": "t",
275
+ "kind": "manual_trigger",
276
+ "position": {
277
+ "x": 0,
278
+ "y": 0
279
+ }
280
+ },
281
+ {
282
+ "id": "b",
283
+ "kind": "log",
284
+ "position": {
285
+ "x": 0,
286
+ "y": 0
287
+ }
288
+ },
289
+ {
290
+ "id": "a",
291
+ "kind": "log",
292
+ "position": {
293
+ "x": 0,
294
+ "y": 0
295
+ }
296
+ }
297
+ ],
298
+ "edges": [
299
+ {
300
+ "id": "t-a",
301
+ "source": "t",
302
+ "target": "a"
303
+ },
304
+ {
305
+ "id": "t-b",
306
+ "source": "t",
307
+ "target": "b"
308
+ }
309
+ ]
310
+ }
311
+ },
312
+ "maxConcurrent": 1,
313
+ "pauses": [],
314
+ "publishes": {}
315
+ },
316
+ "expected": {
317
+ "trace": [
318
+ "dispatch t",
319
+ "complete t",
320
+ "dispatch b",
321
+ "complete b",
322
+ "dispatch a",
323
+ "complete a"
324
+ ],
325
+ "neverDispatched": []
326
+ }
327
+ },
328
+ {
329
+ "id": "0005-a-diamond-joins-once-after-both-branches-serially",
330
+ "title": "Serial diamond: each branch in turn, then the join, which is never dispatched before both inputs settle.",
331
+ "since": "0.25.0",
332
+ "tags": [
333
+ "serial",
334
+ "fan-in"
335
+ ],
336
+ "input": {
337
+ "schema": {
338
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
339
+ "version": 1,
340
+ "graph": {
341
+ "nodes": [
342
+ {
343
+ "id": "t",
344
+ "kind": "manual_trigger",
345
+ "position": {
346
+ "x": 0,
347
+ "y": 0
348
+ }
349
+ },
350
+ {
351
+ "id": "a",
352
+ "kind": "log",
353
+ "position": {
354
+ "x": 0,
355
+ "y": 0
356
+ }
357
+ },
358
+ {
359
+ "id": "b",
360
+ "kind": "log",
361
+ "position": {
362
+ "x": 0,
363
+ "y": 0
364
+ }
365
+ },
366
+ {
367
+ "id": "j",
368
+ "kind": "merge",
369
+ "position": {
370
+ "x": 0,
371
+ "y": 0
372
+ }
373
+ }
374
+ ],
375
+ "edges": [
376
+ {
377
+ "id": "t-a",
378
+ "source": "t",
379
+ "target": "a"
380
+ },
381
+ {
382
+ "id": "t-b",
383
+ "source": "t",
384
+ "target": "b"
385
+ },
386
+ {
387
+ "id": "a-j",
388
+ "source": "a",
389
+ "target": "j"
390
+ },
391
+ {
392
+ "id": "b-j",
393
+ "source": "b",
394
+ "target": "j"
395
+ }
396
+ ]
397
+ }
398
+ },
399
+ "maxConcurrent": 1,
400
+ "pauses": [],
401
+ "publishes": {}
402
+ },
403
+ "expected": {
404
+ "trace": [
405
+ "dispatch t",
406
+ "complete t",
407
+ "dispatch a",
408
+ "complete a",
409
+ "dispatch b",
410
+ "complete b",
411
+ "dispatch j",
412
+ "complete j"
413
+ ],
414
+ "neverDispatched": []
415
+ }
416
+ },
417
+ {
418
+ "id": "0006-a-diamond-under-unlimited",
419
+ "title": "Unlimited diamond: both branches together, then the join.",
420
+ "since": "0.25.0",
421
+ "tags": [
422
+ "unlimited",
423
+ "fan-in"
424
+ ],
425
+ "input": {
426
+ "schema": {
427
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
428
+ "version": 1,
429
+ "graph": {
430
+ "nodes": [
431
+ {
432
+ "id": "t",
433
+ "kind": "manual_trigger",
434
+ "position": {
435
+ "x": 0,
436
+ "y": 0
437
+ }
438
+ },
439
+ {
440
+ "id": "a",
441
+ "kind": "log",
442
+ "position": {
443
+ "x": 0,
444
+ "y": 0
445
+ }
446
+ },
447
+ {
448
+ "id": "b",
449
+ "kind": "log",
450
+ "position": {
451
+ "x": 0,
452
+ "y": 0
453
+ }
454
+ },
455
+ {
456
+ "id": "j",
457
+ "kind": "merge",
458
+ "position": {
459
+ "x": 0,
460
+ "y": 0
461
+ }
462
+ }
463
+ ],
464
+ "edges": [
465
+ {
466
+ "id": "t-a",
467
+ "source": "t",
468
+ "target": "a"
469
+ },
470
+ {
471
+ "id": "t-b",
472
+ "source": "t",
473
+ "target": "b"
474
+ },
475
+ {
476
+ "id": "a-j",
477
+ "source": "a",
478
+ "target": "j"
479
+ },
480
+ {
481
+ "id": "b-j",
482
+ "source": "b",
483
+ "target": "j"
484
+ }
485
+ ]
486
+ }
487
+ },
488
+ "maxConcurrent": 0,
489
+ "pauses": [],
490
+ "publishes": {}
491
+ },
492
+ "expected": {
493
+ "trace": [
494
+ "dispatch t",
495
+ "complete t",
496
+ "dispatch a",
497
+ "dispatch b",
498
+ "complete a",
499
+ "complete b",
500
+ "dispatch j",
501
+ "complete j"
502
+ ],
503
+ "neverDispatched": []
504
+ }
505
+ },
506
+ {
507
+ "id": "0007-a-successor-declared-before-a-waiting-sibling-goes-first",
508
+ "title": "Order is declaration order among what is READY NOW: a node that just became ready and is declared earlier goes before a sibling that has been waiting. Not breadth-first.",
509
+ "since": "0.25.0",
510
+ "tags": [
511
+ "serial",
512
+ "order"
513
+ ],
514
+ "input": {
515
+ "schema": {
516
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
517
+ "version": 1,
518
+ "graph": {
519
+ "nodes": [
520
+ {
521
+ "id": "t",
522
+ "kind": "manual_trigger",
523
+ "position": {
524
+ "x": 0,
525
+ "y": 0
526
+ }
527
+ },
528
+ {
529
+ "id": "c",
530
+ "kind": "log",
531
+ "position": {
532
+ "x": 0,
533
+ "y": 0
534
+ }
535
+ },
536
+ {
537
+ "id": "a",
538
+ "kind": "log",
539
+ "position": {
540
+ "x": 0,
541
+ "y": 0
542
+ }
543
+ },
544
+ {
545
+ "id": "b",
546
+ "kind": "log",
547
+ "position": {
548
+ "x": 0,
549
+ "y": 0
550
+ }
551
+ }
552
+ ],
553
+ "edges": [
554
+ {
555
+ "id": "t-a",
556
+ "source": "t",
557
+ "target": "a"
558
+ },
559
+ {
560
+ "id": "t-b",
561
+ "source": "t",
562
+ "target": "b"
563
+ },
564
+ {
565
+ "id": "a-c",
566
+ "source": "a",
567
+ "target": "c"
568
+ }
569
+ ]
570
+ }
571
+ },
572
+ "maxConcurrent": 1,
573
+ "pauses": [],
574
+ "publishes": {}
575
+ },
576
+ "expected": {
577
+ "trace": [
578
+ "dispatch t",
579
+ "complete t",
580
+ "dispatch a",
581
+ "complete a",
582
+ "dispatch c",
583
+ "complete c",
584
+ "dispatch b",
585
+ "complete b"
586
+ ],
587
+ "neverDispatched": []
588
+ },
589
+ "notes": "The discriminating order row: breadth-first would dispatch b before c."
590
+ },
591
+ {
592
+ "id": "0008-a-paused-gate-keeps-its-slot",
593
+ "title": "Serial: a node paused for a person holds the only slot, so its ready siblings are never dispatched while the person decides.",
594
+ "since": "0.25.0",
595
+ "tags": [
596
+ "serial",
597
+ "pause"
598
+ ],
599
+ "input": {
600
+ "schema": {
601
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
602
+ "version": 1,
603
+ "graph": {
604
+ "nodes": [
605
+ {
606
+ "id": "t",
607
+ "kind": "manual_trigger",
608
+ "position": {
609
+ "x": 0,
610
+ "y": 0
611
+ }
612
+ },
613
+ {
614
+ "id": "gate",
615
+ "kind": "human_approval",
616
+ "position": {
617
+ "x": 0,
618
+ "y": 0
619
+ }
620
+ },
621
+ {
622
+ "id": "b",
623
+ "kind": "log",
624
+ "position": {
625
+ "x": 0,
626
+ "y": 0
627
+ }
628
+ },
629
+ {
630
+ "id": "c",
631
+ "kind": "log",
632
+ "position": {
633
+ "x": 0,
634
+ "y": 0
635
+ }
636
+ }
637
+ ],
638
+ "edges": [
639
+ {
640
+ "id": "t-gate",
641
+ "source": "t",
642
+ "target": "gate"
643
+ },
644
+ {
645
+ "id": "t-b",
646
+ "source": "t",
647
+ "target": "b"
648
+ },
649
+ {
650
+ "id": "t-c",
651
+ "source": "t",
652
+ "target": "c"
653
+ }
654
+ ]
655
+ }
656
+ },
657
+ "maxConcurrent": 1,
658
+ "pauses": [
659
+ "gate"
660
+ ],
661
+ "publishes": {}
662
+ },
663
+ "expected": {
664
+ "trace": [
665
+ "dispatch t",
666
+ "complete t",
667
+ "dispatch gate",
668
+ "pause gate"
669
+ ],
670
+ "neverDispatched": [
671
+ "b",
672
+ "c"
673
+ ]
674
+ },
675
+ "notes": "The gap the ruling closes: b and c are never dispatched while the gate is paused."
676
+ },
677
+ {
678
+ "id": "0009-a-paused-gate-under-unlimited",
679
+ "title": "Unlimited: the gate and its siblings go out together, and the siblings complete while the gate waits.",
680
+ "since": "0.25.0",
681
+ "tags": [
682
+ "unlimited",
683
+ "pause"
684
+ ],
685
+ "input": {
686
+ "schema": {
687
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
688
+ "version": 1,
689
+ "graph": {
690
+ "nodes": [
691
+ {
692
+ "id": "t",
693
+ "kind": "manual_trigger",
694
+ "position": {
695
+ "x": 0,
696
+ "y": 0
697
+ }
698
+ },
699
+ {
700
+ "id": "gate",
701
+ "kind": "human_approval",
702
+ "position": {
703
+ "x": 0,
704
+ "y": 0
705
+ }
706
+ },
707
+ {
708
+ "id": "b",
709
+ "kind": "log",
710
+ "position": {
711
+ "x": 0,
712
+ "y": 0
713
+ }
714
+ },
715
+ {
716
+ "id": "c",
717
+ "kind": "log",
718
+ "position": {
719
+ "x": 0,
720
+ "y": 0
721
+ }
722
+ }
723
+ ],
724
+ "edges": [
725
+ {
726
+ "id": "t-gate",
727
+ "source": "t",
728
+ "target": "gate"
729
+ },
730
+ {
731
+ "id": "t-b",
732
+ "source": "t",
733
+ "target": "b"
734
+ },
735
+ {
736
+ "id": "t-c",
737
+ "source": "t",
738
+ "target": "c"
739
+ }
740
+ ]
741
+ }
742
+ },
743
+ "maxConcurrent": 0,
744
+ "pauses": [
745
+ "gate"
746
+ ],
747
+ "publishes": {}
748
+ },
749
+ "expected": {
750
+ "trace": [
751
+ "dispatch t",
752
+ "complete t",
753
+ "dispatch gate",
754
+ "dispatch b",
755
+ "dispatch c",
756
+ "pause gate",
757
+ "complete b",
758
+ "complete c"
759
+ ],
760
+ "neverDispatched": []
761
+ }
762
+ },
763
+ {
764
+ "id": "0010-a-paused-gate-counts-against-a-cap",
765
+ "title": "A cap of 2 with a paused gate: the gate keeps one slot after it pauses, so only one sibling runs at a time beside it.",
766
+ "since": "0.25.0",
767
+ "tags": [
768
+ "cap",
769
+ "pause"
770
+ ],
771
+ "input": {
772
+ "schema": {
773
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
774
+ "version": 1,
775
+ "graph": {
776
+ "nodes": [
777
+ {
778
+ "id": "t",
779
+ "kind": "manual_trigger",
780
+ "position": {
781
+ "x": 0,
782
+ "y": 0
783
+ }
784
+ },
785
+ {
786
+ "id": "gate",
787
+ "kind": "human_approval",
788
+ "position": {
789
+ "x": 0,
790
+ "y": 0
791
+ }
792
+ },
793
+ {
794
+ "id": "b",
795
+ "kind": "log",
796
+ "position": {
797
+ "x": 0,
798
+ "y": 0
799
+ }
800
+ },
801
+ {
802
+ "id": "c",
803
+ "kind": "log",
804
+ "position": {
805
+ "x": 0,
806
+ "y": 0
807
+ }
808
+ }
809
+ ],
810
+ "edges": [
811
+ {
812
+ "id": "t-gate",
813
+ "source": "t",
814
+ "target": "gate"
815
+ },
816
+ {
817
+ "id": "t-b",
818
+ "source": "t",
819
+ "target": "b"
820
+ },
821
+ {
822
+ "id": "t-c",
823
+ "source": "t",
824
+ "target": "c"
825
+ }
826
+ ]
827
+ }
828
+ },
829
+ "maxConcurrent": 2,
830
+ "pauses": [
831
+ "gate"
832
+ ],
833
+ "publishes": {}
834
+ },
835
+ "expected": {
836
+ "trace": [
837
+ "dispatch t",
838
+ "complete t",
839
+ "dispatch gate",
840
+ "dispatch b",
841
+ "pause gate",
842
+ "complete b",
843
+ "dispatch c",
844
+ "complete c"
845
+ ],
846
+ "neverDispatched": []
847
+ },
848
+ "notes": "Held counts the paused gate: c goes out only after b settles. An implementation that does not count a paused node dispatches c the moment the gate pauses."
849
+ },
850
+ {
851
+ "id": "0011-a-dead-branch-is-skipped-and-never-dispatched",
852
+ "title": "The branch publishes only `true`: the false side and everything below it is settled as skipped, and never takes a slot.",
853
+ "since": "0.25.0",
854
+ "tags": [
855
+ "serial",
856
+ "skip"
857
+ ],
858
+ "input": {
859
+ "schema": {
860
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
861
+ "version": 1,
862
+ "graph": {
863
+ "nodes": [
864
+ {
865
+ "id": "t",
866
+ "kind": "manual_trigger",
867
+ "position": {
868
+ "x": 0,
869
+ "y": 0
870
+ }
871
+ },
872
+ {
873
+ "id": "br",
874
+ "kind": "branch",
875
+ "position": {
876
+ "x": 0,
877
+ "y": 0
878
+ },
879
+ "config": {
880
+ "condition": true
881
+ }
882
+ },
883
+ {
884
+ "id": "y",
885
+ "kind": "log",
886
+ "position": {
887
+ "x": 0,
888
+ "y": 0
889
+ }
890
+ },
891
+ {
892
+ "id": "n",
893
+ "kind": "log",
894
+ "position": {
895
+ "x": 0,
896
+ "y": 0
897
+ }
898
+ },
899
+ {
900
+ "id": "n2",
901
+ "kind": "log",
902
+ "position": {
903
+ "x": 0,
904
+ "y": 0
905
+ }
906
+ }
907
+ ],
908
+ "edges": [
909
+ {
910
+ "id": "t-br",
911
+ "source": "t",
912
+ "target": "br"
913
+ },
914
+ {
915
+ "id": "br-y",
916
+ "source": "br",
917
+ "target": "y",
918
+ "sourceHandle": "true"
919
+ },
920
+ {
921
+ "id": "br-n",
922
+ "source": "br",
923
+ "target": "n",
924
+ "sourceHandle": "false"
925
+ },
926
+ {
927
+ "id": "n-n2",
928
+ "source": "n",
929
+ "target": "n2"
930
+ }
931
+ ]
932
+ }
933
+ },
934
+ "maxConcurrent": 1,
935
+ "publishes": {
936
+ "br": [
937
+ "true"
938
+ ]
939
+ },
940
+ "pauses": []
941
+ },
942
+ "expected": {
943
+ "trace": [
944
+ "dispatch t",
945
+ "complete t",
946
+ "dispatch br",
947
+ "complete br",
948
+ "skip n",
949
+ "skip n2",
950
+ "dispatch y",
951
+ "complete y"
952
+ ],
953
+ "neverDispatched": []
954
+ }
955
+ },
956
+ {
957
+ "id": "0012-a-note-is-settled-not-dispatched",
958
+ "title": "A sticky note is never executed: it settles as skipped without taking a slot.",
959
+ "since": "0.25.0",
960
+ "tags": [
961
+ "serial",
962
+ "note"
963
+ ],
964
+ "input": {
965
+ "schema": {
966
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
967
+ "version": 1,
968
+ "graph": {
969
+ "nodes": [
970
+ {
971
+ "id": "t",
972
+ "kind": "manual_trigger",
973
+ "position": {
974
+ "x": 0,
975
+ "y": 0
976
+ }
977
+ },
978
+ {
979
+ "id": "memo",
980
+ "kind": "note",
981
+ "position": {
982
+ "x": 0,
983
+ "y": 0
984
+ }
985
+ },
986
+ {
987
+ "id": "a",
988
+ "kind": "log",
989
+ "position": {
990
+ "x": 0,
991
+ "y": 0
992
+ }
993
+ }
994
+ ],
995
+ "edges": [
996
+ {
997
+ "id": "t-a",
998
+ "source": "t",
999
+ "target": "a"
1000
+ }
1001
+ ]
1002
+ }
1003
+ },
1004
+ "maxConcurrent": 1,
1005
+ "pauses": [],
1006
+ "publishes": {}
1007
+ },
1008
+ "expected": {
1009
+ "trace": [
1010
+ "skip memo",
1011
+ "dispatch t",
1012
+ "complete t",
1013
+ "dispatch a",
1014
+ "complete a"
1015
+ ],
1016
+ "neverDispatched": []
1017
+ }
1018
+ },
1019
+ {
1020
+ "id": "0013-two-entry-points-take-turns",
1021
+ "title": "Two triggers with nothing between them: serial dispatches them one after the other, in declaration order.",
1022
+ "since": "0.25.0",
1023
+ "tags": [
1024
+ "serial",
1025
+ "entry-points"
1026
+ ],
1027
+ "input": {
1028
+ "schema": {
1029
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
1030
+ "version": 1,
1031
+ "graph": {
1032
+ "nodes": [
1033
+ {
1034
+ "id": "t1",
1035
+ "kind": "manual_trigger",
1036
+ "position": {
1037
+ "x": 0,
1038
+ "y": 0
1039
+ }
1040
+ },
1041
+ {
1042
+ "id": "t2",
1043
+ "kind": "manual_trigger",
1044
+ "position": {
1045
+ "x": 0,
1046
+ "y": 0
1047
+ }
1048
+ },
1049
+ {
1050
+ "id": "a",
1051
+ "kind": "log",
1052
+ "position": {
1053
+ "x": 0,
1054
+ "y": 0
1055
+ }
1056
+ },
1057
+ {
1058
+ "id": "b",
1059
+ "kind": "log",
1060
+ "position": {
1061
+ "x": 0,
1062
+ "y": 0
1063
+ }
1064
+ }
1065
+ ],
1066
+ "edges": [
1067
+ {
1068
+ "id": "t1-a",
1069
+ "source": "t1",
1070
+ "target": "a"
1071
+ },
1072
+ {
1073
+ "id": "t2-b",
1074
+ "source": "t2",
1075
+ "target": "b"
1076
+ }
1077
+ ]
1078
+ }
1079
+ },
1080
+ "maxConcurrent": 1,
1081
+ "pauses": [],
1082
+ "publishes": {}
1083
+ },
1084
+ "expected": {
1085
+ "trace": [
1086
+ "dispatch t1",
1087
+ "complete t1",
1088
+ "dispatch t2",
1089
+ "complete t2",
1090
+ "dispatch a",
1091
+ "complete a",
1092
+ "dispatch b",
1093
+ "complete b"
1094
+ ],
1095
+ "neverDispatched": []
1096
+ }
1097
+ },
1098
+ {
1099
+ "id": "0014-a-cap-is-measured-against-held-work-not-the-batch",
1100
+ "title": "A cap of 2 over four siblings: when one settles, ONE more goes out, because the other is still held. A cap applied per batch would dispatch two.",
1101
+ "since": "0.25.0",
1102
+ "tags": [
1103
+ "cap",
1104
+ "budget"
1105
+ ],
1106
+ "input": {
1107
+ "schema": {
1108
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
1109
+ "version": 1,
1110
+ "graph": {
1111
+ "nodes": [
1112
+ {
1113
+ "id": "t",
1114
+ "kind": "manual_trigger",
1115
+ "position": {
1116
+ "x": 0,
1117
+ "y": 0
1118
+ }
1119
+ },
1120
+ {
1121
+ "id": "a",
1122
+ "kind": "log",
1123
+ "position": {
1124
+ "x": 0,
1125
+ "y": 0
1126
+ }
1127
+ },
1128
+ {
1129
+ "id": "b",
1130
+ "kind": "log",
1131
+ "position": {
1132
+ "x": 0,
1133
+ "y": 0
1134
+ }
1135
+ },
1136
+ {
1137
+ "id": "c",
1138
+ "kind": "log",
1139
+ "position": {
1140
+ "x": 0,
1141
+ "y": 0
1142
+ }
1143
+ },
1144
+ {
1145
+ "id": "d",
1146
+ "kind": "log",
1147
+ "position": {
1148
+ "x": 0,
1149
+ "y": 0
1150
+ }
1151
+ }
1152
+ ],
1153
+ "edges": [
1154
+ {
1155
+ "id": "t-a",
1156
+ "source": "t",
1157
+ "target": "a"
1158
+ },
1159
+ {
1160
+ "id": "t-b",
1161
+ "source": "t",
1162
+ "target": "b"
1163
+ },
1164
+ {
1165
+ "id": "t-c",
1166
+ "source": "t",
1167
+ "target": "c"
1168
+ },
1169
+ {
1170
+ "id": "t-d",
1171
+ "source": "t",
1172
+ "target": "d"
1173
+ }
1174
+ ]
1175
+ }
1176
+ },
1177
+ "maxConcurrent": 2,
1178
+ "pauses": [],
1179
+ "publishes": {}
1180
+ },
1181
+ "expected": {
1182
+ "trace": [
1183
+ "dispatch t",
1184
+ "complete t",
1185
+ "dispatch a",
1186
+ "dispatch b",
1187
+ "complete a",
1188
+ "dispatch c",
1189
+ "complete b",
1190
+ "dispatch d",
1191
+ "complete c",
1192
+ "complete d"
1193
+ ],
1194
+ "neverDispatched": []
1195
+ },
1196
+ "notes": "A per-batch cap dispatches c AND d when a settles, putting three in flight under a cap of 2."
1197
+ }
1198
+ ]
1199
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/durable-dispatch",
4
+ "title": "A queued run hands out one node at a time unless the host asks for more",
5
+ "since": "0.25.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "dispatchTrace(schema: WorkflowSchema, maxConcurrent: int, publishes: { [nodeId]: string[] }, pauses: string[]) -> { trace: string[], neverDispatched: string[] }",
10
+ "summary": "Simulate a durable (queued, per-node) run with the runtime's OWN frontier and dispatch-selection functions, and no engine execution. Import the schema leniently against a local built-in kind registry with the structural kinds. Let `limit` be null when `maxConcurrent` is 0 (unlimited), else `maxConcurrent`. Start with empty node state, an empty FIFO `inFlight` and an empty `trace`. Repeat: (1) compute the frontier from the state; record every `skipped` node as SKIPPED with no ports and append `skip <id>`; (2) SELECT from `ready` (declaration order): with a null limit take all of them, else take the first `limit - held`, where `held` counts nodes whose state is CLAIMED or PAUSED; mark each selected node CLAIMED, push it onto `inFlight` and append `dispatch <id>`; (3) if `inFlight` is empty, stop; (4) shift the first node off `inFlight`: when it is in `pauses`, record it PAUSED (it keeps its slot) and append `pause <id>`; otherwise record it COMPLETED publishing `publishes[id]` (default [\"out\"]) and append `complete <id>`. `neverDispatched` is every node, in declaration order, that never entered the state. The TRACE, not a list of batches, is pinned: a batch list cannot tell a node dispatched the moment a gate paused from one dispatched after a sibling settled, and that is the difference between counting a paused node as held and not.",
11
+ "reference": "php",
12
+ "referenceNote": "Goldens produced by fancy-flow-php 0.54.0's `FancyFlow\\Laravel\\Runs\\Frontier::compute` and `FancyFlow\\Laravel\\Runs\\DispatchLimit::select`, then reviewed row by row. Written for fancy-flow-php#17, which made serial the default.",
13
+ "implementations": [
14
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Laravel\\Runs\\DispatchLimit" },
15
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "selectDispatch" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.durable.select_dispatch" }
17
+ ]
18
+ },
19
+ "notes": [
20
+ "THE OWNER'S RULING, relayed from the estate's largest consumer: nodes never queue at the same time; a node is added to the worker queue only after the previous node finishes, and that is fancy-flow's DEFAULT. Parallel dispatch of a ready frontier is an explicit opt-in. Rows 0001, 0004, 0005, 0007, 0008, 0011, 0012 and 0013 are the default; 0002, 0003, 0006, 0009, 0010 and 0014 are what a host gets only by asking.",
21
+ "SERIAL IS maxConcurrent 1, UNLIMITED IS 0. Every coordinator defaults to 1. A positive integer caps how many of one run's nodes are held at once; 0 is the whole ready frontier, exported under a name (`DispatchLimit::UNLIMITED`, `UNLIMITED_CONCURRENCY`) so a host never writes a bare 0. A negative limit is refused where it is set: under a serial default, a typo that silently turned a run parallel is the failure to avoid.",
22
+ "HELD MEANS CLAIMED OR PAUSED. A node paused for a person keeps its slot, so a gate never opens a gap for a sibling to queue alongside it while the person decides (0008, and 0010 under a cap). On fancy-flow-php a pause also parks the whole run, so this changes nothing observable there; on the TS and Python coordinators a pause does NOT park the run, and without this rule a later advance would dispatch the gate's siblings.",
23
+ "ORDER IS DECLARATION ORDER AMONG WHAT IS READY NOW, not breadth-first. 0007 is the discriminating row: `c` becomes ready after `a` settles and is declared before the still-waiting `b`, so it goes first. An implementation that queues nodes in the order they became ready fails it. 0004 pins that the edge list's order does not matter.",
24
+ "THE BUDGET IS MEASURED AGAINST WORK ALREADY HELD, not the size of one batch. Two nodes settling at once each trigger an advance on a real queue; a per-batch cap lets each dispatch its own quota. The simulation cannot race, so 0014 and 0010 pin the held-count arithmetic directly, and each runtime's own suite pins the race.",
25
+ "WORKERS ARE FIFO AND SETTLE ONE AT A TIME. That makes every row deterministic without pretending to model a real queue's timing. It is a model of the decision, not of the transport.",
26
+ "SKIPS AND NOTES NEVER TAKE A SLOT (0011, 0012). The frontier settles a dead branch, everything below it, and a sticky note in the same pass, before selection.",
27
+ "RUST IS NOT LISTED: fancy-flow-rs has no durable coordinator. Whether it gets one is an open decision recorded in the envelope's .ai/plans/fancy-flow-serial-dispatch.md.",
28
+ "THERE IS A DISCRIMINATION PROBE (tests/discrimination-durable-dispatch.test.ts): a faithful frontier + selection passes every row, and four mutants each fail an exact set -- a paused node not held (0008, 0010), a per-batch cap (0008, 0010, 0014), the pre-#17 default (0001, 0004, 0005, 0007, 0008, 0013) and breadth-first order (0007). Writing it is what turned the goldens from dispatch batches into a trace: the batch shape let the first two mutants through."
29
+ ]
30
+ }