fyflow-orchestration 0.9.1 → 0.11.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 +322 -29
- package/README.md +1 -1
- package/dist/browser/chunks/{chunk-I3KDEOLB.js → chunk-5CVSWDCB.js} +442 -111
- package/dist/browser/index.js +541 -3
- package/dist/browser/scheduler/index.js +13 -2
- package/dist/node/chunks/{chunk-I3KDEOLB.js → chunk-5CVSWDCB.js} +442 -111
- package/dist/node/index.js +541 -3
- package/dist/node/scheduler/index.js +13 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/src/definition/definition-schema.d.ts +23 -0
- package/dist/types/src/definition/types.d.ts +2 -1
- package/dist/types/src/definition/validate.d.ts +41 -2
- package/dist/types/src/definition/wiring.d.ts +10 -0
- package/dist/types/src/driver/driver.d.ts +161 -11
- package/dist/types/src/fold/run-state.d.ts +17 -0
- package/dist/types/src/scheduler/runtime.d.ts +17 -1
- package/package.json +2 -1
package/AGENTS.md
CHANGED
|
@@ -347,6 +347,23 @@ that object and nothing else — there is no wrapper and no reserved key.
|
|
|
347
347
|
| `"someNode"` | that node's result, **on your own branch** |
|
|
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
|
+
| **`!error` after the node id, on any of those** — `"someNode!error"`, `["someNode!error?"]`, … | that node's **ERROR** instead of its result: the string its failure was reduced to, `null` where it succeeded (since 0.11.0, below) |
|
|
351
|
+
|
|
352
|
+
**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),
|
|
353
|
+
`spec/flow-definition.md` v0.47). The value under a `"someNode"` or `["someNode"]` key (either with `?`) is ONE deep copy
|
|
354
|
+
of that output, made the first time anything reads it and handed to EVERY reader of it — every instance of a `map`, every
|
|
355
|
+
node after it, and every edge predicate (`ctx.needs`, section 4) — and a `"$payload"` key is one such copy of the run's
|
|
356
|
+
payload for the whole run. The object `needs` composes is still yours, a `["someNode"]` array is a fresh one per read, and
|
|
357
|
+
`"."` and `"$item"` are still your own copies. **The copy is FROZEN where JavaScript can freeze**: plain objects and arrays
|
|
358
|
+
at every depth, so writing into one throws `TypeError` in strict code — an **inline** worker that does so fails that
|
|
359
|
+
attempt (your `retryPolicy` decides what follows), and a **predicate** in strict code that does so stops the run (in
|
|
360
|
+
sloppy code the write is silently dropped). A threaded worker
|
|
361
|
+
writes into its own clone, as before. **What freezing cannot protect is yours to leave alone**: a `Map`, `Set`, `Date`
|
|
362
|
+
or `ArrayBuffer` in a result is frozen only shallowly (`set`, `add`, `setTime` and a write through a view still work, and
|
|
363
|
+
a `Map`'s keys and values, a `Set`'s members and an `Error`'s `cause` are not frozen), and a typed array, `DataView` or
|
|
364
|
+
`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
|
|
365
|
+
that output sees. Copy before you change anything a reference handed you. *(Until 0.10.0 every read was its own deep
|
|
366
|
+
copy, so such a write landed on yours alone — and a reference to a large result inside a `map` cost one copy per item.)*
|
|
350
367
|
|
|
351
368
|
**Which of the two aimed forms is legal is decided by the graph and refused at construction**
|
|
352
369
|
(`FD-V28`), so you are told which to write rather than discovering the shape at run time. The message
|
|
@@ -458,14 +475,15 @@ in W50* for exactly as long as that was true.)*
|
|
|
458
475
|
And a reference names a **node**, never a path inside its value: `needs: { run: 'ingest' }` gives you
|
|
459
476
|
the whole of `ingest`'s result and your worker reads `.config` off it.
|
|
460
477
|
|
|
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
|
-
|
|
478
|
+
**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
|
|
479
|
+
arity (above). Until 0.10.0 each instance got its OWN COPY of each key, so a reference to a large result was copied
|
|
480
|
+
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
|
|
481
|
+
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
|
|
482
|
+
~126 KiB result, the driver alone, **one case per process**: no `needs` **24.4 ms**, `{item: '.'}` **25.3 ms**, one
|
|
483
|
+
more key naming a genuinely small node **28.0 ms**, and the same key naming the large one **30.5 ms** — **1.1x**, where
|
|
484
|
+
it was 17x in 0.9.1 on the same machine — and the `?` suffix costs nothing (**28.3 ms**). The mechanism makes one copy
|
|
485
|
+
per output read, whoever reads it. A reference still names a whole node and never a path inside it — a path
|
|
486
|
+
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
487
|
`check-docs`' `X6`, both ways — three documents carried them and nothing compared any two, which is
|
|
470
488
|
`doc/workflow.md` section 5 rule 8 obligation (e)'s third shape.)*
|
|
471
489
|
|
|
@@ -475,7 +493,9 @@ including the flow it exists for, so it restates the definition rather than diag
|
|
|
475
493
|
a report channel exists (`onUnmintedTask`'s shape) and is declined because *this reference is resolved
|
|
476
494
|
once per item* is a fact about your document rather than about your run. **Measuring is not the
|
|
477
495
|
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.
|
|
496
|
+
`spec/flow-definition.md` section 3 carries the whole verdict and the measurements. **From 0.10.0 there is nothing
|
|
497
|
+
left to warn about**: the per-item copy that decision was about is gone ([Q186](doc/questions/q186.md), resolved by
|
|
498
|
+
[ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md)), and the decision stands as written.
|
|
479
499
|
|
|
480
500
|
**Finding what a reference names costs its ANSWER** — from 0.9.1
|
|
481
501
|
([ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md), `spec/flow-definition.md` v0.46). A branch task
|
|
@@ -484,8 +504,8 @@ predecessor — and the read takes that one output, however many instances the m
|
|
|
484
504
|
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
505
|
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
506
|
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.
|
|
488
|
-
|
|
507
|
+
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`
|
|
508
|
+
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
509
|
|
|
490
510
|
### `"$payload"` — what reached the RUN, from any node
|
|
491
511
|
|
|
@@ -498,14 +518,14 @@ that passed it through.
|
|
|
498
518
|
|
|
499
519
|
| | |
|
|
500
520
|
|---|---|
|
|
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.
|
|
521
|
+
| 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
522
|
| 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
523
|
| 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
524
|
| `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
|
|
505
525
|
| 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
526
|
| 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
527
|
| 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 |
|
|
528
|
+
| 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
529
|
|
|
510
530
|
**`givenVerdict` and `tryTransform(…, { input })` compose nothing** (section 15): the value you give them IS what the node
|
|
511
531
|
receives, so a node declaring `needs: { run: "$payload" }` is given `{ run: <the payload> }` — composing it is yours, as
|
|
@@ -524,7 +544,7 @@ const scheduler = {
|
|
|
524
544
|
addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
|
|
525
545
|
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
526
546
|
};
|
|
527
|
-
const
|
|
547
|
+
const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
|
|
528
548
|
|
|
529
549
|
const flow: FlowDefinition = {
|
|
530
550
|
flowId: 'pay-invoice', version: 1, entry: 'approve',
|
|
@@ -555,9 +575,9 @@ driver.start(invoice);
|
|
|
555
575
|
invoice.amount = 1; // yours to change: the run holds its own copy
|
|
556
576
|
|
|
557
577
|
driver.resolve('r1:approve:-:0', 'done', { result: { approved: true }, actor: 'ana' });
|
|
558
|
-
console.log(
|
|
559
|
-
if (JSON.stringify(
|
|
560
|
-
throw new Error(`pay received ${JSON.stringify(
|
|
578
|
+
console.log(handedInput('r1:pay:-:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
|
|
579
|
+
if (JSON.stringify(handedInput('r1:pay:-:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
|
|
580
|
+
throw new Error(`pay received ${JSON.stringify(handedInput('r1:pay:-:0'))}`);
|
|
561
581
|
}
|
|
562
582
|
|
|
563
583
|
// `pay` settles; its edge reads `needs.run.amount` — 120, the run's — and routes to the audit.
|
|
@@ -615,7 +635,7 @@ const scheduler = {
|
|
|
615
635
|
addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
|
|
616
636
|
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
617
637
|
};
|
|
618
|
-
const
|
|
638
|
+
const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
|
|
619
639
|
|
|
620
640
|
const flow: FlowDefinition = {
|
|
621
641
|
flowId: 'review-each', version: 1, entry: 'each',
|
|
@@ -650,12 +670,12 @@ payload.invoices[0]!.amount = 1; // yours to change: the instance keeps its ow
|
|
|
650
670
|
// Two asks parked, one per invoice. Answered out of order, each `pay` gets ITS instance's invoice.
|
|
651
671
|
driver.resolve('r1:review:1:0', 'done', { result: { approved: true }, actor: 'bo' });
|
|
652
672
|
driver.resolve('r1:review:0:0', 'done', { result: { approved: true }, actor: 'ana' });
|
|
653
|
-
console.log(
|
|
654
|
-
if (JSON.stringify(
|
|
655
|
-
throw new Error(`pay@0 received ${JSON.stringify(
|
|
673
|
+
console.log(handedInput('r1:pay:0:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
|
|
674
|
+
if (JSON.stringify(handedInput('r1:pay:0:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
|
|
675
|
+
throw new Error(`pay@0 received ${JSON.stringify(handedInput('r1:pay:0:0'))}`);
|
|
656
676
|
}
|
|
657
|
-
if (JSON.stringify(
|
|
658
|
-
throw new Error(`pay@1 received ${JSON.stringify(
|
|
677
|
+
if (JSON.stringify(handedInput('r1:pay:1:0')) !== '{"invoice":{"id":"INV-2","amount":40},"answer":{"approved":true}}') {
|
|
678
|
+
throw new Error(`pay@1 received ${JSON.stringify(handedInput('r1:pay:1:0'))}`);
|
|
659
679
|
}
|
|
660
680
|
|
|
661
681
|
// Each `pay` settles; its edge reads `needs.it.amount` — 120 audits, 40 does not.
|
|
@@ -672,6 +692,83 @@ const [finding] = validateDefinition(outside, registry);
|
|
|
672
692
|
if (finding?.rule !== 'FD-V49' || !finding.detail.includes('move this node inside the map')) throw new Error(`expected FD-V49, got ${finding?.rule}`);
|
|
673
693
|
```
|
|
674
694
|
|
|
695
|
+
### `"someNode!error"` — what FAILED, from the nodes after it
|
|
696
|
+
|
|
697
|
+
**`"lookup!error"` names node `lookup`'s ERROR** — the outcome's `error`, the string the scheduler reduced the failure to
|
|
698
|
+
(section 6) — where `"lookup"` names its result (`spec/flow-definition.md` v0.49,
|
|
699
|
+
[ADR 0131](doc/decisions/0131-a-reference-may-name-a-nodes-error.md), since 0.11.0). A failed task's result is `null`, so
|
|
700
|
+
until it a node after a failure edge — an ask asking a person *what now?* above all — could not say what failed.
|
|
701
|
+
|
|
702
|
+
| | |
|
|
703
|
+
|---|---|
|
|
704
|
+
| what it is | the effective outcome's `error`: the record's, or a RESOLUTION's where a person resolved the node (`resolve(id, 'failed', { error })` on a parked task, a declined ask; `'resolved as failed'` where they gave none). **`null` where the output SUCCEEDED** — the key is present, holding `null` |
|
|
705
|
+
| its forms | the plain reference's four: `"lookup!error"`, `"lookup!error?"`, `["lookup!error"]`, `["lookup!error?"]` — the `?` goes LAST. It names the same node's same outputs as `"lookup"`, so `FD-V27`-`FD-V29` and the `?` and array rules are the plain form's, and the fixes `FD-V28` and `FD-V29` name keep the `!error` (`FD-V27` names the node, as for `"lookup"`) |
|
|
706
|
+
| where it is legal | wherever `"lookup"` is: a `task`'s, a `map`'s or an `ask`'s `needs`, and an edge's (`ctx.needs`, section 4) |
|
|
707
|
+
| what it is NOT | a path: the error is a field of the OUTCOME beside `result`, and `error` is the one part a reference may name. `"lookup?!error"`, `"!error"` and `"lookup!err"` are `FD-V26`, each naming its fix |
|
|
708
|
+
| what it costs | nothing to copy — a string or `null`, never the shared frozen copy of the result (above) |
|
|
709
|
+
| in the wiring check | the key composes as `{ type: ['string', 'null'] }` with the sample `''`, whatever the producer declares (section 15) |
|
|
710
|
+
| `referenceTarget` | the error form parses to the plain form's object plus `part: 'error'`, present ONLY there (`AimedReference`), so every other spelling parses as before. A resolver of your own must read `part`; an aiming rule may ignore it |
|
|
711
|
+
|
|
712
|
+
**The default — a failure edge handing `{ error }` to its successor — was declined** (P43 (c2)): it would change what every
|
|
713
|
+
existing failure edge's successor receives. You name the error where you want it.
|
|
714
|
+
|
|
715
|
+
The app's case — `lookup` fails, an ask after its failure edge shows the person why, and the node both roads reach reads
|
|
716
|
+
the error beside the result:
|
|
717
|
+
|
|
718
|
+
```ts run
|
|
719
|
+
import { Driver, fold, liveToken, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
|
|
720
|
+
|
|
721
|
+
const added: { id: string; payload: { input: unknown } }[] = [];
|
|
722
|
+
const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
|
|
723
|
+
const scheduler = {
|
|
724
|
+
addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
|
|
725
|
+
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
726
|
+
};
|
|
727
|
+
const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
|
|
728
|
+
const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
|
|
729
|
+
|
|
730
|
+
const flow: FlowDefinition = {
|
|
731
|
+
flowId: 'lookup-or-ask', version: 1, entry: 'lookup',
|
|
732
|
+
nodes: [
|
|
733
|
+
// `optional`, so a failure SETTLES failed and routes the failure edge (a non-optional task would park, section 7).
|
|
734
|
+
{ id: 'lookup', kind: 'task', type: 'Work', optional: true, edges: [{ on: ['failed'], to: 'resolve' }, { to: 'publish' }] },
|
|
735
|
+
{ id: 'resolve', kind: 'ask', needs: { why: 'lookup!error' },
|
|
736
|
+
answerSchema: { type: 'object', required: ['value'], properties: { value: { type: 'string' } } },
|
|
737
|
+
edges: [{ on: ['failed'], to: 'skipped' }, { to: 'publish' }] },
|
|
738
|
+
{ id: 'publish', kind: 'task', type: 'Work', needs: { found: 'lookup', why: 'lookup!error', given: 'resolve?' }, edges: [{ to: 'done' }] },
|
|
739
|
+
{ id: 'skipped', kind: 'end', outcome: 'skipped' },
|
|
740
|
+
{ id: 'done', kind: 'end', outcome: 'completed' },
|
|
741
|
+
],
|
|
742
|
+
};
|
|
743
|
+
const types = { workers: { w: { url: 'worker.js' } }, nodeTypes: { Work: { worker: 'w' } } };
|
|
744
|
+
const registry = { predicates: [], ...types };
|
|
745
|
+
if (validateDefinition(flow, registry).length) throw new Error('"lookup!error" is legal on an ask and on a task');
|
|
746
|
+
|
|
747
|
+
const driver = new Driver(flow, { runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken, ...types });
|
|
748
|
+
driver.start({ invoice: 'INV-?' });
|
|
749
|
+
fire('task.failed', { id: 'r1:lookup:-:0', state: 'failed', result: null, error: 'lookup timed out after 30 s' });
|
|
750
|
+
|
|
751
|
+
// The ask parks with the error: what it shows the person.
|
|
752
|
+
const asked = driver.foldInputs().find((e) => e.kind === 'asked');
|
|
753
|
+
console.log(asked?.kind === 'asked' && asked.input); // { why: 'lookup timed out after 30 s' }
|
|
754
|
+
if (JSON.stringify(asked?.kind === 'asked' && asked.input) !== '{"why":"lookup timed out after 30 s"}') throw new Error('the ask is handed the error');
|
|
755
|
+
|
|
756
|
+
driver.resolve('r1:resolve:-:0', 'done', { result: { value: 'INV-9' }, actor: 'ana' });
|
|
757
|
+
console.log(handedInput('r1:publish:-:0')); // { found: null, why: 'lookup timed out after 30 s', given: { value: 'INV-9' } }
|
|
758
|
+
if (JSON.stringify(handedInput('r1:publish:-:0')) !== '{"found":null,"why":"lookup timed out after 30 s","given":{"value":"INV-9"}}') {
|
|
759
|
+
throw new Error(`publish received ${JSON.stringify(handedInput('r1:publish:-:0'))}`);
|
|
760
|
+
}
|
|
761
|
+
if (fold(driver.foldInputs()).outcome !== 'running') throw new Error('publish is in flight');
|
|
762
|
+
|
|
763
|
+
// A part other than `error`, or the `?` in the wrong place, is refused naming the fix.
|
|
764
|
+
const refuse = (ref: unknown) => validateDefinition({ ...flow, nodes: flow.nodes.map((n) =>
|
|
765
|
+
(n.id === 'publish' ? { ...n, needs: { why: ref } } : n)) } as FlowDefinition, registry).map((f) => `${f.rule}: ${f.detail}`);
|
|
766
|
+
for (const [ref, fix] of [['lookup!err', 'write "lookup!error"'], ['lookup?!error', 'write "lookup!error?"']] as const) {
|
|
767
|
+
const [finding] = refuse(ref);
|
|
768
|
+
if (!finding?.startsWith('FD-V26') || !finding.includes(fix)) throw new Error(`${ref}: ${finding}`);
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
675
772
|
### Validation happens at construction, not at routing time
|
|
676
773
|
|
|
677
774
|
`new Driver(...)` validates the whole definition before anything is queued. A typo cannot become a
|
|
@@ -705,6 +802,82 @@ It would have said *every path from a branch entry reaches the join* — which w
|
|
|
705
802
|
that routes a branch away from its join invalid input, and that case is the one this library exists
|
|
706
803
|
to get right (`ADR 0003`). Do not add it.
|
|
707
804
|
|
|
805
|
+
### The definition's SHAPE, as data — `definitionSchema()` and `definitionSchema(registry)`
|
|
806
|
+
|
|
807
|
+
**`definitionSchema()` returns everything above as a JSON Schema, draft 2020-12** (`spec/flow-definition.md` v0.50,
|
|
808
|
+
[ADR 0132](doc/decisions/0132-a-definitions-shape-is-a-schema-built-from-the-validator.md), since 0.11.0): every node kind
|
|
809
|
+
and its keys, which are required, the edges of each kind, the three `when` shapes, the `needs` grammar (`"lookup!error"`
|
|
810
|
+
included), a `retryPolicy`, a `transform` and an `answerSchema` — each key with a `description`. Hand it to any 2020-12
|
|
811
|
+
validator, or to an agent writing a flow, BEFORE the findings. It is BUILT from the validator's own key sets, so its
|
|
812
|
+
keys cannot drift from them (the reference tokens and the operator partition are written beside them and held to the
|
|
813
|
+
validator by its tests); it is a new plain object on every call, yours to change. A join's `of` and a fanout's branch
|
|
814
|
+
entries are held to the id charset only where they are strings — the validator does no more today
|
|
815
|
+
([Q774](doc/questions/q774.md)).
|
|
816
|
+
|
|
817
|
+
| | |
|
|
818
|
+
|---|---|
|
|
819
|
+
| it refuses | what a node, an edge or a value gets wrong in its OWN shape: an undeclared or missing key, a wrong type, an id outside `[A-Za-z0-9_-]{1,64}`, an unknown kind, a malformed reference, `when`, `retryPolicy` or `transform`, a task's key on an ask, an `answerSchema` outside the subset |
|
|
820
|
+
| it does not | anything that needs ANOTHER node or the REGISTRY — whether `to` names a node, reachability, regions and joins, where a reference points, every registered name (types, pools, groups, languages, predicates, gate types and their routes) — the last is what `definitionSchema(registry)` adds, below |
|
|
821
|
+
| the authority | **`validateDefinition(flow, registry)`**, or `runtime.check(flow)` on a runtime — for all of that and for the whole. What the schema refuses, `validateDefinition` refuses under every registry; a document it accepts may still be refused there. The root `description` says so |
|
|
822
|
+
| not | a check the library runs (nothing evaluates it), nor in [ADR 0053](doc/decisions/0053-the-schema-validator-is-a-built-in-subset-and-a-keyword-outside-it-is-refused.md)'s subset — it uses `$defs`, `$ref`, `oneOf`, `if`/`then`, `pattern` |
|
|
823
|
+
|
|
824
|
+
**`definitionSchema(registry)` is the same shape SPECIALISED to the `Registry` you validate against** (`spec/flow-definition.md`
|
|
825
|
+
v0.51, since 0.11.0) — what an agent most often gets wrong, as data: a task's `type` is one of your node types, and per
|
|
826
|
+
type (an `if`/`then`) its `pool`, its `settings` against its worker's `settingsSchema`, and a `transform` where the type only
|
|
827
|
+
transforms; `workerGroups`, `transform.lang` and a predicate name from your sets; a gate's `type`, and per gate type its
|
|
828
|
+
`settings` and — for a static type — its edges' `route` names. A registry `checkRegistry` refuses (what `new Driver` would
|
|
829
|
+
refuse) is never specialised: the call throws that refusal. What it refuses, `validateDefinition(flow, registry)` refuses.
|
|
830
|
+
|
|
831
|
+
| | |
|
|
832
|
+
|---|---|
|
|
833
|
+
| it cannot cleanly carry | the `settings` check over the EFFECTIVE settings (your type's defaults merged under the node's): a key the defaults supply is not required of the node, and a default's own value — and a top-level keyword of your settingsSchema other than `properties`, `required` and `additionalProperties` — is not checked; a `minLength` above 1 inside one of your schemas, emitted as half of it rounded up (your schemas count UTF-16 units, JSON Schema counts code points); a DERIVED gate type's routes (`switch`'s are its cases); every route wired EXACTLY ONCE; a gate type's `settingsProblem` and a language's `compile` |
|
|
834
|
+
| annotation only | each node type's worker `inputSchema` — at `$defs.inputSchema` in its type's branch, constraining nothing (it is runtime input) |
|
|
835
|
+
| the authority | as above, against that registry. The root `description` names every row of this table |
|
|
836
|
+
| mind | a validator that tests a property with `in` (`@cfworker/json-schema` 4.1.1 does) misreads a schema property of yours NAMED as an `Object.prototype` member — `constructor`, `toString`, `__proto__` — and refuses or throws where 2020-12 accepts; name your settings otherwise |
|
|
837
|
+
|
|
838
|
+
```ts run
|
|
839
|
+
import { definitionSchema, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
|
|
840
|
+
|
|
841
|
+
const schema = definitionSchema();
|
|
842
|
+
console.log(schema.$schema); // https://json-schema.org/draft/2020-12/schema
|
|
843
|
+
if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') throw new Error('a 2020-12 document');
|
|
844
|
+
if (!String(schema.description).includes('validateDefinition')) throw new Error('it names the authority for the rest');
|
|
845
|
+
|
|
846
|
+
type Def = { properties: Record<string, { description?: string }>; required: string[]; additionalProperties: boolean };
|
|
847
|
+
const defs = schema.$defs as Record<string, Def> & { reference: { anyOf: { pattern?: string }[] } };
|
|
848
|
+
console.log(defs.askNode.required); // [ 'id', 'kind', 'edges' ]
|
|
849
|
+
if (defs.askNode.additionalProperties !== false || 'type' in defs.askNode.properties) throw new Error('an ask declares no type');
|
|
850
|
+
if (!defs.taskNode.properties.retryPolicy?.description) throw new Error('every key is described');
|
|
851
|
+
|
|
852
|
+
// The reference grammar, as a pattern any validator can apply — the error form accepted, a misplaced "?" not.
|
|
853
|
+
const reference = new RegExp(defs.reference.anyOf[0]!.pattern!);
|
|
854
|
+
if (!reference.test('lookup!error?') || reference.test('lookup?!error')) throw new Error('the "!error" grammar');
|
|
855
|
+
|
|
856
|
+
// A shape the schema accepts can still be refused: an edge to a node that does not exist is the VALIDATOR's.
|
|
857
|
+
const dangling = { flowId: 'f', version: 1, entry: 'a', nodes: [{ id: 'a', kind: 'task', type: 'Work', edges: [{ to: 'ghost' }] }] } as FlowDefinition;
|
|
858
|
+
const rules = validateDefinition(dangling, { predicates: [], workers: { w: { url: 'worker.js' } }, nodeTypes: { Work: { worker: 'w' } } }).map((f) => f.rule);
|
|
859
|
+
if (rules.join() !== 'FD-V2') throw new Error(`the graph is validateDefinition's: ${rules.join()}`);
|
|
860
|
+
if (definitionSchema() === schema) throw new Error('a new object per call');
|
|
861
|
+
|
|
862
|
+
// Specialised to a registry: its node types (the built-in Transform beside yours), each type's pools and settings.
|
|
863
|
+
const registry = {
|
|
864
|
+
predicates: ['isUrgent'],
|
|
865
|
+
workers: { w: { url: 'worker.js', settingsSchema: { type: 'object', properties: { depth: { type: 'integer', minimum: 1 } }, additionalProperties: false } } },
|
|
866
|
+
nodeTypes: { Work: { worker: 'w' } },
|
|
867
|
+
};
|
|
868
|
+
const specialised = definitionSchema(registry);
|
|
869
|
+
type Task = { properties: { type: { enum: string[] } }; allOf: { then: { properties: { pool: { enum: string[] }; settings?: unknown } } }[] };
|
|
870
|
+
const task = (specialised.$defs as { taskNode: Task }).taskNode;
|
|
871
|
+
console.log(task.properties.type.enum); // [ 'Work', 'Transform' ]
|
|
872
|
+
console.log(task.allOf[0]!.then.properties.pool.enum); // [ 'Work' ]
|
|
873
|
+
if (task.allOf[0]!.then.properties.settings === undefined) throw new Error('Work\'s settings are held to its worker\'s schema');
|
|
874
|
+
if (!String(specialised.description).includes('EXACTLY ONCE')) throw new Error('it names what it cannot carry');
|
|
875
|
+
// A registry new Driver would refuse is never specialised.
|
|
876
|
+
let refused = '';
|
|
877
|
+
try { definitionSchema({ ...registry, nodeTypes: { Lost: { worker: 'missing' } } }); } catch (e) { refused = (e as Error).name; }
|
|
878
|
+
if (refused !== 'WorkerMissing') throw new Error(`a refused registry throws its refusal: ${refused}`);
|
|
879
|
+
```
|
|
880
|
+
|
|
708
881
|
---
|
|
709
882
|
|
|
710
883
|
## 4. Decisions and predicates
|
|
@@ -841,7 +1014,7 @@ flagged low confidence* was not expressible until v0.12 —
|
|
|
841
1014
|
answering [Q8](doc/questions/q8.md).
|
|
842
1015
|
|
|
843
1016
|
**An edge may carry `needs`, and its predicate sees the result under `ctx.needs`.** Same grammar as
|
|
844
|
-
a node's (section 3) — a node id, or a one-element array holding one,
|
|
1017
|
+
a node's (section 3) — a node id, or a one-element array holding one, since 0.8.0 `"$payload"` and `"$item"`, and since 0.11.0 a node's error, `"n!error"` —
|
|
845
1018
|
**minus `"."`**, which an edge has no use for: *what a predicate would have received anyway* is `ctx.outcome`. `"$payload"`
|
|
846
1019
|
and `"$item"` are not refused there: a predicate has no other road to the run's payload or to its map instance's item
|
|
847
1020
|
(section 3's two subsections route on them).
|
|
@@ -911,7 +1084,9 @@ instance, from what the instance KEEPS**: its item, while a node of it may still
|
|
|
911
1084
|
(section 3).
|
|
912
1085
|
|
|
913
1086
|
**`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
|
|
1087
|
+
predicate that writes to it throws rather than editing what the next predicate reads — and from 0.10.0 so does a
|
|
1088
|
+
write INTO a value under a reference or `"$payload"` key, the run's one shared frozen copy (section 3), and a predicate
|
|
1089
|
+
that throws stops the run; and a reference
|
|
915
1090
|
the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, section 9) exactly as a
|
|
916
1091
|
node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
|
|
917
1092
|
and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
|
|
@@ -1447,10 +1622,17 @@ keeps waiting, and the run is not stuck.
|
|
|
1447
1622
|
([W180](doc/work-items/w180-asking-and-answering-in-a-run.md), [ADR 0109](doc/decisions/0109-an-ask-parks-with-nothing-dispatched-and-its-answer-is-its-result.md), `spec/run-state.md` `RN-R36`/`RN-R37`):
|
|
1448
1623
|
|
|
1449
1624
|
- **Nothing is handed to the scheduler** — the scheduler never sees the ask's id. The driver appends ONE fold input,
|
|
1450
|
-
`{ kind: 'asked', taskId, input }`; the fold reads the node `suspended` and the run `awaiting_action`, exactly the
|
|
1625
|
+
`{ kind: 'asked', taskId, input, observedAt? }`; the fold reads the node `suspended` and the run `awaiting_action`, exactly the
|
|
1451
1626
|
words a parked task gives (so `done` waits, below), and the node's entry in `nodes` carries **`input`** — what reached
|
|
1452
1627
|
the ask, its `needs` composed — kept after the answer. Tell an ask from a parked failure by the node's KIND, or by the
|
|
1453
1628
|
`asked` element, never by an error string. In a fan-out the ask's branch is LIVE, so **its join waits for the person**.
|
|
1629
|
+
- **WHEN it was asked** (since 0.11.0, [ADR 0130](doc/decisions/0130-a-regenerated-asked-element-keeps-the-stored-runs-time.md)):
|
|
1630
|
+
the element's `observedAt` is the driver's `now` — the clock an outcome record's `observedAt` reads — and the node's
|
|
1631
|
+
entry carries it as **`askedAt`**, kept after the answer as `input` is. **A resume KEEPS the stored time**: the replay
|
|
1632
|
+
regenerates the element and takes its stored `observedAt`, so a wait measured across a resume does not restart; past
|
|
1633
|
+
the stored tail it is the resumed driver's `now`. An `asked` element written before 0.11.0 has none, and its run
|
|
1634
|
+
resumes with none. **A driver older than 0.11.0 refuses a newer checkpoint `diverged` at its first ask** — resume a
|
|
1635
|
+
run on a driver at least as new as the one that wrote it.
|
|
1454
1636
|
- **The answer is `driver.resolve(taskId, …)`** — the same call as below. `'done'` with `result` the answer, held to the
|
|
1455
1637
|
ask's `answerSchema` (refused as `BoundaryRefused` with NOTHING appended and the ask still parked — answer again;
|
|
1456
1638
|
`validateBoundaries: false` turns it off); `'failed'` with an `error` is the person DECLINING, held to nothing.
|
|
@@ -1463,7 +1645,7 @@ keeps waiting, and the run is not stuck.
|
|
|
1463
1645
|
run's log, once per ask — that element keeps what reached the ask, whatever its size. A predicate's `ctx.outcome` for an ask has `attempts: 0` and
|
|
1464
1646
|
**`schedulerTimestamp`, `startTime`, `endTime` and `executionTime` all `null`** — no scheduler was involved.
|
|
1465
1647
|
- **BREAKING in 0.8.0** (P35 decision 8): `FoldInput` gains the kind `'asked'`, and `OutcomeRecord.schedulerTimestamp`
|
|
1466
|
-
is `number | null` — `null` only on an ask's effective outcome. `NodeRun.input` is optional and additive.
|
|
1648
|
+
is `number | null` — `null` only on an ask's effective outcome. `NodeRun.input` is optional and additive — as, since 0.11.0, are the element's `observedAt` and `NodeRun.askedAt`.
|
|
1467
1649
|
- **An answer given from a callback on the driver's own step** — `onFoldInput` on the `asked` element included — is
|
|
1468
1650
|
checked at once (a refusal is thrown or `false` returned right there) and, accepted, `resolve()` returns `true` and the
|
|
1469
1651
|
resolution is appended when the step ends: so the run's checkpoint resumes. A second answer while it is pending is
|
|
@@ -1522,6 +1704,9 @@ console.log(added, parked.outcome, parked.nodes['r1:approve:-:0'].input);
|
|
|
1522
1704
|
// [ 'r1:draft:-:0' ] awaiting_action { text: 'a draft' }
|
|
1523
1705
|
if (added.join() !== 'r1:draft:-:0' || parked.outcome !== 'awaiting_action') throw new Error('the ask should park with nothing dispatched');
|
|
1524
1706
|
if (JSON.stringify(parked.nodes['r1:approve:-:0'].input) !== '{"text":"a draft"}') throw new Error('the ask keeps what reached it');
|
|
1707
|
+
// And WHEN it was asked: the element's `observedAt` (the driver's clock, `Date.now()` by default) is the entry's `askedAt`.
|
|
1708
|
+
const askedEl = driver.foldInputs().find((e) => e.kind === 'asked');
|
|
1709
|
+
if (askedEl?.kind !== 'asked' || typeof askedEl.observedAt !== 'number' || parked.nodes['r1:approve:-:0'].askedAt !== askedEl.observedAt) throw new Error('the ask keeps when it was asked');
|
|
1525
1710
|
|
|
1526
1711
|
// An answer its schema refuses is thrown at you BY NAME — nothing appended, the ask still parked.
|
|
1527
1712
|
let refused: unknown = null;
|
|
@@ -1754,7 +1939,7 @@ need the state; step only where you hold a short one. Both return exactly what t
|
|
|
1754
1939
|
([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
1940
|
ONE task — a settlement's effective outcome, whether a task is parked, what is in flight, what a `needs` can reach — reads
|
|
1756
1941
|
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
|
|
1942
|
+
`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
1943
|
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
1944
|
O(run) a call: read them when you need the run, not after every event.
|
|
1760
1945
|
|
|
@@ -1915,6 +2100,7 @@ goes no longer folds it a second time in its own `onFoldInput`:
|
|
|
1915
2100
|
| `handle.outcome()` | the run's outcome now | O(1) — **the read to make after every input** |
|
|
1916
2101
|
| `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
2102
|
| `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 |
|
|
2103
|
+
| `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
2104
|
|
|
1919
2105
|
**At a quiescent point `handle.state()` IS `fold(handle.driver.foldInputs())`** — the same rule as above: read on a
|
|
1920
2106
|
`queueMicrotask` from `onFoldInput`, or outside the driver's turn. **It is not a live view** — a read is the state at that
|
|
@@ -1975,6 +2161,108 @@ try { (end.nodes[a]!.result as { score: number }).score = 1; } catch { wrote = f
|
|
|
1975
2161
|
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
2162
|
```
|
|
1977
2163
|
|
|
2164
|
+
### What a node was HANDED — `inputOf` and `handle.inputOf`, after the fact
|
|
2165
|
+
|
|
2166
|
+
**A run keeps what its tasks RETURNED, never what they were handed** — `RunState` holds an ask's input (`NodeRun.input`)
|
|
2167
|
+
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)),
|
|
2168
|
+
and no function over `RunState` can rebuild it exactly: the entry's payload, the edge each node took, what was visible at
|
|
2169
|
+
the moment of dispatch and a gate's memory are not in it. **From 0.10.0 you READ it instead** (`RN-R45`,
|
|
2170
|
+
[ADR 0128](doc/decisions/0128-what-a-node-was-handed-is-read-by-replaying-the-run-to-its-composition.md)):
|
|
2171
|
+
|
|
2172
|
+
```
|
|
2173
|
+
inputOf(flow, options, { header, events }, target) // the root export — a stored run, its runtime gone or not
|
|
2174
|
+
handle.inputOf(target) // a live run: the same function over its own checkpoint()
|
|
2175
|
+
```
|
|
2176
|
+
|
|
2177
|
+
It builds a driver for the stored run — DEAF: no callback, nothing handed to any scheduler — and REPLAYS the stored
|
|
2178
|
+
events, exactly as `resume()` does, until the target's composition. **So the answer is the driver's own composition,
|
|
2179
|
+
not a second one**, and costs the run's prefix up to the target. `options` is what the run's driver was built from:
|
|
2180
|
+
`runtime.registry`, plus its `joinPolicy` where you did not use `liveToken`.
|
|
2181
|
+
|
|
2182
|
+
| target | the node is | `inputs` |
|
|
2183
|
+
|---|---|---|
|
|
2184
|
+
| `{ 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 |
|
|
2185
|
+
| the same | an **ask** | one `received` — equal to its `asked` element's `input` |
|
|
2186
|
+
| `{ 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 |
|
|
2187
|
+
| `{ 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 |
|
|
2188
|
+
| `{ nodeId, branchPath }` | a **join**, at its OPENER's path | one `collection` — what it handed on when it fired |
|
|
2189
|
+
| `{ 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 |
|
|
2190
|
+
|
|
2191
|
+
A path target answers the FIRST composition on its path: an end shared by two sibling regions can be reached twice on one
|
|
2192
|
+
path, and only `{ nodeId }` lists both.
|
|
2193
|
+
|
|
2194
|
+
**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
|
|
2195
|
+
ids cannot name), `not-reached`, `diverged` (the replay produced something other than a stored element BEFORE the
|
|
2196
|
+
target — a changed predicate or definition), and `format` / `flow` (the header, refused as a resume refuses it). **A
|
|
2197
|
+
halted, cancelled or stopped run IS read**, up to its halt. **What it cannot see**: a change to the flow or the registry
|
|
2198
|
+
AFTER the target — or one that moves only an envelope, even at the target: the events carry no payloads, so a reading
|
|
2199
|
+
answers with the definition you hand it. **It re-runs your predicates and gate types**, as a resume does. The value is
|
|
2200
|
+
the reading's own — a value a `needs` reference composed is frozen, as every reader's is (section 3), the rest yours —
|
|
2201
|
+
and nothing of a live run is reachable through it. **The input a task's worker sees AFTER its transform is not answered**
|
|
2202
|
+
([Q760](doc/questions/q760.md), resolved by P43: the road serves): hand the envelope's `input` to `tryTransform` (section 15). **BREAKING in 0.10.0**:
|
|
2203
|
+
`RunHandle` gains `inputOf`, which breaks only code that BUILDS a handle.
|
|
2204
|
+
|
|
2205
|
+
```ts run
|
|
2206
|
+
import { Driver, inputOf, liveToken, type FlowDefinition, type InputOfOptions } from 'fyflow-orchestration';
|
|
2207
|
+
|
|
2208
|
+
// A scheduler double, section 12's shape: it records what it is handed and fires only what we tell it to.
|
|
2209
|
+
const added: { id: string; payload: unknown }[] = [];
|
|
2210
|
+
const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
|
|
2211
|
+
const scheduler = {
|
|
2212
|
+
addTask(task: unknown) { added.push(task as { id: string; payload: unknown }); },
|
|
2213
|
+
addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
|
|
2214
|
+
};
|
|
2215
|
+
const settle = (id: string, result: unknown) => {
|
|
2216
|
+
const detail = { id, state: 'done', result, attempts: 0, timestamp: 1, startTime: 1, endTime: 2, executionTime: 1 };
|
|
2217
|
+
for (const fn of listeners.get('task.completed') ?? []) fn({ detail });
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
// A map over invoices; each check reads its invoice and the run's payload; a rejected one ENDS in the map.
|
|
2221
|
+
const flow: FlowDefinition = {
|
|
2222
|
+
flowId: 'invoices', version: 1, entry: 'fetch',
|
|
2223
|
+
nodes: [
|
|
2224
|
+
{ id: 'fetch', kind: 'task', type: 'Fetch', edges: [{ to: 'each' }] },
|
|
2225
|
+
{ id: 'each', kind: 'map', over: 'invoices', branch: 'check' },
|
|
2226
|
+
{ id: 'check', kind: 'task', type: 'Check', needs: { invoice: '.', run: '$payload' }, edges: [
|
|
2227
|
+
{ when: { path: 'outcome.result.ok', op: 'eq', value: false }, to: 'rejected' },
|
|
2228
|
+
{ to: 'joined' },
|
|
2229
|
+
] },
|
|
2230
|
+
{ id: 'rejected', kind: 'end', outcome: 'rejected' },
|
|
2231
|
+
{ id: 'joined', kind: 'join', of: 'each', edges: [{ to: 'done' }] },
|
|
2232
|
+
{ id: 'done', kind: 'end', outcome: 'completed' },
|
|
2233
|
+
],
|
|
2234
|
+
};
|
|
2235
|
+
// What the run's driver was built from — and so what a reading builds its own from (joinPolicy: liveToken by default).
|
|
2236
|
+
const options: InputOfOptions = { predicates: new Map(), workers: { w: { url: 'worker.js' } }, nodeTypes: { Fetch: { worker: 'w' }, Check: { worker: 'w' } } };
|
|
2237
|
+
const driver = new Driver(flow, { runId: 'r1', scheduler, ...options, joinPolicy: liveToken });
|
|
2238
|
+
driver.start({ tenant: 't1' });
|
|
2239
|
+
settle('r1:fetch:-:0', { invoices: [{ id: 'INV-1' }, { id: 'INV-2' }, { id: 'INV-3' }] });
|
|
2240
|
+
settle('r1:check:2:0', { ok: false });
|
|
2241
|
+
settle('r1:check:0:0', { ok: true });
|
|
2242
|
+
settle('r1:check:1:0', { ok: false });
|
|
2243
|
+
|
|
2244
|
+
// What a store keeps: the header and the events (section 18). Read them back — the run's process may be long gone.
|
|
2245
|
+
const stored = driver.checkpoint();
|
|
2246
|
+
const task = inputOf(flow, options, stored, { taskId: 'r1:check:1:0' });
|
|
2247
|
+
console.log(task.ok && task.inputs[0]!.value); // { settings: {}, input: { invoice: { id: 'INV-2' }, run: { tenant: 't1' } } }
|
|
2248
|
+
const ends = inputOf(flow, options, stored, { nodeId: 'rejected' });
|
|
2249
|
+
console.log(ends.ok && ends.inputs.map((i) => [i.branchPath, i.value])); // [['2', { ok: false }], ['1', { ok: false }]]
|
|
2250
|
+
const nope = inputOf(flow, options, stored, { nodeId: 'audit' });
|
|
2251
|
+
console.log(nope.ok ? 'answered' : nope.reason); // 'unknown'
|
|
2252
|
+
|
|
2253
|
+
// The answer IS what the driver handed the scheduler — the same envelope, built by the same code.
|
|
2254
|
+
const handed = added.find((t) => t.id === 'r1:check:1:0')!.payload;
|
|
2255
|
+
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');
|
|
2256
|
+
if (JSON.stringify(handed) !== '{"settings":{},"input":{"invoice":{"id":"INV-2"},"run":{"tenant":"t1"}}}') throw new Error(`check@1 was handed ${JSON.stringify(handed)}`);
|
|
2257
|
+
// A node id alone: every path the END was reached on, in the order the run reached them — 2 before 1.
|
|
2258
|
+
if (!ends.ok || JSON.stringify(ends.inputs.map((i) => i.branchPath)) !== '["2","1"]') throw new Error('rejected was reached at 2, then 1');
|
|
2259
|
+
if (ends.inputs.some((i) => i.handed !== 'received' || JSON.stringify(i.value) !== '{"ok":false}')) throw new Error('an end is handed what reached it');
|
|
2260
|
+
if (nope.ok || nope.reason !== 'unknown') throw new Error('a node the flow does not have is unknown');
|
|
2261
|
+
// A prefix of the events — a store that has not caught up — reads what it holds and no more.
|
|
2262
|
+
const early = inputOf(flow, options, { header: stored.header, events: stored.events.slice(0, 4) }, { taskId: 'r1:check:1:0' });
|
|
2263
|
+
if (early.ok || early.reason !== 'not-reached') throw new Error('a task the prefix did not dispatch is not-reached');
|
|
2264
|
+
```
|
|
2265
|
+
|
|
1978
2266
|
### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
|
|
1979
2267
|
|
|
1980
2268
|
**`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
|
|
@@ -2855,6 +3143,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
|
|
|
2855
3143
|
| ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
|
|
2856
3144
|
| ~~**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
3145
|
| 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 |
|
|
3146
|
+
| 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), resolved — the envelope's `input` through `tryTransform` serves) |
|
|
2858
3147
|
| a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
|
|
2859
3148
|
| **per-join policy choice** | one policy per driver |
|
|
2860
3149
|
| a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
|
|
@@ -4024,6 +4313,7 @@ whatever arcs reach the node, on the entry node too** (W166, P37 stop-and-ask (d
|
|
|
4024
4313
|
| `shape` | `'given'` — not an arc; `checkWiring` never produces it |
|
|
4025
4314
|
| `producer` | **absent** — the value came from no node (and `through` too) |
|
|
4026
4315
|
| `sample` | `{ source: 'given', value, result }` — the value you gave, and what the transform made of it; no `arity`. **Never `'example'` or `'witness'`** |
|
|
4316
|
+
| `cause` | **on `transform-fails` only** (since 0.11.0, every road — `checkWiring`'s arcs too): `'threw'` — the worker half did not import, had no default export, or its `compile` or `evaluate` threw — or `'schema'`, a result the node's `inputSchema` refused — the type `TransformFailureCause`, a root export. Absent on every other kind. Read it rather than `why`'s prefix |
|
|
4027
4317
|
| `kind` | `transform-passes`, or `transform-fails` with the finding against the node's worker `inputSchema` — or naming the error, where the expression does not compile or throws on this value; `unchecked` for a language, type, worker or worker schema the registry lacks, as on an arc, naming which — and for a `null` candidate, which takes away any transform the node declares too: there is nothing to run the value through, and holding that value to the `inputSchema` is `findings(schema, value)` |
|
|
4028
4318
|
|
|
4029
4319
|
**The value is `$` as you give it: no `needs` is composed over it**, because a `needs` composes what
|
|
@@ -4080,6 +4370,7 @@ if (entry!.kind !== 'transform-passes') throw new Error(`expected the given valu
|
|
|
4080
4370
|
const [wrong] = await tryTransform(registry, flow, 'read', rename, { input: { folder: 3 } });
|
|
4081
4371
|
console.log(`${wrong!.kind}: ${wrong!.why}`); // transform-fails: the transform's result on the given input fails the consumer's inputSchema — $.source expected string, ...
|
|
4082
4372
|
if (wrong!.kind !== 'transform-fails' || !wrong!.why.includes('$.source expected string')) throw new Error(`expected the finding, got ${wrong!.why}`);
|
|
4373
|
+
if (wrong!.cause !== 'schema') throw new Error(`a result the schema refused is cause 'schema', got ${wrong!.cause}`);
|
|
4083
4374
|
|
|
4084
4375
|
// THE INNER NODE. Without an input its one arc runs on `reader`'s declared EXAMPLE; given a value, it runs on that.
|
|
4085
4376
|
const join = { lang: 'jsonata', expr: '{ "text": $join(lines, " ") }' };
|
|
@@ -4092,6 +4383,8 @@ if (JSON.stringify(mine!.sample!.result) !== JSON.stringify({ text: 'typed by ha
|
|
|
4092
4383
|
// An expression the engine refuses: `transform-fails`, naming the throw — the compile error, on the given input.
|
|
4093
4384
|
const [broken] = await tryTransform(registry, flow, 'sum', { lang: 'jsonata', expr: '{{{' }, { input: { lines: [] } });
|
|
4094
4385
|
if (broken!.kind !== 'transform-fails' || !broken!.why.startsWith('the transform threw on the given input')) throw new Error(`expected the compile error, got ${broken!.why}`);
|
|
4386
|
+
// The same, as DATA (since 0.11.0): `cause` is 'threw' — no result was produced — where the finding above is 'schema'.
|
|
4387
|
+
if (broken!.cause !== 'threw' || entry!.cause !== undefined) throw new Error(`a throw is cause 'threw', and a passing verdict has none — got ${broken!.cause}, ${entry!.cause}`);
|
|
4095
4388
|
```
|
|
4096
4389
|
|
|
4097
4390
|
**BREAKING in 0.8.0** (P37 decision 10): **`SampleSource` gains `'given'`**, **`WiringShape` gains
|
|
@@ -4398,7 +4691,7 @@ no serializer. A CHECKPOINT is `{ header, events }`
|
|
|
4398
4691
|
([ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md)) —
|
|
4399
4692
|
the header is five facts the events cannot regenerate (`format`, `runId`, `flowId`, `version` and a
|
|
4400
4693
|
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:
|
|
4694
|
+
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
4695
|
|
|
4403
4696
|
| | where you get it | what you do with it |
|
|
4404
4697
|
|---|---|---|
|
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.11.0`.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|