fyflow-orchestration 0.9.0 → 0.10.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/AGENTS.md +160 -24
- package/README.md +1 -1
- package/dist/browser/chunks/{chunk-3U4UZC34.js → chunk-NA6QQAZ2.js} +469 -113
- package/dist/browser/index.js +5 -1
- package/dist/browser/scheduler/index.js +13 -2
- package/dist/node/chunks/{chunk-3U4UZC34.js → chunk-NA6QQAZ2.js} +469 -113
- package/dist/node/index.js +5 -1
- package/dist/node/scheduler/index.js +13 -2
- package/dist/types/src/definition/regions.d.ts +12 -0
- package/dist/types/src/driver/driver.d.ts +185 -10
- package/dist/types/src/scheduler/runtime.d.ts +17 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -348,6 +348,22 @@ that object and nothing else — there is no wrapper and no reserved key.
|
|
|
348
348
|
| `["someNode"]` | that node's results as an **array**, in ascending branch-path order. Legal only when you are OUTSIDE a region that node is in — which is what makes it more than one |
|
|
349
349
|
| **a `?` on either of those** — `"someNode?"`, `["someNode?"]` | the same thing, except that **nothing to give leaves the key out** (or gives `[]` for the array form) instead of stopping the run. This is how you say *if it ran* |
|
|
350
350
|
|
|
351
|
+
**What a reference hands you is ONE shared, frozen copy — read it, never write it** — from 0.10.0 ([ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md),
|
|
352
|
+
`spec/flow-definition.md` v0.47). The value under a `"someNode"` or `["someNode"]` key (either with `?`) is ONE deep copy
|
|
353
|
+
of that output, made the first time anything reads it and handed to EVERY reader of it — every instance of a `map`, every
|
|
354
|
+
node after it, and every edge predicate (`ctx.needs`, section 4) — and a `"$payload"` key is one such copy of the run's
|
|
355
|
+
payload for the whole run. The object `needs` composes is still yours, a `["someNode"]` array is a fresh one per read, and
|
|
356
|
+
`"."` and `"$item"` are still your own copies. **The copy is FROZEN where JavaScript can freeze**: plain objects and arrays
|
|
357
|
+
at every depth, so writing into one throws `TypeError` in strict code — an **inline** worker that does so fails that
|
|
358
|
+
attempt (your `retryPolicy` decides what follows), and a **predicate** in strict code that does so stops the run (in
|
|
359
|
+
sloppy code the write is silently dropped). A threaded worker
|
|
360
|
+
writes into its own clone, as before. **What freezing cannot protect is yours to leave alone**: a `Map`, `Set`, `Date`
|
|
361
|
+
or `ArrayBuffer` in a result is frozen only shallowly (`set`, `add`, `setTime` and a write through a view still work, and
|
|
362
|
+
a `Map`'s keys and values, a `Set`'s members and an `Error`'s `cause` are not frozen), and a typed array, `DataView` or
|
|
363
|
+
`RegExp` is not frozen at all (a frozen `RegExp` could not run its own `test` or `exec`) — an inline worker writing into one changes what every other reader of
|
|
364
|
+
that output sees. Copy before you change anything a reference handed you. *(Until 0.10.0 every read was its own deep
|
|
365
|
+
copy, so such a write landed on yours alone — and a reference to a large result inside a `map` cost one copy per item.)*
|
|
366
|
+
|
|
351
367
|
**Which of the two aimed forms is legal is decided by the graph and refused at construction**
|
|
352
368
|
(`FD-V28`), so you are told which to write rather than discovering the shape at run time. The message
|
|
353
369
|
names the form to use instead.
|
|
@@ -458,14 +474,15 @@ in W50* for exactly as long as that was true.)*
|
|
|
458
474
|
And a reference names a **node**, never a path inside its value: `needs: { run: 'ingest' }` gives you
|
|
459
475
|
the whole of `ingest`'s result and your worker reads `.config` off it.
|
|
460
476
|
|
|
461
|
-
**And inside a `map`, what it costs is what you point it at.**
|
|
462
|
-
each key, so a reference to a large result
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
477
|
+
**And inside a `map`, what it costs is what you point it at.** From 0.10.0 that is ONE copy of it, whatever the
|
|
478
|
+
arity (above). Until 0.10.0 each instance got its OWN COPY of each key, so a reference to a large result was copied
|
|
479
|
+
once per item, every copy live at once: fyflow-app's *fetch a list, then map over it* at 4,000 items cost the driver
|
|
480
|
+
alone 15.5 s and 1.4 GB in 0.9.1, and 0.09 s and 15 MB from 0.10.0 (node 24). Measured at a map arity of 1000 over a
|
|
481
|
+
~126 KiB result, the driver alone, **one case per process**: no `needs` **24.4 ms**, `{item: '.'}` **25.3 ms**, one
|
|
482
|
+
more key naming a genuinely small node **28.0 ms**, and the same key naming the large one **30.5 ms** — **1.1x**, where
|
|
483
|
+
it was 17x in 0.9.1 on the same machine — and the `?` suffix costs nothing (**28.3 ms**). The mechanism makes one copy
|
|
484
|
+
per output read, whoever reads it. A reference still names a whole node and never a path inside it — a path
|
|
485
|
+
language would make *the small part of the big thing* a selector's business rather than the document's. *(These five figures are compared to `spec/flow-definition.md` section 3's own table by
|
|
469
486
|
`check-docs`' `X6`, both ways — three documents carried them and nothing compared any two, which is
|
|
470
487
|
`doc/workflow.md` section 5 rule 8 obligation (e)'s third shape.)*
|
|
471
488
|
|
|
@@ -475,7 +492,19 @@ including the flow it exists for, so it restates the definition rather than diag
|
|
|
475
492
|
a report channel exists (`onUnmintedTask`'s shape) and is declined because *this reference is resolved
|
|
476
493
|
once per item* is a fact about your document rather than about your run. **Measuring is not the
|
|
477
494
|
obstacle** — a size measurement is about 0.03% of what it would warn about at arity 1000.
|
|
478
|
-
`spec/flow-definition.md` section 3 carries the whole verdict and the measurements.
|
|
495
|
+
`spec/flow-definition.md` section 3 carries the whole verdict and the measurements. **From 0.10.0 there is nothing
|
|
496
|
+
left to warn about**: the per-item copy that decision was about is gone ([Q186](doc/questions/q186.md), resolved by
|
|
497
|
+
[ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md)), and the decision stands as written.
|
|
498
|
+
|
|
499
|
+
**Finding what a reference names costs its ANSWER** — from 0.9.1
|
|
500
|
+
([ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md), `spec/flow-definition.md` v0.46). A branch task
|
|
501
|
+
may read any earlier task of its own `map` instance by id — one step back or several, where `"."` reaches only its
|
|
502
|
+
predecessor — and the read takes that one output, however many instances the map has: the driver files a node's outputs
|
|
503
|
+
by the branch-path prefix its readers share. In 0.9.0 every read searched every output of the node it names, so such a
|
|
504
|
+
map cost O(items²) (the driver alone at 8,000 items: 22 s in 0.9.0, 0.22 s from 0.9.1, node 24). **A read never costs
|
|
505
|
+
less than its answer**: `["n"]` from a reader outside every region `n` is in — a SIBLING map's reader — is handed every
|
|
506
|
+
output of `n`, so two maps of `n` and `m` instances compose `n·m` entries — from 0.10.0 `m` fresh arrays of the same `n`
|
|
507
|
+
shared copies, so the price is the arrays, not `n·m` clones. That is the answer's price, and the remedy is the cliff's.
|
|
479
508
|
|
|
480
509
|
### `"$payload"` — what reached the RUN, from any node
|
|
481
510
|
|
|
@@ -488,14 +517,14 @@ that passed it through.
|
|
|
488
517
|
|
|
489
518
|
| | |
|
|
490
519
|
|---|---|
|
|
491
|
-
| what it is | the copy the run's checkpoint HEADER keeps (`header.entryPayload`, section 18) — so a caller changing its payload object after `start()` changes nothing.
|
|
520
|
+
| what it is | the copy the run's checkpoint HEADER keeps (`header.entryPayload`, section 18) — so a caller changing its payload object after `start()` changes nothing. From 0.10.0 every `"$payload"` key of the run, at every node and edge, is ONE deep-frozen copy of it, made at the first read — a worker or a predicate writing into it throws `TypeError` and reaches nothing (above). *(Until 0.10.0 each key was its own deep copy, per read.)* |
|
|
492
521
|
| where it is legal | every node and every edge — at the entry, on a task, it equals `"."`, and both spellings are accepted. **On an edge it is NOT refused** the way `"."` is: a predicate has no `ctx.payload`, so `needs: { run: '$payload' }` is how a decision routes on `needs.run.amount` with no task before it |
|
|
493
522
|
| always there | no `?` and no array form: `["$payload"]` and `"$payload?"` are `FD-V26`, each naming the bare form. The whole `$` prefix is reserved — `$` is outside the node-id charset (`FD-V16`), so the token can never collide with a node — and any other `$` spelling (`"$input"`, a typo) is `FD-V26` naming the two `$` references |
|
|
494
523
|
| `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
|
|
495
524
|
| on a resume | the same value, from the stored header: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT` stays 1) |
|
|
496
525
|
| at `start()` | `FD-R4` holds a task's composed `"$payload"` key to its worker's `inputSchema` like any other key (section 9) — composed from the value you passed, before the run exists |
|
|
497
526
|
| in the wiring check | an arc composed through a `"$payload"` key is `unchecked`: the run's payload is a value no schema declares (section 15) |
|
|
498
|
-
| what it costs |
|
|
527
|
+
| what it costs | ONE copy of the whole payload per RUN, from 0.10.0 — however many instances of a `map` read it. *(Until 0.10.0 one copy per key per read: inside a `map`, once per instance — the cliff above at its largest.)* On an ASK the copy is also written into the run's LOG: the `asked` element keeps what reached the ask (section 7) |
|
|
499
528
|
|
|
500
529
|
**`givenVerdict` and `tryTransform(…, { input })` compose nothing** (section 15): the value you give them IS what the node
|
|
501
530
|
receives, so a node declaring `needs: { run: "$payload" }` is given `{ run: <the payload> }` — composing it is yours, as
|
|
@@ -514,7 +543,7 @@ const scheduler = {
|
|
|
514
543
|
addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
|
|
515
544
|
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
516
545
|
};
|
|
517
|
-
const
|
|
546
|
+
const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
|
|
518
547
|
|
|
519
548
|
const flow: FlowDefinition = {
|
|
520
549
|
flowId: 'pay-invoice', version: 1, entry: 'approve',
|
|
@@ -545,9 +574,9 @@ driver.start(invoice);
|
|
|
545
574
|
invoice.amount = 1; // yours to change: the run holds its own copy
|
|
546
575
|
|
|
547
576
|
driver.resolve('r1:approve:-:0', 'done', { result: { approved: true }, actor: 'ana' });
|
|
548
|
-
console.log(
|
|
549
|
-
if (JSON.stringify(
|
|
550
|
-
throw new Error(`pay received ${JSON.stringify(
|
|
577
|
+
console.log(handedInput('r1:pay:-:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
|
|
578
|
+
if (JSON.stringify(handedInput('r1:pay:-:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
|
|
579
|
+
throw new Error(`pay received ${JSON.stringify(handedInput('r1:pay:-:0'))}`);
|
|
551
580
|
}
|
|
552
581
|
|
|
553
582
|
// `pay` settles; its edge reads `needs.run.amount` — 120, the run's — and routes to the audit.
|
|
@@ -605,7 +634,7 @@ const scheduler = {
|
|
|
605
634
|
addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
|
|
606
635
|
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
607
636
|
};
|
|
608
|
-
const
|
|
637
|
+
const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
|
|
609
638
|
|
|
610
639
|
const flow: FlowDefinition = {
|
|
611
640
|
flowId: 'review-each', version: 1, entry: 'each',
|
|
@@ -640,12 +669,12 @@ payload.invoices[0]!.amount = 1; // yours to change: the instance keeps its ow
|
|
|
640
669
|
// Two asks parked, one per invoice. Answered out of order, each `pay` gets ITS instance's invoice.
|
|
641
670
|
driver.resolve('r1:review:1:0', 'done', { result: { approved: true }, actor: 'bo' });
|
|
642
671
|
driver.resolve('r1:review:0:0', 'done', { result: { approved: true }, actor: 'ana' });
|
|
643
|
-
console.log(
|
|
644
|
-
if (JSON.stringify(
|
|
645
|
-
throw new Error(`pay@0 received ${JSON.stringify(
|
|
672
|
+
console.log(handedInput('r1:pay:0:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
|
|
673
|
+
if (JSON.stringify(handedInput('r1:pay:0:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
|
|
674
|
+
throw new Error(`pay@0 received ${JSON.stringify(handedInput('r1:pay:0:0'))}`);
|
|
646
675
|
}
|
|
647
|
-
if (JSON.stringify(
|
|
648
|
-
throw new Error(`pay@1 received ${JSON.stringify(
|
|
676
|
+
if (JSON.stringify(handedInput('r1:pay:1:0')) !== '{"invoice":{"id":"INV-2","amount":40},"answer":{"approved":true}}') {
|
|
677
|
+
throw new Error(`pay@1 received ${JSON.stringify(handedInput('r1:pay:1:0'))}`);
|
|
649
678
|
}
|
|
650
679
|
|
|
651
680
|
// Each `pay` settles; its edge reads `needs.it.amount` — 120 audits, 40 does not.
|
|
@@ -901,7 +930,9 @@ instance, from what the instance KEEPS**: its item, while a node of it may still
|
|
|
901
930
|
(section 3).
|
|
902
931
|
|
|
903
932
|
**`ctx.needs` is `{}` when the edge declares nothing**, never `undefined`; it is **frozen**, so a
|
|
904
|
-
predicate that writes to it throws rather than editing what the next predicate reads
|
|
933
|
+
predicate that writes to it throws rather than editing what the next predicate reads — and from 0.10.0 so does a
|
|
934
|
+
write INTO a value under a reference or `"$payload"` key, the run's one shared frozen copy (section 3), and a predicate
|
|
935
|
+
that throws stops the run; and a reference
|
|
905
936
|
the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, section 9) exactly as a
|
|
906
937
|
node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
|
|
907
938
|
and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
|
|
@@ -1743,7 +1774,8 @@ need the state; step only where you hold a short one. Both return exactly what t
|
|
|
1743
1774
|
**The driver's own work per settlement does not grow with the run either** — `RN-R13` v0.30, from 0.9.0
|
|
1744
1775
|
([ADR 0121](doc/decisions/0121-the-driver-keeps-a-per-task-view-of-its-log.md)): every question it asks of its log about
|
|
1745
1776
|
ONE task — a settlement's effective outcome, whether a task is parked, what is in flight, what a `needs` can reach — reads
|
|
1746
|
-
that task's records alone
|
|
1777
|
+
that task's records alone (and from 0.9.1 a `needs` read takes the outputs it is handed rather than searching its node's —
|
|
1778
|
+
`RN-R13` v0.32, [ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md); and from 0.10.0 it is handed ONE shared copy of each output, made at the first read and kept for the run, rather than a clone per read — `RN-R13` v0.33, [ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md)), where 0.8.0 scanned the whole log (a 16,000-item `map` driven on a scheduler double: 999 ms in
|
|
1747
1779
|
0.8.0, 229 ms from 0.9.0, node 24). `snapshot()`, `foldInputs()` and `checkpoint()` still hand you a copy of the whole run,
|
|
1748
1780
|
O(run) a call: read them when you need the run, not after every event.
|
|
1749
1781
|
|
|
@@ -1904,6 +1936,7 @@ goes no longer folds it a second time in its own `onFoldInput`:
|
|
|
1904
1936
|
| `handle.outcome()` | the run's outcome now | O(1) — **the read to make after every input** |
|
|
1905
1937
|
| `handle.node(taskId)` | that task's `nodes` entry now — the same frozen object `state()` holds — or `null` for a task the run never dispatched | O(1) in the run — its first read of an entry freezes that entry once |
|
|
1906
1938
|
| `handle.state()` | the whole `RunState` now: a COPY, **frozen at every depth**, and the SAME object on every read until an input moves the run | O(run) for the first read after each input that moved it, nothing after — **so reading it after EVERY input costs O(run²) over a run**: read it when you draw the run |
|
|
1939
|
+
| `handle.inputOf(target)` | what a node of the run was HANDED — a task's envelope, any other node's input (since 0.10.0, *What a node was HANDED* below) | O(the run so far) per call — a replay, never cached: read it when a person asks |
|
|
1907
1940
|
|
|
1908
1941
|
**At a quiescent point `handle.state()` IS `fold(handle.driver.foldInputs())`** — the same rule as above: read on a
|
|
1909
1942
|
`queueMicrotask` from `onFoldInput`, or outside the driver's turn. **It is not a live view** — a read is the state at that
|
|
@@ -1964,6 +1997,108 @@ try { (end.nodes[a]!.result as { score: number }).score = 1; } catch { wrote = f
|
|
|
1964
1997
|
if (wrote) throw new Error('a write into the run`s state must throw — it is a copy to read, not a document to edit');
|
|
1965
1998
|
```
|
|
1966
1999
|
|
|
2000
|
+
### What a node was HANDED — `inputOf` and `handle.inputOf`, after the fact
|
|
2001
|
+
|
|
2002
|
+
**A run keeps what its tasks RETURNED, never what they were handed** — `RunState` holds an ask's input (`NodeRun.input`)
|
|
2003
|
+
and no task's ([ADR 0046](doc/decisions/0046-a-run-retains-what-its-tasks-returned-and-a-region-releases-its-collection-when-its-join-becomes-terminal.md)),
|
|
2004
|
+
and no function over `RunState` can rebuild it exactly: the entry's payload, the edge each node took, what was visible at
|
|
2005
|
+
the moment of dispatch and a gate's memory are not in it. **From 0.10.0 you READ it instead** (`RN-R45`,
|
|
2006
|
+
[ADR 0128](doc/decisions/0128-what-a-node-was-handed-is-read-by-replaying-the-run-to-its-composition.md)):
|
|
2007
|
+
|
|
2008
|
+
```
|
|
2009
|
+
inputOf(flow, options, { header, events }, target) // the root export — a stored run, its runtime gone or not
|
|
2010
|
+
handle.inputOf(target) // a live run: the same function over its own checkpoint()
|
|
2011
|
+
```
|
|
2012
|
+
|
|
2013
|
+
It builds a driver for the stored run — DEAF: no callback, nothing handed to any scheduler — and REPLAYS the stored
|
|
2014
|
+
events, exactly as `resume()` does, until the target's composition. **So the answer is the driver's own composition,
|
|
2015
|
+
not a second one**, and costs the run's prefix up to the target. `options` is what the run's driver was built from:
|
|
2016
|
+
`runtime.registry`, plus its `joinPolicy` where you did not use `liveToken`.
|
|
2017
|
+
|
|
2018
|
+
| target | the node is | `inputs` |
|
|
2019
|
+
|---|---|---|
|
|
2020
|
+
| `{ taskId }` or `{ nodeId, branchPath }` | a **task** | one `envelope`: `{ settings, input, transform? }` — what the driver handed `createTask`, BEFORE any transform; a retry and a resolution share it |
|
|
2021
|
+
| the same | an **ask** | one `received` — equal to its `asked` element's `input` |
|
|
2022
|
+
| `{ nodeId, branchPath }` | a **gate**, **fanout**, **map** or **end** | one `received` — what reached it on that path; an end's path is the branch that reached it |
|
|
2023
|
+
| `{ nodeId, branchPath }` | a **join**, at an ARRIVING branch's path | one `arrival` — what that branch carried in, a late arrival after an early firing included |
|
|
2024
|
+
| `{ nodeId, branchPath }` | a **join**, at its OPENER's path | one `collection` — what it handed on when it fired |
|
|
2025
|
+
| `{ nodeId }` | anything | EVERY one of the above the run reached, in the order it reached them — the paths an END was reached on included, which nothing else names |
|
|
2026
|
+
|
|
2027
|
+
A path target answers the FIRST composition on its path: an end shared by two sibling regions can be reached twice on one
|
|
2028
|
+
path, and only `{ nodeId }` lists both.
|
|
2029
|
+
|
|
2030
|
+
**It never throws for the run or the target** — only for a flow or a registry no driver can be built from, as `new Driver` throws — `{ ok: false, reason, index, message }`: `unknown` (a target this run's
|
|
2031
|
+
ids cannot name), `not-reached`, `diverged` (the replay produced something other than a stored element BEFORE the
|
|
2032
|
+
target — a changed predicate or definition), and `format` / `flow` (the header, refused as a resume refuses it). **A
|
|
2033
|
+
halted, cancelled or stopped run IS read**, up to its halt. **What it cannot see**: a change to the flow or the registry
|
|
2034
|
+
AFTER the target — or one that moves only an envelope, even at the target: the events carry no payloads, so a reading
|
|
2035
|
+
answers with the definition you hand it. **It re-runs your predicates and gate types**, as a resume does. The value is
|
|
2036
|
+
the reading's own — a value a `needs` reference composed is frozen, as every reader's is (section 3), the rest yours —
|
|
2037
|
+
and nothing of a live run is reachable through it. **The input a task's worker sees AFTER its transform is not answered**
|
|
2038
|
+
([Q760](doc/questions/q760.md)): hand the envelope's `input` to `tryTransform` (section 15). **BREAKING in 0.10.0**:
|
|
2039
|
+
`RunHandle` gains `inputOf`, which breaks only code that BUILDS a handle.
|
|
2040
|
+
|
|
2041
|
+
```ts run
|
|
2042
|
+
import { Driver, inputOf, liveToken, type FlowDefinition, type InputOfOptions } from 'fyflow-orchestration';
|
|
2043
|
+
|
|
2044
|
+
// A scheduler double, section 12's shape: it records what it is handed and fires only what we tell it to.
|
|
2045
|
+
const added: { id: string; payload: unknown }[] = [];
|
|
2046
|
+
const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
|
|
2047
|
+
const scheduler = {
|
|
2048
|
+
addTask(task: unknown) { added.push(task as { id: string; payload: unknown }); },
|
|
2049
|
+
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
2050
|
+
};
|
|
2051
|
+
const settle = (id: string, result: unknown) => {
|
|
2052
|
+
const detail = { id, state: 'done', result, attempts: 0, timestamp: 1, startTime: 1, endTime: 2, executionTime: 1 };
|
|
2053
|
+
for (const fn of listeners.get('task.completed') ?? []) fn({ detail });
|
|
2054
|
+
};
|
|
2055
|
+
|
|
2056
|
+
// A map over invoices; each check reads its invoice and the run's payload; a rejected one ENDS in the map.
|
|
2057
|
+
const flow: FlowDefinition = {
|
|
2058
|
+
flowId: 'invoices', version: 1, entry: 'fetch',
|
|
2059
|
+
nodes: [
|
|
2060
|
+
{ id: 'fetch', kind: 'task', type: 'Fetch', edges: [{ to: 'each' }] },
|
|
2061
|
+
{ id: 'each', kind: 'map', over: 'invoices', branch: 'check' },
|
|
2062
|
+
{ id: 'check', kind: 'task', type: 'Check', needs: { invoice: '.', run: '$payload' }, edges: [
|
|
2063
|
+
{ when: { path: 'outcome.result.ok', op: 'eq', value: false }, to: 'rejected' },
|
|
2064
|
+
{ to: 'joined' },
|
|
2065
|
+
] },
|
|
2066
|
+
{ id: 'rejected', kind: 'end', outcome: 'rejected' },
|
|
2067
|
+
{ id: 'joined', kind: 'join', of: 'each', edges: [{ to: 'done' }] },
|
|
2068
|
+
{ id: 'done', kind: 'end', outcome: 'completed' },
|
|
2069
|
+
],
|
|
2070
|
+
};
|
|
2071
|
+
// What the run's driver was built from — and so what a reading builds its own from (joinPolicy: liveToken by default).
|
|
2072
|
+
const options: InputOfOptions = { predicates: new Map(), workers: { w: { url: 'worker.js' } }, nodeTypes: { Fetch: { worker: 'w' }, Check: { worker: 'w' } } };
|
|
2073
|
+
const driver = new Driver(flow, { runId: 'r1', scheduler, ...options, joinPolicy: liveToken });
|
|
2074
|
+
driver.start({ tenant: 't1' });
|
|
2075
|
+
settle('r1:fetch:-:0', { invoices: [{ id: 'INV-1' }, { id: 'INV-2' }, { id: 'INV-3' }] });
|
|
2076
|
+
settle('r1:check:2:0', { ok: false });
|
|
2077
|
+
settle('r1:check:0:0', { ok: true });
|
|
2078
|
+
settle('r1:check:1:0', { ok: false });
|
|
2079
|
+
|
|
2080
|
+
// What a store keeps: the header and the events (section 18). Read them back — the run's process may be long gone.
|
|
2081
|
+
const stored = driver.checkpoint();
|
|
2082
|
+
const task = inputOf(flow, options, stored, { taskId: 'r1:check:1:0' });
|
|
2083
|
+
console.log(task.ok && task.inputs[0]!.value); // { settings: {}, input: { invoice: { id: 'INV-2' }, run: { tenant: 't1' } } }
|
|
2084
|
+
const ends = inputOf(flow, options, stored, { nodeId: 'rejected' });
|
|
2085
|
+
console.log(ends.ok && ends.inputs.map((i) => [i.branchPath, i.value])); // [['2', { ok: false }], ['1', { ok: false }]]
|
|
2086
|
+
const nope = inputOf(flow, options, stored, { nodeId: 'audit' });
|
|
2087
|
+
console.log(nope.ok ? 'answered' : nope.reason); // 'unknown'
|
|
2088
|
+
|
|
2089
|
+
// The answer IS what the driver handed the scheduler — the same envelope, built by the same code.
|
|
2090
|
+
const handed = added.find((t) => t.id === 'r1:check:1:0')!.payload;
|
|
2091
|
+
if (!task.ok || JSON.stringify(task.inputs[0]!.value) !== JSON.stringify(handed)) throw new Error('a task`s answer is the envelope it was handed');
|
|
2092
|
+
if (JSON.stringify(handed) !== '{"settings":{},"input":{"invoice":{"id":"INV-2"},"run":{"tenant":"t1"}}}') throw new Error(`check@1 was handed ${JSON.stringify(handed)}`);
|
|
2093
|
+
// A node id alone: every path the END was reached on, in the order the run reached them — 2 before 1.
|
|
2094
|
+
if (!ends.ok || JSON.stringify(ends.inputs.map((i) => i.branchPath)) !== '["2","1"]') throw new Error('rejected was reached at 2, then 1');
|
|
2095
|
+
if (ends.inputs.some((i) => i.handed !== 'received' || JSON.stringify(i.value) !== '{"ok":false}')) throw new Error('an end is handed what reached it');
|
|
2096
|
+
if (nope.ok || nope.reason !== 'unknown') throw new Error('a node the flow does not have is unknown');
|
|
2097
|
+
// A prefix of the events — a store that has not caught up — reads what it holds and no more.
|
|
2098
|
+
const early = inputOf(flow, options, { header: stored.header, events: stored.events.slice(0, 4) }, { taskId: 'r1:check:1:0' });
|
|
2099
|
+
if (early.ok || early.reason !== 'not-reached') throw new Error('a task the prefix did not dispatch is not-reached');
|
|
2100
|
+
```
|
|
2101
|
+
|
|
1967
2102
|
### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
|
|
1968
2103
|
|
|
1969
2104
|
**`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
|
|
@@ -2844,6 +2979,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
|
|
|
2844
2979
|
| ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
|
|
2845
2980
|
| ~~**RUNNING an `ask`**~~ — a node that waits for a person, its answer at `resolve()` | **BUILT** since [W180](doc/work-items/w180-asking-and-answering-in-a-run.md): a run parks at an ask with nothing handed to the scheduler, and its answer is its result — section 7. Under a cancel, a stop, a pause and a resume an ask is a parked task — never in flight, so nothing is ever sent or handed on for it — since [W181](doc/work-items/w181-an-ask-under-run-control.md) (section 7) |
|
|
2846
2981
|
| a **live, mutable view** of a run's state, or the state handed to `onFoldInput` beside each input | **no** — [P39](doc/plans/p39-what-a-step-costs-and-where-a-live-run-state-is-read.md) (b), [ADR 0122](doc/decisions/0122-a-runtimes-live-state-is-a-frozen-copy-on-its-handle.md): `handle.state()` is a frozen COPY, cached until the run moves, and `handle.outcome()` / `handle.node()` are the O(1) reads — section 8 |
|
|
2982
|
+
| a task's INPUT kept in the run state — `NodeRun.input` for a task, or `pathEnded` carrying the value that reached an end | **no** — [ADR 0046](doc/decisions/0046-a-run-retains-what-its-tasks-returned-and-a-region-releases-its-collection-when-its-join-becomes-terminal.md): a run keeps what its tasks returned. **READ it instead**, since [W205](doc/work-items/w205-what-a-node-was-handed-read-by-replaying-the-run.md): `inputOf(flow, options, { header, events }, target)` or `handle.inputOf(target)` replays the run to that node — section 8. The input a worker sees AFTER its transform: **no** ([Q760](doc/questions/q760.md)) |
|
|
2847
2983
|
| a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
|
|
2848
2984
|
| **per-join policy choice** | one policy per driver |
|
|
2849
2985
|
| a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
|
|
@@ -4387,7 +4523,7 @@ no serializer. A CHECKPOINT is `{ header, events }`
|
|
|
4387
4523
|
([ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md)) —
|
|
4388
4524
|
the header is five facts the events cannot regenerate (`format`, `runId`, `flowId`, `version` and a
|
|
4389
4525
|
copy of the entry payload), and `events` is every fold input so far, so **every event after a
|
|
4390
|
-
checkpoint is an append to it**. You never have to call `driver.checkpoint()` to keep one:
|
|
4526
|
+
checkpoint is an append to it**. **The same header and events answer what any node was HANDED, long after the run** — `inputOf`, section 8 (since 0.10.0). You never have to call `driver.checkpoint()` to keep one:
|
|
4391
4527
|
|
|
4392
4528
|
| | where you get it | what you do with it |
|
|
4393
4529
|
|---|---|---|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ worker returned, routing can **fan out** into parallel branches, and a **join**
|
|
|
17
17
|
The scheduler runs tasks; this library decides **which task runs next**. `fyflow-scheduler` has no
|
|
18
18
|
dependency graph of its own, so sequence, decision, fan-out and join live here.
|
|
19
19
|
|
|
20
|
-
Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.
|
|
20
|
+
Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.10.0`.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|