fyflow-orchestration 0.8.0 → 0.9.1

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
@@ -67,7 +67,7 @@ void shape;
67
67
  **Two documents, and one file of your own.** A REGISTRY says what exists — a worker module, and a
68
68
  node type that runs on it. A FLOW says what happens — nodes naming those types, and the edges between
69
69
  them. `createRuntime` builds every pool, group and scheduler the registry names and hands back
70
- **`{ driver, done, header }`** per run — the driver already running, and `done` a promise that resolves with
70
+ **a handle** per run — `{ driver, done, header, stop, state, outcome, node }`: the driver already running, and `done` a promise that resolves with
71
71
  the run's terminal state ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md)). **The only code you write is the worker**:
72
72
  `examples/getting-started/worker.ts`, a class with a `run` method and nothing else — no base class,
73
73
  no import. Everything below is executed by the gate on every `npm run gate`, and the same snippet is
@@ -477,6 +477,16 @@ once per item* is a fact about your document rather than about your run. **Measu
477
477
  obstacle** — a size measurement is about 0.03% of what it would warn about at arity 1000.
478
478
  `spec/flow-definition.md` section 3 carries the whole verdict and the measurements.
479
479
 
480
+ **Finding what a reference names costs its ANSWER** — from 0.9.1
481
+ ([ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md), `spec/flow-definition.md` v0.46). A branch task
482
+ may read any earlier task of its own `map` instance by id — one step back or several, where `"."` reaches only its
483
+ predecessor — and the read takes that one output, however many instances the map has: the driver files a node's outputs
484
+ 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
+ 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
+ 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.
489
+
480
490
  ### `"$payload"` — what reached the RUN, from any node
481
491
 
482
492
  **`"$payload"` names the value the run was STARTED with** — `start(flow, payload)`'s `payload` — on any node that may
@@ -1734,6 +1744,20 @@ A resolved node is **indistinguishable** from one that settled that way: same no
1734
1744
  **Run state is a pure fold of an ordered log.** The driver produces the sequence; `fold` turns it
1735
1745
  into state and touches nothing else.
1736
1746
 
