fyflow-orchestration 0.8.0 → 0.9.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
@@ -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
@@ -1734,6 +1734,19 @@ A resolved node is **indistinguishable** from one that settled that way: same no
1734
1734
  **Run state is a pure fold of an ordered log.** The driver produces the sequence; `fold` turns it
1735
1735
  into state and touches nothing else.
1736
1736
 
1737
+ **What it costs** — `RN-R43`, from 0.9.0 ([ADR 0120](doc/decisions/0120-the-fold-is-one-accumulator-and-step-stays-pure.md)):
1738
+ **`fold(inputs)` is linear in its inputs** — a `map` over 4,000 items folds in milliseconds, where 0.8.0 took half a
1739
+ minute — and **`step(state, input)` is pure and costs O(the state it is handed)**: it never mutates the state you give
1740
+ it, so wherever the input moves something it copies what changed into a new state (and hands yours back where nothing
1741
+ moves) — and stepping every input of a long run yourself costs O(run²) in total. Fold the sequence when you
1742
+ 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).
1743
+ **The driver's own work per settlement does not grow with the run either** — `RN-R13` v0.30, from 0.9.0
1744
+ ([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
+ 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, where 0.8.0 scanned the whole log (a 16,000-item `map` driven on a scheduler double: 999 ms in
1747
+ 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
+ O(run) a call: read them when you need the run, not after every event.
1749
+
1737
1750
  ```ts run
1738
1751
  import { fold, isTerminal, type FoldInput } from 'fyflow-orchestration';
1739
1752
 
@@ -1839,7 +1852,8 @@ if (isTerminal(state)) console.log('finished as', state.outcome);
1839
1852
  fold is defined over any prefix, and a prefix taken mid-turn is not a resting state: a settlement is
1840
1853
  recorded before the routing it causes, so there are instants where nothing is running and the run is
1841
1854
  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
1855
+ you read a run as it goes — a runtime's `handle.outcome()` from `onFoldInput` (below), or a fold you keep yourself on a
1856
+ bare `Driver` — read on a `queueMicrotask` — and that microtask is
1843
1857
  **load-bearing rather than decoration**, which is why `done` asks its own predicate at one
1844
1858
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md)
1845
1859
  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 +1864,7 @@ the block — W78's conformity reader, finding 3.)*
1850
1864
  `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
1865
  `stuck` existed, a permanently hung run folded to `completed` and a waiter reported success.
1852
1866
 
1853
- **And `done` is that rule applied for you.** `runtime.start` returns `{ driver, done, header }` —
1867
+ **And `done` is that rule applied for you.** `runtime.start` returns `{ driver, done, header, stop, state, outcome, node }` —
1854
1868
  `header` is the run's CHECKPOINT header since
1855
1869
  [ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md),
1856
1870
  and `driver.checkpoint()` hands out the header with every event so far (`spec/run-state.md`
@@ -1858,7 +1872,8 @@ section 8) — and, since 0.8.0, `start`'s `onHeader` option hands you the same
1858
1872
  ([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
1873
  resolves with the first `RunState` this predicate calls terminal
1860
1874
  ([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
1875
+ is the same two functions — the runtime keeps the fold's own accumulator open, applies each input to it (`RN-R44`, from
1876
+ 0.9.0; it stepped one `RunState` per input until then) and asks
1862
1877
  `isTerminal` at a quiescent point — so there is one definition of *over* with one more caller, and
1863
1878
  nothing above about reading at rest stops being true. **Three things follow, and two of them catch
1864
1879
  people:**
@@ -1878,6 +1893,77 @@ people:**
1878
1893
  `Promise.race([done, timeout])` is your one line — and a library default would be wrong for the
1879
1894
  bullet above.
1880
1895
 
1896
+ ### Reading a run WHILE it runs — `handle.state()`, `outcome()` and `node()`
1897
+
1898
+ **From 0.9.0 the handle `start` and `resume` return reads the run's state live** (`RN-R44`,
1899
+ [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
1900
+ goes no longer folds it a second time in its own `onFoldInput`:
1901
+
1902
+ | read | what it is | costs |
1903
+ |---|---|---|
1904
+ | `handle.outcome()` | the run's outcome now | O(1) — **the read to make after every input** |
1905
+ | `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
+ | `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 |
1907
+
1908
+ **At a quiescent point `handle.state()` IS `fold(handle.driver.foldInputs())`** — the same rule as above: read on a
1909
+ `queueMicrotask` from `onFoldInput`, or outside the driver's turn. **It is not a live view** — a read is the state at that
1910
+ moment, and the next input makes a new one beside it; writing to it throws in strict code and reaches nothing the runtime
1911
+ keeps. **`done` resolves with the object `state()` returns then**, so `await done === handle.state()` once it has settled —
1912
+ until you `unpause` a run that finished while paused, the one run control a finished run still takes: `state()` follows
1913
+ that element, and `done` keeps the state at settlement. On a RESUMED run the reads hold the stored events too. **What
1914
+ `Object.freeze` does not stop stays writable** — a typed array's elements, an `ArrayBuffer`'s bytes, a `Date`'s time, a
1915
+ `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`**:
1916
+ what it hands you is unchanged, and the runtime keeps its own copy of every result rather than sharing yours.
1917
+
1918
+ **BREAKING in 0.9.0** (P39 (d) and (f)): `RunHandle` gains `state`, `outcome` and `node`, which breaks only code that BUILDS
1919
+ a handle; and `await done` is FROZEN — code that wrote into the state `done` resolved with now throws.
1920
+
1921
+ ```ts run
1922
+ import { createRuntime } from 'fyflow-orchestration/scheduler';
1923
+ import { fold, type FlowDefinition, type RegistryDocument } from 'fyflow-orchestration';
1924
+
1925
+ const registry: RegistryDocument = {
1926
+ workers: { scorer: { url: '../../examples/getting-started/scorer.ts' } },
1927
+ nodeTypes: { Scorer: { worker: 'scorer', inline: true } },
1928
+ };
1929
+ const flow: FlowDefinition = {
1930
+ flowId: 'live', version: 1, entry: 'a',
1931
+ nodes: [
1932
+ { id: 'a', kind: 'task', type: 'Scorer', settings: { score: 0.1 }, edges: [{ to: 'split' }] },
1933
+ { id: 'split', kind: 'fanout', branches: ['b', 'c'] },
1934
+ { id: 'b', kind: 'task', type: 'Scorer', settings: { score: 0.2 }, edges: [{ to: 'j' }] },
1935
+ { id: 'c', kind: 'task', type: 'Scorer', settings: { score: 0.3 }, edges: [{ to: 'j' }] },
1936
+ { id: 'j', kind: 'join', of: 'split', edges: [{ to: 'e' }] },
1937
+ { id: 'e', kind: 'end', outcome: 'completed' },
1938
+ ],
1939
+ };
1940
+
1941
+ const runtime = createRuntime(registry, {}, { base: import.meta.url });
1942
+ // The CHEAP read after every input, on a microtask: the driver's turn is over by then (RN-R12). The microtasks of the
1943
+ // inputs `start` delivers before it returns run after it has, so `handle` is there for every one.
1944
+ const outcomes: string[] = [];
1945
+ const handle = runtime.start(flow, {}, {
1946
+ onFoldInput: () => queueMicrotask(() => { outcomes.push(handle.outcome()); }),
1947
+ });
1948
+ const end = await handle.done;
1949
+ await runtime.shutdown();
1950
+ console.log(outcomes.length, outcomes.at(-1), end.outcome); // one outcome per input, the last 'completed'
1951
+
1952
+ const a = `${handle.driver.runId}:a:-:0`;
1953
+ if (outcomes.length < 10 || outcomes.at(-1) !== 'completed') throw new Error(`one outcome() per input, the last completed: ${outcomes.join()}`);
1954
+ if (outcomes.some((o) => o !== 'running' && o !== 'completed')) throw new Error(`a run with nobody in it reads running, then completed: ${outcomes.join()}`);
1955
+ // `done` and `state()` are ONE object, and it is the caller's own fold — key order included.
1956
+ if (end !== handle.state()) throw new Error('await done is the object state() returns — one copy, not two');
1957
+ if (JSON.stringify(end) !== JSON.stringify(fold(handle.driver.foldInputs()))) throw new Error('state() is the fold of what the driver appended');
1958
+ // `node()` is the state's own entry — no copy — and null for a task the run never dispatched.
1959
+ 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');
1960
+ // Frozen at every depth: the run, its nodes, an entry and the result inside it.
1961
+ 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');
1962
+ let wrote = true;
1963
+ try { (end.nodes[a]!.result as { score: number }).score = 1; } catch { wrote = false; }
1964
+ 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
+ ```
1966
+
1881
1967
  ### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
1882
1968
 
1883
1969
  **`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
@@ -2757,6 +2843,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
2757
2843
  | **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
2844
  | ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
2759
2845
  | ~~**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
+ | 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
2847
  | a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
2761
2848
  | **per-join policy choice** | one policy per driver |
2762
2849
  | a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
@@ -2765,7 +2852,9 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
2765
2852
  scheduler cannot show which node a run is at, which branch it took, or what a join is waiting on.
2766
2853
  That is [Q21](doc/questions/q21.md), and it is about the scheduler's stream, not about this
2767
2854
  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
2855
+ so you can see a run finish without polling — on a runtime, read `handle.outcome()` after each input and
2856
+ `handle.state()` when you draw the run (section 8, *Reading a run WHILE it runs*); on a bare `Driver`, mind section 8's
2857
+ *What it costs*: a `step` per input is O(run) each; `onRegionEvent` reports branches
2769
2858
  opening, arriving, exiting, suspending and resuming; `joinStatusOf()` answers what a join is waiting
2770
2859
  on. What is missing is an agreed envelope shape between the two repositories, not the data.
2771
2860
 
@@ -2785,7 +2874,7 @@ is recorded rather than added to the gate.
2785
2874
  `fyflow-orchestration/scheduler`, [W66](doc/work-items/w66-the-runtime-entry.md). It resolves every
2786
2875
  name the document uses against the code you supply, refuses every defect at construction by a named
2787
2876
  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
2877
+ them, and hands you **a handle per run** — `{ driver, done, header, stop, state, outcome, node }`: the driver already running, `done` a
2789
2878
  promise that resolves with the run's terminal `RunState`, and `header` the run's checkpoint header — handed to
2790
2879
  `start`'s `onHeader` option too, before the run's first event, for a store that writes it first (section 18)
2791
2880
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md);
@@ -4304,7 +4393,7 @@ checkpoint is an append to it**. You never have to call `driver.checkpoint()` to
4304
4393
  |---|---|---|
4305
4394
  | 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
4395
  | 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 |
4396
+ | 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
4397
 
4309
4398
  **Write the header FIRST, through `onHeader`** (`RN-R39`, [ADR 0112](doc/decisions/0112-a-run-delivers-its-header-before-its-first-event.md),
4310
4399
  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.0`.
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 |