fyflow-orchestration 0.9.1 → 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 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.** Each instance gets its OWN COPY of
462
- each key, so a reference to a large result is copied once per item. Measured at a map arity of 1000
463
- over a ~126 KiB payload, **one case per process**: no `needs` **5.5 ms**, `{item: '.'}` **7.7 ms**,
464
- one more key naming a genuinely small node **8.7 ms**, and the same key naming the large one
465
- **613.6 ms** — **71x**, and the `?` suffix costs nothing (**617.4 ms**). The mechanism makes one copy
466
- per instance and the cost is linear in the size of each, so the product is what you pay. That is also
467
- why a reference names a whole node and never a path inside it — a path language would hide this
468
- choice. *(These five figures are compared to `spec/flow-definition.md` section 3's own table by
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,9 @@ 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.
479
498
 
480
499
  **Finding what a reference names costs its ANSWER** — from 0.9.1
481
500
  ([ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md), `spec/flow-definition.md` v0.46). A branch task
@@ -484,8 +503,8 @@ predecessor — and the read takes that one output, however many instances the m
484
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
485
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
486
505
  less than its answer**: `["n"]` from a reader outside every region `n` is in — a SIBLING map's reader — is handed every
487
- output of `n`, so two maps of `n` and `m` instances compose `n·m` entries. That is the answer's price, the cliff above by
488
- another road, and the remedy is the same.
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.
489
508
 
490
509
  ### `"$payload"` — what reached the RUN, from any node
491
510
 
@@ -498,14 +517,14 @@ that passed it through.
498
517
 
499
518
  | | |
500
519
  |---|---|
501
- | 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. Each key is its own deep copy, per read: no node receives an object another holds, and a worker writing into it reaches 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.)* |
502
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 |
503
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 |
504
523
  | `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
505
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) |
506
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 |
507
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) |
508
- | what it costs | one copy of the whole payload **per key per read** — inside a `map`, once per instance (the cliff above, at its largest: name the small part's producer instead, or give the `map` a `needs` that projects what each instance needs). Nothing warns, as above. On an ASK the copy is also written into the run's LOG: the `asked` element keeps what reached the ask (section 7) |
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) |
509
528
 
510
529
  **`givenVerdict` and `tryTransform(…, { input })` compose nothing** (section 15): the value you give them IS what the node
511
530
  receives, so a node declaring `needs: { run: "$payload" }` is given `{ run: <the payload> }` — composing it is yours, as
@@ -524,7 +543,7 @@ const scheduler = {
524
543
  addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
525
544
  addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
526
545
  };
527
- const inputOf = (id: string) => added.find((t) => t.id === id)?.payload.input;
546
+ const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
528
547
 
529
548
  const flow: FlowDefinition = {
530
549
  flowId: 'pay-invoice', version: 1, entry: 'approve',
@@ -555,9 +574,9 @@ driver.start(invoice);
555
574
  invoice.amount = 1; // yours to change: the run holds its own copy
556
575
 
557
576
  driver.resolve('r1:approve:-:0', 'done', { result: { approved: true }, actor: 'ana' });
558
- console.log(inputOf('r1:pay:-:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
559
- if (JSON.stringify(inputOf('r1:pay:-:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
560
- throw new Error(`pay received ${JSON.stringify(inputOf('r1:pay:-:0'))}`);
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'))}`);
561
580
  }
562
581
 
563
582
  // `pay` settles; its edge reads `needs.run.amount` — 120, the run's — and routes to the audit.
@@ -615,7 +634,7 @@ const scheduler = {
615
634
  addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
616
635
  addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
617
636
  };
618
- const inputOf = (id: string) => added.find((t) => t.id === id)?.payload.input;
637
+ const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
619
638
 
620
639
  const flow: FlowDefinition = {
621
640
  flowId: 'review-each', version: 1, entry: 'each',
@@ -650,12 +669,12 @@ payload.invoices[0]!.amount = 1; // yours to change: the instance keeps its ow
650
669
  // Two asks parked, one per invoice. Answered out of order, each `pay` gets ITS instance's invoice.
651
670
  driver.resolve('r1:review:1:0', 'done', { result: { approved: true }, actor: 'bo' });
652
671
  driver.resolve('r1:review:0:0', 'done', { result: { approved: true }, actor: 'ana' });
653
- console.log(inputOf('r1:pay:0:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
654
- if (JSON.stringify(inputOf('r1:pay:0:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
655
- throw new Error(`pay@0 received ${JSON.stringify(inputOf('r1:pay:0:0'))}`);
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'))}`);
656
675
  }
657
- if (JSON.stringify(inputOf('r1:pay:1:0')) !== '{"invoice":{"id":"INV-2","amount":40},"answer":{"approved":true}}') {
658
- throw new Error(`pay@1 received ${JSON.stringify(inputOf('r1:pay:1:0'))}`);
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'))}`);
659
678
  }
660
679
 
661
680
  // Each `pay` settles; its edge reads `needs.it.amount` — 120 audits, 40 does not.
@@ -911,7 +930,9 @@ instance, from what the instance KEEPS**: its item, while a node of it may still
911
930
  (section 3).
912
931
 
913
932
  **`ctx.needs` is `{}` when the edge declares nothing**, never `undefined`; it is **frozen**, so a
914
- predicate that writes to it throws rather than editing what the next predicate reads; and a reference
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
915
936
  the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, section 9) exactly as a
916
937
  node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
917
938
  and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
@@ -1754,7 +1775,7 @@ need the state; step only where you hold a short one. Both return exactly what t
1754
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
1755
1776
  ONE task — a settlement's effective outcome, whether a task is parked, what is in flight, what a `needs` can reach — reads
1756
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 —
1757
- `RN-R13` v0.32, [ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md)), where 0.8.0 scanned the whole log (a 16,000-item `map` driven on a scheduler double: 999 ms in
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
1758
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,
1759
1780
  O(run) a call: read them when you need the run, not after every event.
1760
1781
 
@@ -1915,6 +1936,7 @@ goes no longer folds it a second time in its own `onFoldInput`:
1915
1936
  | `handle.outcome()` | the run's outcome now | O(1) — **the read to make after every input** |
1916
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 |
1917
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 |
1918
1940
 
1919
1941
  **At a quiescent point `handle.state()` IS `fold(handle.driver.foldInputs())`** — the same rule as above: read on a
1920
1942
  `queueMicrotask` from `onFoldInput`, or outside the driver's turn. **It is not a live view** — a read is the state at that
@@ -1975,6 +1997,108 @@ try { (end.nodes[a]!.result as { score: number }).score = 1; } catch { wrote = f
1975
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');
1976
1998
  ```
1977
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
+
1978
2102
  ### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
1979
2103
 
1980
2104
  **`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
@@ -2855,6 +2979,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
2855
2979
  | ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
2856
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) |
2857
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)) |
2858
2983
  | a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
2859
2984
  | **per-join policy choice** | one policy per driver |
2860
2985
  | a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
@@ -4398,7 +4523,7 @@ no serializer. A CHECKPOINT is `{ header, events }`
4398
4523
  ([ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md)) —
4399
4524
  the header is five facts the events cannot regenerate (`format`, `runId`, `flowId`, `version` and a
4400
4525
  copy of the entry payload), and `events` is every fold input so far, so **every event after a
4401
- 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:
4402
4527
 
4403
4528
  | | where you get it | what you do with it |
4404
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.9.1`.
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