1747
+ **What it costs** — `RN-R43`, from 0.9.0 ([ADR 0120](doc/decisions/0120-the-fold-is-one-accumulator-and-step-stays-pure.md)):
1748
+ **`fold(inputs)` is linear in its inputs** — a `map` over 4,000 items folds in milliseconds, where 0.8.0 took half a
1749
+ minute — and **`step(state, input)` is pure and costs O(the state it is handed)**: it never mutates the state you give
1750
+ it, so wherever the input moves something it copies what changed into a new state (and hands yours back where nothing
1751
+ moves) — and stepping every input of a long run yourself costs O(run²) in total. Fold the sequence when you
1752
+ need the state; step only where you hold a short one. Both return exactly what they did in 0.8.0, on every sequence a driver appends (`RN-R43` states its one limit).
1753
+ **The driver's own work per settlement does not grow with the run either** — `RN-R13` v0.30, from 0.9.0
1754
+ ([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
+ ONE task — a settlement's effective outcome, whether a task is parked, what is in flight, what a `needs` can reach — reads
1756
+ 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
1758
+ 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
+ O(run) a call: read them when you need the run, not after every event.
1760
+
1737
1761
  ```ts run
1738
1762
  import { fold, isTerminal, type FoldInput } from 'fyflow-orchestration';
1739
1763
 
@@ -1839,7 +1863,8 @@ if (isTerminal(state)) console.log('finished as', state.outcome);
1839
1863
  fold is defined over any prefix, and a prefix taken mid-turn is not a resting state: a settlement is
1840
1864
  recorded before the routing it causes, so there are instants where nothing is running and the run is
1841
1865
  not over. If you fold `driver.foldInputs()` after the fact you are always at a quiescent point; if
1842
- you fold incrementally from `onFoldInput`, read on a `queueMicrotask` — and that microtask is
1866
+ you read a run as it goes — a runtime's `handle.outcome()` from `onFoldInput` (below), or a fold you keep yourself on a
1867
+ bare `Driver` — read on a `queueMicrotask` — and that microtask is
1843
1868
  **load-bearing rather than decoration**, which is why `done` asks its own predicate at one
1844
1869
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md)
1845
1870
  rule 6). *(That phrase lived in §2's copied block until [W78](doc/work-items/w78-the-run-a-consumer-holds-in-one-line.md)
@@ -1850,7 +1875,7 @@ the block — W78's conformity reader, finding 3.)*
1850
1875
  `awaiting_action` and `cancelling` and true for everything else — `interrupted` included, though a resume ELSEWHERE continues that run — and `stuck`, which is the point: before
1851
1876
  `stuck` existed, a permanently hung run folded to `completed` and a waiter reported success.
1852
1877
 
1853
- **And `done` is that rule applied for you.** `runtime.start` returns `{ driver, done, header }` —
1878
+ **And `done` is that rule applied for you.** `runtime.start` returns `{ driver, done, header, stop, state, outcome, node }` —
1854
1879
  `header` is the run's CHECKPOINT header since
1855
1880
  [ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md),
1856
1881
  and `driver.checkpoint()` hands out the header with every event so far (`spec/run-state.md`
@@ -1858,7 +1883,8 @@ section 8) — and, since 0.8.0, `start`'s `onHeader` option hands you the same
1858
1883
  ([ADR 0065](doc/decisions/0065-a-resume-replays-the-stored-stimuli-refuses-what-it-cannot-regenerate-and-re-dispatches-what-was-in-flight.md), `spec/run-state.md` section 9 — section 18 teaches it, as an executed snippet) — and `done`
1859
1884
  resolves with the first `RunState` this predicate calls terminal
1860
1885
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md)). It
1861
- is the same two functions — the runtime keeps one `RunState`, steps it per input and asks
1886
+ is the same two functions — the runtime keeps the fold's own accumulator open, applies each input to it (`RN-R44`, from
1887
+ 0.9.0; it stepped one `RunState` per input until then) and asks
1862
1888
  `isTerminal` at a quiescent point — so there is one definition of *over* with one more caller, and
1863
1889
  nothing above about reading at rest stops being true. **Three things follow, and two of them catch
1864
1890
  people:**
@@ -1878,6 +1904,77 @@ people:**
1878
1904
  `Promise.race([done, timeout])` is your one line — and a library default would be wrong for the
1879
1905
  bullet above.
1880
1906
 
1907
+ ### Reading a run WHILE it runs — `handle.state()`, `outcome()` and `node()`
1908
+
1909
+ **From 0.9.0 the handle `start` and `resume` return reads the run's state live** (`RN-R44`,
1910
+ [ADR 0122](doc/decisions/0122-a-runtimes-live-state-is-a-frozen-copy-on-its-handle.md)) — so a consumer showing a run as it
1911
+ goes no longer folds it a second time in its own `onFoldInput`:
1912
+
1913
+ | read | what it is | costs |
1914
+ |---|---|---|
1915
+ | `handle.outcome()` | the run's outcome now | O(1) — **the read to make after every input** |
1916
+ | `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
+ | `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 |
1918
+
1919
+ **At a quiescent point `handle.state()` IS `fold(handle.driver.foldInputs())`** — the same rule as above: read on a
1920
+ `queueMicrotask` from `onFoldInput`, or outside the driver's turn. **It is not a live view** — a read is the state at that
1921
+ moment, and the next input makes a new one beside it; writing to it throws in strict code and reaches nothing the runtime
1922
+ keeps. **`done` resolves with the object `state()` returns then**, so `await done === handle.state()` once it has settled —
1923
+ until you `unpause` a run that finished while paused, the one run control a finished run still takes: `state()` follows
1924
+ that element, and `done` keeps the state at settlement. On a RESUMED run the reads hold the stored events too. **What
1925
+ `Object.freeze` does not stop stays writable** — a typed array's elements, an `ArrayBuffer`'s bytes, a `Date`'s time, a
1926
+ `Map`'s or `Set`'s membership — and a write there reaches later reads and `done`: return plain data, read plain data. **Nothing is added to `onFoldInput`**:
1927
+ what it hands you is unchanged, and the runtime keeps its own copy of every result rather than sharing yours.
1928
+
1929
+ **BREAKING in 0.9.0** (P39 (d) and (f)): `RunHandle` gains `state`, `outcome` and `node`, which breaks only code that BUILDS
1930
+ a handle; and `await done` is FROZEN — code that wrote into the state `done` resolved with now throws.
1931
+
1932
+ ```ts run
1933
+ import { createRuntime } from 'fyflow-orchestration/scheduler';
1934
+ import { fold, type FlowDefinition, type RegistryDocument } from 'fyflow-orchestration';
1935
+
1936
+ const registry: RegistryDocument = {
1937
+ workers: { scorer: { url: '../../examples/getting-started/scorer.ts' } },
1938
+ nodeTypes: { Scorer: { worker: 'scorer', inline: true } },
1939
+ };
1940
+ const flow: FlowDefinition = {
1941
+ flowId: 'live', version: 1, entry: 'a',
1942
+ nodes: [
1943
+ { id: 'a', kind: 'task', type: 'Scorer', settings: { score: 0.1 }, edges: [{ to: 'split' }] },
1944
+ { id: 'split', kind: 'fanout', branches: ['b', 'c'] },
1945
+ { id: 'b', kind: 'task', type: 'Scorer', settings: { score: 0.2 }, edges: [{ to: 'j' }] },
1946
+ { id: 'c', kind: 'task', type: 'Scorer', settings: { score: 0.3 }, edges: [{ to: 'j' }] },
1947
+ { id: 'j', kind: 'join', of: 'split', edges: [{ to: 'e' }] },
1948
+ { id: 'e', kind: 'end', outcome: 'completed' },
1949
+ ],
1950
+ };
1951
+
1952
+ const runtime = createRuntime(registry, {}, { base: import.meta.url });
1953
+ // The CHEAP read after every input, on a microtask: the driver's turn is over by then (RN-R12). The microtasks of the
1954
+ // inputs `start` delivers before it returns run after it has, so `handle` is there for every one.
1955
+ const outcomes: string[] = [];
1956
+ const handle = runtime.start(flow, {}, {
1957
+ onFoldInput: () => queueMicrotask(() => { outcomes.push(handle.outcome()); }),
1958
+ });
1959
+ const end = await handle.done;
1960
+ await runtime.shutdown();
1961
+ console.log(outcomes.length, outcomes.at(-1), end.outcome); // one outcome per input, the last 'completed'
1962
+
1963
+ const a = `${handle.driver.runId}:a:-:0`;
1964
+ if (outcomes.length < 10 || outcomes.at(-1) !== 'completed') throw new Error(`one outcome() per input, the last completed: ${outcomes.join()}`);
1965
+ if (outcomes.some((o) => o !== 'running' && o !== 'completed')) throw new Error(`a run with nobody in it reads running, then completed: ${outcomes.join()}`);
1966
+ // `done` and `state()` are ONE object, and it is the caller's own fold — key order included.
1967
+ if (end !== handle.state()) throw new Error('await done is the object state() returns — one copy, not two');
1968
+ if (JSON.stringify(end) !== JSON.stringify(fold(handle.driver.foldInputs()))) throw new Error('state() is the fold of what the driver appended');
1969
+ // `node()` is the state's own entry — no copy — and null for a task the run never dispatched.
1970
+ if (handle.node(a) !== end.nodes[a] || handle.node(`${handle.driver.runId}:nope:-:0`) !== null) throw new Error('node() reads the state`s own entry, or null');
1971
+ // Frozen at every depth: the run, its nodes, an entry and the result inside it.
1972
+ if (!Object.isFrozen(end) || !Object.isFrozen(end.nodes) || !Object.isFrozen(end.nodes[a]) || !Object.isFrozen(end.nodes[a]!.result)) throw new Error('state() is frozen at every depth');
1973
+ let wrote = true;
1974
+ try { (end.nodes[a]!.result as { score: number }).score = 1; } catch { wrote = false; }
1975
+ 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
+ ```
1977
+
1881
1978
  ### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
1882
1979
 
1883
1980
  **`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
@@ -2757,6 +2854,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
2757
2854
  | **nested fan-out** — a branch that fans out again | **built, and the join over it is settled.** `FD-V15` is retired; a task inside a nested region is dispatched at its whole dotted `branchPath` (`r1:scoreC:1.0:0`); and every policy in section 5 is **transitive** — each region answers exactly as it would if it were not nested ([ADR 0030](doc/decisions/0030-a-join-is-transitive-and-a-dead-join-ends-the-branch-it-sits-in.md)). One thing to know: if an inner join is reported through `onUnsatisfiableJoin`, the outer branch containing it **ends** rather than waiting, so the outer join may still fire and its successor may run — and once that work settles the run folds to `stuck`, because a region that never fired is what `stuck` reads. **Fold too early and you will see `running`.** The four matrix rows and the join seam are tested at **depth 2**; the dead-join cascade is asserted at depth 3, and nothing else about depth 3 is a tested claim |
2758
2855
  | ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
2759
2856
  | ~~**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
+ | 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 |
2760
2858
  | a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
2761
2859
  | **per-join policy choice** | one policy per driver |
2762
2860
  | a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
@@ -2765,7 +2863,9 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
2765
2863
  scheduler cannot show which node a run is at, which branch it took, or what a join is waiting on.
2766
2864
  That is [Q21](doc/questions/q21.md), and it is about the scheduler's stream, not about this
2767
2865
  library — **this library does expose all three.** `onFoldInput` is the push half of `foldInputs()`,
2768
- so you can fold incrementally and see a run finish without polling; `onRegionEvent` reports branches
2866
+ so you can see a run finish without polling — on a runtime, read `handle.outcome()` after each input and
2867
+ `handle.state()` when you draw the run (section 8, *Reading a run WHILE it runs*); on a bare `Driver`, mind section 8's
2868
+ *What it costs*: a `step` per input is O(run) each; `onRegionEvent` reports branches
2769
2869
  opening, arriving, exiting, suspending and resuming; `joinStatusOf()` answers what a join is waiting
2770
2870
  on. What is missing is an agreed envelope shape between the two repositories, not the data.
2771
2871
 
@@ -2785,7 +2885,7 @@ is recorded rather than added to the gate.
2785
2885
  `fyflow-orchestration/scheduler`, [W66](doc/work-items/w66-the-runtime-entry.md). It resolves every
2786
2886
  name the document uses against the code you supply, refuses every defect at construction by a named
2787
2887
  error, builds one pool per type instance and one object per group, holds a `FyflowScheduler` over
2788
- them, and hands you **`{ driver, done, header }` per run** — the driver already running, `done` a
2888
+ them, and hands you **a handle per run** — `{ driver, done, header, stop, state, outcome, node }`: the driver already running, `done` a
2789
2889
  promise that resolves with the run's terminal `RunState`, and `header` the run's checkpoint header — handed to
2790
2890
  `start`'s `onHeader` option too, before the run's first event, for a store that writes it first (section 18)
2791
2891
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md);
@@ -4304,7 +4404,7 @@ checkpoint is an append to it**. You never have to call `driver.checkpoint()` to
4304
4404
  |---|---|---|
4305
4405
  | the header | **`onHeader(header)`** on `start`'s options — handed to you inside `start`, BEFORE the first event and before anything reaches the scheduler; and `runtime.start(…)` returns the same one as `header`, beside `driver` and `done` | write it once, FIRST |
4306
4406
  | every event | `onFoldInput(event, position)` | write it AT `position` — the driver's index, never a count you start again in each process |
4307
- | a resumed run | `runtime.resume(flow, { header, events }, options)` returns the same `{ driver, done, header }` — and calls no `onHeader`: you hold the header already | keep writing: it hands you positions from the STORED LENGTH on |
4407
+ | a resumed run | `runtime.resume(flow, { header, events }, options)` returns the same handle — `{ driver, done, header, stop, state, outcome, node }` — and calls no `onHeader`: you hold the header already | keep writing: it hands you positions from the STORED LENGTH on |
4308
4408
 
4309
4409
  **Write the header FIRST, through `onHeader`** (`RN-R39`, [ADR 0112](doc/decisions/0112-a-run-delivers-its-header-before-its-first-event.md),
4310
4410
  since 0.8.0). The first events are delivered WHILE `start` runs, so the handle's `header` arrives after them, and a process
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.8.0`.
20
+ Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.9.1`.
21
21
 
22
22
  ## Install
23
23
 
@@ -258,6 +258,7 @@ if (after.counts.no !== 0) throw new Error('and the flow that ran has no unwired
258
258
  | A task **cancelled** by its id — the run ends `cancelled` and refuses a resume | [AGENTS.md](AGENTS.md) section 6 |
259
259
  | **Cancelling a run** — `driver.cancel`, the run `cancelling` until its last task has stopped | section 6 |
260
260
  | **Pausing** a run and unpausing it — every dispatch held, then handed on in order | section 8 |
261
+ | Reading a run's state **while it runs** — `handle.outcome()` after every input, `handle.state()`, a frozen copy, when you draw it | section 8 |
261
262
  | A node that **waits for a person** by design — an `ask`, which keeps what reached it and holds the answer to a schema | section 7 |
262
263
  | A task's **messages and progress**, each with its run, node and attempt as fields — `onTaskMessage`, `onTaskProgress` | section 8 |
263
264
  | A **worker's own messages**, outside any task — `worker.message` on its pool, reached through `createScheduler` | section 8 |