fyflow-orchestration 0.11.0 → 0.13.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 +172 -60
- package/README.md +5 -5
- package/dist/browser/chunks/{chunk-5CVSWDCB.js → chunk-53EDI674.js} +144 -52
- package/dist/browser/index.js +30 -15
- package/dist/browser/scheduler/index.js +1 -1
- package/dist/node/chunks/{chunk-5CVSWDCB.js → chunk-53EDI674.js} +144 -52
- package/dist/node/index.js +30 -15
- package/dist/node/scheduler/index.js +1 -1
- package/dist/types/src/definition/assist.d.ts +1 -1
- package/dist/types/src/definition/condition.d.ts +28 -7
- package/dist/types/src/definition/types.d.ts +37 -11
- package/dist/types/src/definition/validate.d.ts +12 -6
- package/dist/types/src/definition/wiring.d.ts +5 -5
- package/dist/types/src/driver/driver.d.ts +16 -5
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -218,11 +218,12 @@ if (v48.join() !== 'FD-V48') throw new Error(`expected FD-V48, got ${v48.join()}
|
|
|
218
218
|
>
|
|
219
219
|
> **What each instance receives, and what the join hands on.** Each instance is dispatched with
|
|
220
220
|
> **its own item** — `over`'s array at that instance's index, as its own copy, so no two instances
|
|
221
|
-
> share an object — and the join carries **every item's result keyed by that item's
|
|
222
|
-
> map of three items
|
|
223
|
-
> `{ "0": …, "1": …, "2": … }
|
|
224
|
-
>
|
|
225
|
-
>
|
|
221
|
+
> share an object — and the join carries **every item's result keyed by that item's INDEX**, one
|
|
222
|
+
> segment, wherever the map sits: a map of three items dispatches three tasks and its successor
|
|
223
|
+
> receives `{ "0": …, "1": …, "2": … }`, and so does a map nested in a fan-out branch. **Since 0.12.0**
|
|
224
|
+
> ([ADR 0138](doc/decisions/0138-a-join-hands-on-each-branch-under-its-own-key-and-a-checkpoint-of-the-old-keying-is-refused-by-format.md)): until then a nested map keyed its collection with the whole path — a map
|
|
225
|
+
> inside branch 1 gave `{ "1.0": …, "1.1": … }` — and *What a join carries downstream* in section 5 says
|
|
226
|
+
> what moved with it.
|
|
226
227
|
> [ADR 0037](doc/decisions/0037-what-a-branch-receives-is-a-property-of-the-fan-out-and-what-a-join-carries-is-a-property-of-the-region.md),
|
|
227
228
|
> and see *What a join carries downstream* in section 5 for what happens when an item's branch does
|
|
228
229
|
> not arrive.
|
|
@@ -292,7 +293,7 @@ if (!noGroups.some((f) => f.rule === 'FD-V31' && f.at === 'score')) {
|
|
|
292
293
|
| `join` | `of: '<fanout id>'`. Fires when its policy says so | it does not have **exactly one** edge, or that edge carries `when` or `on` (`FD-V7`) |
|
|
293
294
|
| `end` | terminal. `outcome` is **your label**, never interpreted | — |
|
|
294
295
|
| `ask` | waits for a PERSON — `edges`, and optionally `settings` (opaque), `answerSchema` (what a `done` answer must satisfy) and `needs` (what reaches it). Never dispatched, and has a task id; its answer is its output. A run PARKS at one and takes the person's answer at `resolve()` (section 7) | it has no `edges`, or **no edge of it is eligible for `done`** (`FD-V7`, as a task); `settings` that is not a plain object (`FD-V21`); a key only a task declares — `type`, `pool`, `optional`, `retryPolicy`, `workerGroups`, `transform` — one finding per key (`FD-V47`); an `answerSchema` outside the schema subset (`FD-V48`) |
|
|
295
|
-
| `gate` | `type: '<gate type>'`, optional `settings`, and `edges` of `{ to, route? }` — routes the branch that reaches it and hands on what reached it. `settings.key` is a dotted key path over that value
|
|
296
|
+
| `gate` | `type: '<gate type>'`, optional `settings`, and `edges` of `{ to, route? }` — routes the branch that reaches it and hands on what reached it. `settings.key` is a dotted key path over that value — `series.id`, and since 0.12.0 an array element by a bracket index, `files[0].series` (a plain `xs.1` is still a KEY, never an index). Built in: `distinct`, `if` and `switch`. Run by the driver where a branch enters it (section 19) | its `type` is not a registered gate type, or its type's `routesFor` throws or returns a malformed route set for this gate, or its `settingsProblem` throws or returns neither a message nor `null` (`FD-V39`); its `settings` are not a plain object its type's schema accepts, its type's `settingsProblem` returns a message, they cannot be copied for the type, or its `key` is not a key path (`FD-V40`); a route is unwired, wired twice, or not its type's, or a gate edge carries `when`, `on` or `needs` (`FD-V41`) — and `route` on any other node's edge is `FD-V41` too |
|
|
296
297
|
|
|
297
298
|
**A `task` node — and an `ask` — must have an edge that can be taken when it succeeds.** A node whose only edge is
|
|
298
299
|
`{ when: { predicate: 'p' }, to: 'x' }` is refused at construction — it is the mistake the format most invites, and
|
|
@@ -522,7 +523,7 @@ that passed it through.
|
|
|
522
523
|
| 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 |
|
|
523
524
|
| 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 |
|
|
524
525
|
| `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
|
|
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`
|
|
526
|
+
| on a resume | the same value, from the stored header: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT` does not move for it — it is 2 since 0.12.0, for the join's keys, section 5) |
|
|
526
527
|
| 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 |
|
|
527
528
|
| 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) |
|
|
528
529
|
| 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) |
|
|
@@ -617,7 +618,7 @@ passed it through.
|
|
|
617
618
|
| always there | no `?` and no array form: `["$item"]` and `"$item?"` are `FD-V26`, each naming the bare form |
|
|
618
619
|
| what it is | its OWN deep copy of the item, per key per read — two `"$item"` keys on one node are two objects, and a worker or a predicate writing into one reaches nothing else |
|
|
619
620
|
| what an instance KEEPS | its item, by reference to the element the map received — and only where some node of that map's region reads `"$item"`: a map nobody reads it from keeps nothing. **Released when nothing of that instance may still be entered** — its branch has ended (arrived at the map's join, exited the region, or been lost) AND no branch of a region nested inside it is still live — told by those branches' terminal region events, never by a join: an ask parked in the instance keeps it until the person answers, and a nested fan-out whose join a policy fired early keeps it until its last branch ends. Released whole when the run halts, is cancelled or is stopped (`spec/run-state.md` `RN-R13`) |
|
|
620
|
-
| on a resume | rebuilt by the replay, which re-enters the map: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT`
|
|
621
|
+
| on a resume | rebuilt by the replay, which re-enters the map: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT` does not move for it — it is 2 since 0.12.0, for the join's keys, section 5) |
|
|
621
622
|
| at `start()` | `FD-R4` composes it on an ENTRY map's branch task from the element the walk reached — which IS that instance's item — and holds it to the worker's `inputSchema` like any key (section 9). *Until 0.8.0's W191 ANY `needs` on such a task — `"."` included — made `start()` throw an `UnresolvedBranchPath` instead of `BoundaryRefused`* |
|
|
622
623
|
| in the wiring check | an arc composed through a `"$item"` key reads the enclosing map's ITEM schema, one sample per element; `unchecked` for an ENTRY map, whose items come from the run's payload, which no schema declares (section 15) |
|
|
623
624
|
| what it costs | one copy of ONE element per key per read — the cheap end of section 3's cliff — and the retention above, at most the `over` array the log already holds |
|
|
@@ -811,8 +812,14 @@ included), a `retryPolicy`, a `transform` and an `answerSchema` — each key wit
|
|
|
811
812
|
validator, or to an agent writing a flow, BEFORE the findings. It is BUILT from the validator's own key sets, so its
|
|
812
813
|
keys cannot drift from them (the reference tokens and the operator partition are written beside them and held to the
|
|
813
814
|
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
|
|
815
|
-
([Q774](doc/questions/q774.md)).
|
|
815
|
+
entries are held to the id charset, a non-string refused — as `validateDefinition` refuses one under `FD-V21` **since
|
|
816
|
+
0.12.0** (`spec/flow-definition.md` v0.52, [Q774](doc/questions/q774.md)). **That is a change in what is accepted**:
|
|
817
|
+
`of: null`, or `branches: ["b", "c", 5]`, passed 0.11.0's validator with no finding when nothing else in the flow
|
|
818
|
+
tripped over it, and is refused now. **And the other way, since 0.12.0** (`spec/flow-definition.md` v0.53,
|
|
819
|
+
[ADR 0137](doc/decisions/0137-a-key-path-indexes-an-array-by-a-bracket-suffix-and-a-plain-segment-keeps-its-meaning.md)):
|
|
820
|
+
a gate's key and a comparison's `path` may carry a bracket index — `files[0].series`, `outcome.result.files[0].series` — so both patterns accept it — and
|
|
821
|
+
`KEY_PATH_RE`, the gate key's, is the exported constant whose `source` moved. Only more is accepted. *(It, the parse and
|
|
822
|
+
the read a host may call are section 4's* Reading a key path the way the library does*.)*
|
|
816
823
|
|
|
817
824
|
| | |
|
|
818
825
|
|---|---|
|
|
@@ -888,14 +895,14 @@ a flow document can say what a branch depends on without any code at all, which
|
|
|
888
895
|
an agent emit a complete flow. A name still works and is what a predicate that COMPUTES uses.
|
|
889
896
|
|
|
890
897
|
```ts run
|
|
891
|
-
import { validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
|
|
898
|
+
import { evaluateWhen, validateDefinition, type FlowDefinition, type When } from 'fyflow-orchestration';
|
|
892
899
|
|
|
893
900
|
// A `when` is EXACTLY ONE of three shapes, told apart by which keys it carries.
|
|
894
901
|
const flow: FlowDefinition = {
|
|
895
902
|
flowId: 'declared', version: 1, entry: 'score',
|
|
896
903
|
nodes: [
|
|
897
904
|
{ id: 'score', kind: 'task', type: 'ScoreWorker', edges: [
|
|
898
|
-
// 1. a COMPARISON — a key path over the context, an operator, a literal.
|
|
905
|
+
// 1. a COMPARISON — a key path over the context, an operator, a literal (or `valueFrom`, below).
|
|
899
906
|
{ when: { path: 'outcome.result.confidence', op: 'lt', value: 0.5 }, to: 'review' },
|
|
900
907
|
// 2. a flat GROUP — `all` or `any`, whose members are comparisons.
|
|
901
908
|
{ when: { any: [
|
|
@@ -934,16 +941,41 @@ if (refuse({ path: 'result.confidence', op: 'lt', value: 0.5 })[0] !== 'FD-V43')
|
|
|
934
941
|
if (refuse({ path: 'outcome.result.name', op: 'matches', value: '^a' })[0] !== 'FD-V44') throw new Error('an unknown operator is FD-V44');
|
|
935
942
|
// FD-V45 — a group is FLAT, and a member naming a registered predicate could be IMPURE.
|
|
936
943
|
if (refuse({ all: [{ any: [{ path: 'outcome.result.a', op: 'eq', value: 1 }] }] })[0] !== 'FD-V45') throw new Error('nesting is FD-V45');
|
|
944
|
+
|
|
945
|
+
// `valueFrom` (since 0.13.0) — compare against ANOTHER key path over the same context, in place of `value`.
|
|
946
|
+
// Exactly one of the two: both, or neither, is FD-V42. It takes the same roots as `path` (FD-V43).
|
|
947
|
+
if (refuse({ path: 'outcome.result.score', op: 'gte', valueFrom: 'outcome.result.threshold' }).length !== 0) throw new Error('valueFrom alone is valid');
|
|
948
|
+
if (refuse({ path: 'outcome.result.score', op: 'gte', value: 50, valueFrom: 'outcome.result.threshold' })[0] !== 'FD-V42') throw new Error('both is FD-V42');
|
|
949
|
+
if (refuse({ path: 'outcome.result.score', op: 'gte', valueFrom: 'result.threshold' })[0] !== 'FD-V43') throw new Error('a rootless valueFrom is FD-V43');
|
|
950
|
+
// A `needs` path — on either side — reads a key THIS EDGE's `needs` declares; `refuse`'s edge declares none, so FD-V43.
|
|
951
|
+
if (refuse({ path: 'outcome.result.size', op: 'lte', valueFrom: 'needs.limit.max' })[0] !== 'FD-V43') throw new Error('an undeclared needs key is FD-V43');
|
|
952
|
+
|
|
953
|
+
// What it resolves to is compared as a literal would be — and an ABSENT one is NO MATCH for every operator,
|
|
954
|
+
// so `x gt y` and `x lte y` are BOTH false where `y` is absent. No `default` stands in for it: that is the left's.
|
|
955
|
+
const ask = (when: When, result: unknown) => evaluateWhen(when, { outcome: { result } as never, needs: {} }, new Map());
|
|
956
|
+
const gt: When = { path: 'outcome.result.x', op: 'gt', valueFrom: 'outcome.result.y' };
|
|
957
|
+
const lte: When = { path: 'outcome.result.x', op: 'lte', valueFrom: 'outcome.result.y' };
|
|
958
|
+
if (!ask(gt, { x: 5, y: 3 }) || ask(lte, { x: 5, y: 3 })) throw new Error('5 gt 3, and not 5 lte 3');
|
|
959
|
+
if (ask(gt, { x: 5 }) || ask(lte, { x: 5 })) throw new Error('y absent: both are false');
|
|
960
|
+
// A resolved object or array is no match too, `ne` included — `!==` would have said true.
|
|
961
|
+
if (ask({ path: 'outcome.result.x', op: 'ne', valueFrom: 'outcome.result.y' }, { x: 5, y: { v: 5 } })) throw new Error('ne against an object is no match');
|
|
937
962
|
```
|
|
938
963
|
|
|
939
964
|
| | |
|
|
940
965
|
|---|---|
|
|
941
|
-
| `path` | a key path over the context, **rooted at `outcome` or `needs`** — `outcome.result.x`, `outcome.taskId`, `needs.earlier.flag
|
|
966
|
+
| `path` | a key path over the context, **rooted at `outcome` or `needs`** — `outcome.result.x`, `outcome.taskId`, `needs.earlier.flag`, and an array element by a bracket index after a key, `outcome.result.items[0].score` (never on the root). No wildcard, no expression. **A `needs` path names a key THIS EDGE's `needs` declares** (since 0.13.0, `FD-V43`): any other would be absent for ever |
|
|
942
967
|
| `op` | one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `startsWith`, and **nothing else** |
|
|
943
|
-
| `value` | a scalar: `null`, a boolean, a finite number or a string |
|
|
968
|
+
| `value` | a scalar: `null`, a boolean, a finite number or a string. **Exactly one of `value` and `valueFrom`** (`FD-V42`) |
|
|
969
|
+
| `valueFrom` | since 0.13.0, in place of `value` — **another key path over the same context**, held to everything `path` is; what it resolves to is compared as a literal would be. **No match where it does not resolve, or resolves to an object or an array** |
|
|
944
970
|
| `default` | optional — what is compared when the path does not resolve **or resolves to `null`**, which is exactly `??`. Without it an absent path is **no match**, and a resolved `null` is compared as the scalar it is |
|
|
945
971
|
| `length` | optional `true` — compare the LENGTH of an array or a string, after `default` |
|
|
946
972
|
|
|
973
|
+
**The type moved with it, in 0.13.0.** `PredicateLeaf` is a two-member union — `{ value, valueFrom?: never }` or
|
|
974
|
+
`{ valueFrom, value?: never }` over the shared `PredicateLeafBase` — so a comparison with both, or neither, fails to
|
|
975
|
+
compile, and code READING `leaf.value` now sees `PredicateScalar | undefined`: tell the two apart by `'valueFrom' in leaf`.
|
|
976
|
+
`PREDICATE_LEAF_KEYS` gained `valueFrom` and `PREDICATE_LEAF_REQUIRED` lost `value`; `predicateProblem(when, edgeNeeds?)`
|
|
977
|
+
takes the keys an edge's `needs` declares, and asks the `needs` rule only when given them.
|
|
978
|
+
|
|
947
979
|
**Three things to know before you write one.**
|
|
948
980
|
**A comparison between kinds is no match, never a coercion** — `{ op: 'lt', value: 50 }` is false
|
|
949
981
|
against the string `"9"` where JavaScript's `<` would say true.
|
|
@@ -954,6 +986,10 @@ holding `null` as well as a missing one.
|
|
|
954
986
|
that holds over NUMBERS, and over an absent path where you declared a `default`. Over a value an
|
|
955
987
|
ordering operator cannot compare, a string or a boolean, **both it and its complement are false**.
|
|
956
988
|
`startsWith` has no complement here at all.
|
|
989
|
+
**An absent `valueFrom` is no match for every operator, and nothing makes it otherwise** — `default` and `length`
|
|
990
|
+
are the LEFT side's, and there is no `valueDefault`. So `x gt y` and `x lte y` are BOTH false where `y` is
|
|
991
|
+
absent: route the absent case by its own edge if it matters. And a `valueFrom` resolving to an object or an
|
|
992
|
+
array is no match even for `ne`, where an object on the LEFT with `ne` is still true — the left side is 0.12.0's.
|
|
957
993
|
**A declared predicate CANNOT be impure.** There is no clock, no random source and no way to name
|
|
958
994
|
anything but the context, so re-evaluating it over the same log gives the same answer. A registered
|
|
959
995
|
name has no such guarantee — which is the trade, and why a regular expression, a date window or a
|
|
@@ -1091,6 +1127,58 @@ the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, se
|
|
|
1091
1127
|
node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
|
|
1092
1128
|
and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
|
|
1093
1129
|
|
|
1130
|
+
### Reading a key path the way the library does — `readKeyPath`, `keyPathSteps`, `KEY_PATH_RE`, `PREDICATE_ROOTS`
|
|
1131
|
+
|
|
1132
|
+
**A host that suggests, checks or previews a key path need not keep its own copy of the spelling.** Five names, all
|
|
1133
|
+
from the root entry in node, the browser and Deno — `readKeyPath`, `KEY_PATH_RE` and `PREDICATE_ROOTS` since before
|
|
1134
|
+
0.12.0, **`keyPathSteps` and its `KeyPathStep` type since 0.13.0** (P45, fyflow-app's pass 6 #2). They are the ones a
|
|
1135
|
+
gate's `settings.key`, a comparison's `path` and the wiring check read, so a host using them cannot read a path
|
|
1136
|
+
differently from a run.
|
|
1137
|
+
|
|
1138
|
+
| name | what it is |
|
|
1139
|
+
|---|---|
|
|
1140
|
+
| `KEY_PATH_RE` | the GRAMMAR (`spec/flow-definition.md` section 2's KEY PATH): dot-separated object keys, each `[A-Za-z0-9_-]{1,64}` optionally followed by array indexes `[n]` — `n` is `0` or a number with no leading zero, at most 999,999,999 — never an index leading the path; no quoted key, no wildcard, no expression. A gate's `key` is refused `FD-V40` outside it |
|
|
1141
|
+
| `keyPathSteps(path)` | the PARSE: a path's steps in order, each `{ key }` or `{ index }` — that union is `KeyPathStep`. **Total**: any string has steps, and a segment the grammar does not spell is ONE literal key (`files[*]` is a key named `files[*]`), so test the path with `KEY_PATH_RE` first |
|
|
1142
|
+
| `readKeyPath(value, path)` | the READ: a KEY step is an OWN key of a PLAIN object, an INDEX step an own element of a REAL array; anything else — a missing key, `null`, an array under a key step, a class instance, a hole — is `undefined`. **A plain numeric segment is a KEY**: `xs.1` is nothing over an array, `xs[1]` is its element. The value at the end is returned whatever it is |
|
|
1143
|
+
| `PREDICATE_ROOTS` | a task's or a join's edge comparison's two ROOTS, `outcome` and `needs` (`FD-V43`): the first key of its `path` (and of a `valueFrom`), read up to the first `.` or `[`; never indexed, never alone. **An `if` gate's one root, `input`, is not a public name** — section 19 |
|
|
1144
|
+
|
|
1145
|
+
```ts run
|
|
1146
|
+
import { KEY_PATH_RE, keyPathSteps, PREDICATE_ROOTS, readKeyPath, type KeyPathStep } from 'fyflow-orchestration';
|
|
1147
|
+
|
|
1148
|
+
const value = { files: [{ name: 'a.csv' }, { name: 'b.csv' }], xs: ['p', 'q'] };
|
|
1149
|
+
|
|
1150
|
+
// The grammar, the parse and the read of one path.
|
|
1151
|
+
if (!KEY_PATH_RE.test('files[0].name')) throw new Error('files[0].name is a key path');
|
|
1152
|
+
const steps: KeyPathStep[] = keyPathSteps('files[0].name');
|
|
1153
|
+
if (JSON.stringify(steps) !== '[{"key":"files"},{"index":0},{"key":"name"}]') throw new Error(`steps: ${JSON.stringify(steps)}`);
|
|
1154
|
+
if (readKeyPath(value, 'files[0].name') !== 'a.csv') throw new Error('files[0].name reads a.csv');
|
|
1155
|
+
if (readKeyPath(value, 'files[2].name') !== undefined) throw new Error('past the end is nothing');
|
|
1156
|
+
|
|
1157
|
+
// A plain numeric segment is a KEY, and an array has none: xs.1 is nothing where xs[1] is 'q'.
|
|
1158
|
+
if (readKeyPath(value, 'xs[1]') !== 'q' || readKeyPath(value, 'xs.1') !== undefined) throw new Error('xs.1 is a key');
|
|
1159
|
+
|
|
1160
|
+
// A malformed path is refused by the grammar — and the parse, being total, still answers.
|
|
1161
|
+
for (const bad of ['files[*].name', 'xs[01]', '[0].x', 'a..b']) {
|
|
1162
|
+
if (KEY_PATH_RE.test(bad)) throw new Error(`${bad} is not a key path`);
|
|
1163
|
+
}
|
|
1164
|
+
const first = keyPathSteps('files[*].name')[0]!;
|
|
1165
|
+
if (!('key' in first) || first.key !== 'files[*]') throw new Error('an unspelled segment is one literal key');
|
|
1166
|
+
|
|
1167
|
+
// A comparison's path on an edge: a key path whose first key is a root, with a key after it. NECESSARY, not
|
|
1168
|
+
// sufficient: FD-V43 also holds a `needs.X` path to an X the edge's own `needs` declares (since 0.13.0), which
|
|
1169
|
+
// these names cannot see — `validateDefinition` is the authority on a whole flow.
|
|
1170
|
+
const edgePath = (p: string) => KEY_PATH_RE.test(p) && (PREDICATE_ROOTS as readonly string[]).includes(p.split(/[.[]/)[0]!) && /^[A-Za-z0-9_-]+\./.test(p);
|
|
1171
|
+
if (!edgePath('outcome.result.files[0].size') || edgePath('result.size') || edgePath('outcome[0].x') || edgePath('needs')) {
|
|
1172
|
+
throw new Error('the grammar and the roots, as FD-V43 reads them on a path alone');
|
|
1173
|
+
}
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
**What is not exported, and why.** No *is this a valid comparison path* function: the grammar and the roots are
|
|
1177
|
+
these two values, and a second spelling of `FD-V43` would be a second thing to hold in step — `validateDefinition`
|
|
1178
|
+
is the authority on a whole flow. Nor the declared-comparison internals `evalLeaf` and `evaluateDeclared`: a
|
|
1179
|
+
`when` is evaluated over a `DecisionContext` by **`evaluateWhen(when, ctx, predicates)`**, which IS public from the root entry
|
|
1180
|
+
(`predicates` a `Map` of the registered names it may call), and the built-in `if` evaluates its `when` over `{ input }` inside a run.
|
|
1181
|
+
|
|
1094
1182
|
---
|
|
1095
1183
|
|
|
1096
1184
|
## 5. Fan-out, join, and which policy to pass
|
|
@@ -1225,10 +1313,12 @@ is not a supported choice. See
|
|
|
1225
1313
|
|
|
1226
1314
|
### What a join carries downstream
|
|
1227
1315
|
|
|
1228
|
-
**The arrived branches' results,
|
|
1229
|
-
with an object
|
|
1230
|
-
|
|
1231
|
-
|
|
1316
|
+
**The arrived branches' results, each under the branch's OWN key.** A satisfied join dispatches its
|
|
1317
|
+
successor with an object holding one entry per branch that **reached** it — keyed by the branch's
|
|
1318
|
+
**entry node id** for a `fanout` and by the **item index** for a `map`, one segment at every depth —
|
|
1319
|
+
whose values are what each of those branches carried in. So a fan-out's results are not thrown away,
|
|
1320
|
+
a key path reaches into any depth (`input.prep.1`), and the value is the same whatever order the
|
|
1321
|
+
branches finished in.
|
|
1232
1322
|
|
|
1233
1323
|
```ts run
|
|
1234
1324
|
import { createRuntime } from 'fyflow-orchestration/scheduler';
|
|
@@ -1250,7 +1340,7 @@ const flow: FlowDefinition = {
|
|
|
1250
1340
|
flowId: 'collect', version: 1, entry: 'split',
|
|
1251
1341
|
nodes: [
|
|
1252
1342
|
{ id: 'split', kind: 'fanout', branches: ['scoreA', 'scoreB'] },
|
|
1253
|
-
// `scoreA` is
|
|
1343
|
+
// `scoreA` is the FIRST entry and is made SLOW on purpose, so `scoreB` settles first.
|
|
1254
1344
|
{ id: 'scoreA', kind: 'task', type: 'Scorer', settings: { score: 0.9, ms: 40 }, edges: [{ to: 'collect' }] },
|
|
1255
1345
|
{ id: 'scoreB', kind: 'task', type: 'Scorer', settings: { score: 0.4 }, edges: [{ to: 'collect' }] },
|
|
1256
1346
|
{ id: 'collect', kind: 'join', of: 'split', edges: [{ to: 'publish' }] },
|
|
@@ -1269,7 +1359,7 @@ const inputs: FoldInput[] = [];
|
|
|
1269
1359
|
const state = await runtime.start(flow, {}, { onFoldInput: (input) => { inputs.push(input); } }).done;
|
|
1270
1360
|
await runtime.shutdown();
|
|
1271
1361
|
|
|
1272
|
-
// **The order is READ, not assumed.** The delay above ASKS for
|
|
1362
|
+
// **The order is READ, not assumed.** The delay above ASKS for `scoreB` to settle first; this is
|
|
1273
1363
|
// what the run actually did, and the claim below is about the collection being independent of it.
|
|
1274
1364
|
const settledOrder = inputs.flatMap((i) =>
|
|
1275
1365
|
(i.kind === 'outcome' && /:score[AB]:/.test(i.record.taskId) ? [i.record.taskId.split(':')[1]] : []));
|
|
@@ -1278,13 +1368,14 @@ console.log(settledOrder.join(' then ')); // 'scoreB then scoreA'
|
|
|
1278
1368
|
// `publish` is the join's successor, so what it received IS the collection — and `tag` returns what
|
|
1279
1369
|
// it was handed with its own label added, so the collection is readable in the result.
|
|
1280
1370
|
const published = Object.values(state.nodes).find((n) => n.nodeId === 'publish')?.result;
|
|
1281
|
-
console.log(JSON.stringify(published)); // {"
|
|
1371
|
+
console.log(JSON.stringify(published)); // {"scoreA":{"score":0.9},"scoreB":{"score":0.4},"publish":true}
|
|
1282
1372
|
|
|
1283
|
-
const want = '{"
|
|
1373
|
+
const want = '{"scoreA":{"score":0.9},"scoreB":{"score":0.4},"publish":true}';
|
|
1284
1374
|
if (JSON.stringify(published) !== want) throw new Error(`the join carried ${JSON.stringify(published)}`);
|
|
1285
|
-
// **B settled FIRST and is still under key
|
|
1286
|
-
//
|
|
1287
|
-
// order the run took, the second says the
|
|
1375
|
+
// **B settled FIRST and is still under its own key, listed SECOND.** The collection is keyed by each
|
|
1376
|
+
// branch's ENTRY ID and inserted in `branches` order, not in arrival order, which is the whole point —
|
|
1377
|
+
// and the two assertions are a pair: the first says which order the run took, the second says the
|
|
1378
|
+
// record did not follow it.
|
|
1288
1379
|
if (settledOrder.join() !== 'scoreB,scoreA') throw new Error(`settled ${settledOrder.join()} — the arrangement did not hold, so the claim below is untested`);
|
|
1289
1380
|
```
|
|
1290
1381
|
|
|
@@ -1297,20 +1388,32 @@ if (settledOrder.join() !== 'scoreB,scoreA') throw new Error(`settled ${settledO
|
|
|
1297
1388
|
2. **`{}` is a real answer, not an error.** Under `liveToken` a join fires once its region has no live
|
|
1298
1389
|
branches, and every branch reaching an `end` satisfies that with nothing arrived. If that should be
|
|
1299
1390
|
a failure instead, pass `liveTokenRequiringArrival` — see above.
|
|
1300
|
-
3. **
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
branch
|
|
1305
|
-
|
|
1306
|
-
`
|
|
1307
|
-
|
|
1391
|
+
3. **Every key is ONE segment, at every depth — the branch's own key.** A `fanout`'s branch is keyed by
|
|
1392
|
+
its entry node id (`FD-V9` keeps them distinct), a `map`'s instance by its item index — so a
|
|
1393
|
+
fan-out whose first branch holds a map gives `{"prep":{"0":1,"1":2},"side":{"xs":[1,2]}}`, never
|
|
1394
|
+
`"0.1"` under `"0"`. A nested region's inner join carries its own object, which becomes the value of
|
|
1395
|
+
the outer branch it sits in. The full branch path is not lost: it is the region's own path (`regionPath`) + `.` + the
|
|
1396
|
+
position — the position alone at the top level, where `regionPath` is `-` — on every `branch.*` event, task id and
|
|
1397
|
+
`inputOf` path, which are unchanged.
|
|
1398
|
+
4. **The order is the object's own.** Entries are inserted in `branches` order for a fan-out and in
|
|
1399
|
+
ascending item index for a map, and **JavaScript lists integer-like keys first, ascending** — so a
|
|
1400
|
+
fan-out whose entries are `"b"` and `"2"` lists `"2"` first. Deterministic whatever order the
|
|
1401
|
+
branches finished in; no node id is refused for it. Each entry is an own property, so even an entry
|
|
1402
|
+
id `__proto__` is a key.
|
|
1403
|
+
5. **BREAKING in 0.12.0, and a run in flight across the upgrade cannot be resumed.** Until 0.11.0 the
|
|
1404
|
+
keys were full branch paths (`{"0":{"0.0":1,"0.1":2},"1":{"xs":[1,2]}}` for the flow above) and a
|
|
1405
|
+
fan-out's were POSITIONS; a flow reading `"0.1"` or `"0"` reads nothing now. A collection reaches a
|
|
1406
|
+
checkpoint (an ask's kept input, a gate's decision, a worker's result), so **`CHECKPOINT_FORMAT` is
|
|
1407
|
+
2** and a checkpoint 0.11.0 wrote — join or none — is refused `ResumeRefused('format')` by
|
|
1408
|
+
`resume()`, `runtime.resume` and `inputOf`; a 0.11.0 driver refuses a 0.12.0 checkpoint the same
|
|
1409
|
+
way. There is no migration: drain or abandon those runs first
|
|
1410
|
+
([ADR 0138](doc/decisions/0138-a-join-hands-on-each-branch-under-its-own-key-and-a-checkpoint-of-the-old-keying-is-refused-by-format.md)).
|
|
1308
1411
|
|
|
1309
1412
|
*(This section read **"`null`, currently … this is [Q36](doc/questions/q36.md) and it is open"**
|
|
1310
1413
|
until [ADR 0037](doc/decisions/0037-what-a-branch-receives-is-a-property-of-the-fan-out-and-what-a-join-carries-is-a-property-of-the-region.md).
|
|
1311
1414
|
The argument for the `null` was real and is unchanged — which branch finishes last is genuinely
|
|
1312
1415
|
non-deterministic — and what answered it is that carrying **all** the arrivals keyed by branch is not
|
|
1313
|
-
picking one. `spec/join-policy.md` section 4, `JP-R12
|
|
1416
|
+
picking one. `spec/join-policy.md` section 4, `JP-R12`; its keys are each branch's own since 0.12.0.)*
|
|
1314
1417
|
|
|
1315
1418
|
### The same on the RUNTIME — a decision, a fan-out and a join on real workers
|
|
1316
1419
|
|
|
@@ -1345,7 +1448,7 @@ const isLowConfidence: Predicate = (ctx) => {
|
|
|
1345
1448
|
|
|
1346
1449
|
// THE FLOW — a DECISION, a FAN-OUT, a JOIN. `ingest` goes to `review` when the predicate holds,
|
|
1347
1450
|
// otherwise to `split`. `split` runs two branches concurrently; `collect` waits for both and hands
|
|
1348
|
-
// `publish` every branch's result,
|
|
1451
|
+
// `publish` every branch's result, each under its own entry id.
|
|
1349
1452
|
const flow: FlowDefinition = {
|
|
1350
1453
|
flowId: 'decide', version: 1, entry: 'ingest',
|
|
1351
1454
|
nodes: [
|
|
@@ -1373,13 +1476,13 @@ const ran = (s: RunState) => Object.values(s.nodes).map((n) => n.nodeId).sort();
|
|
|
1373
1476
|
const resultOf = (s: RunState, id: string) => Object.values(s.nodes).find((n) => n.nodeId === id)?.result;
|
|
1374
1477
|
console.log(ran(high)); // [ 'classify', 'enrich', 'ingest', 'publish' ]
|
|
1375
1478
|
console.log(ran(low)); // [ 'ingest', 'review' ]
|
|
1376
|
-
console.log(resultOf(high, 'publish')); // {
|
|
1479
|
+
console.log(resultOf(high, 'publish')); // { enrich: { confidence: 0.9, ingest: true, enrich: true }, classify: { …, classify: true }, publish: true }
|
|
1377
1480
|
|
|
1378
1481
|
// Verified by the docs gate:
|
|
1379
1482
|
if (high.outcome !== 'completed' || low.outcome !== 'completed') throw new Error(`outcomes ${high.outcome} / ${low.outcome}`);
|
|
1380
1483
|
if (ran(high).join() !== 'classify,enrich,ingest,publish') throw new Error(`high ran ${ran(high).join()}`);
|
|
1381
1484
|
if (ran(low).join() !== 'ingest,review') throw new Error(`low ran ${ran(low).join()}`);
|
|
1382
|
-
const want = '{"
|
|
1485
|
+
const want = '{"enrich":{"confidence":0.9,"ingest":true,"enrich":true},"classify":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
|
|
1383
1486
|
if (JSON.stringify(resultOf(high, 'publish')) !== want) throw new Error(`publish received ${JSON.stringify(resultOf(high, 'publish'))}`);
|
|
1384
1487
|
```
|
|
1385
1488
|
|
|
@@ -1630,9 +1733,10 @@ keeps waiting, and the run is not stuck.
|
|
|
1630
1733
|
the element's `observedAt` is the driver's `now` — the clock an outcome record's `observedAt` reads — and the node's
|
|
1631
1734
|
entry carries it as **`askedAt`**, kept after the answer as `input` is. **A resume KEEPS the stored time**: the replay
|
|
1632
1735
|
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
|
|
1634
|
-
|
|
1635
|
-
|
|
1736
|
+
the stored tail it is the resumed driver's `now`. *(An `asked` element written before 0.11.0 has none; since 0.12.0
|
|
1737
|
+
such a checkpoint is refused `format` before its events are read — section 5, `CHECKPOINT_FORMAT` 2.)* **A driver
|
|
1738
|
+
older than 0.11.0 refused a 0.11.0 checkpoint `diverged` at its first ask**; any driver before 0.12.0 refuses a 0.12.0
|
|
1739
|
+
checkpoint `format` before replaying anything — resume a run on a driver of the format that wrote it.
|
|
1636
1740
|
- **The answer is `driver.resolve(taskId, …)`** — the same call as below. `'done'` with `result` the answer, held to the
|
|
1637
1741
|
ask's `answerSchema` (refused as `BoundaryRefused` with NOTHING appended and the ask still parked — answer again;
|
|
1638
1742
|
`validateBoundaries: false` turns it off); `'failed'` with an `error` is the person DECLINING, held to nothing.
|
|
@@ -2882,7 +2986,9 @@ After a refusal the run is **halted**: nothing routes for it again, and its stat
|
|
|
2882
2986
|
and FOUR surfaces are renamed — the `RunOutcome` `stopped` is `halted`, `RunState.stoppedBy` is `haltedBy`, the fold
|
|
2883
2987
|
input `{ kind: 'stopped', reason }` is `{ kind: 'halted', reason }`, and `ResumeRefusalReason`'s `stopped` is
|
|
2884
2988
|
`halted` — so a comparison against an old name, or a `{ kind: 'stopped' }` you construct, no longer compiles. A 0.7.0 checkpoint or log holding a `{ kind: 'stopped' }` element still reads as `halted`:
|
|
2885
|
-
`fold` folds it so, and a resume refuses it `halted` at its position.
|
|
2989
|
+
`fold` folds it so, and a resume refuses it `halted` at its position — but a 0.7.0 checkpoint is format 1, so since
|
|
2990
|
+
0.12.0 a resume refuses it `format` first (section 5); the `halted` read is reached only by such an element under a
|
|
2991
|
+
format-2 header, which no driver writes.
|
|
2886
2992
|
|
|
2887
2993
|
---
|
|
2888
2994
|
|
|
@@ -4075,7 +4181,7 @@ driver.start({ items: [1, 2, 3] });
|
|
|
4075
4181
|
|
|
4076
4182
|
**What an expression sees, and what it may not.** `$` is the node's input — the previous node's
|
|
4077
4183
|
result, the object `needs` described, a map instance's own item, or a join's collection keyed by
|
|
4078
|
-
branch
|
|
4184
|
+
each branch's own key — and the node's effective `settings` are bound as `$settings`, so one expression is
|
|
4079
4185
|
parameterised per node. **Five names are unbound, by name, on every compiled expression**: `$now`,
|
|
4080
4186
|
`$millis`, `$random` — the three every adapter must refuse, because a transform is a function of what
|
|
4081
4187
|
the run produced and run state is a fold of the log — **`$shuffle`**, JSONata's own fourth, which
|
|
@@ -4130,10 +4236,10 @@ if (!publish('$sum([*.score])', { ...registry, languages: {} }).some((f) => f.ru
|
|
|
4130
4236
|
// The envelope worker — the class the bootstrap helper puts around your worker — with the adapter's worker half by URL.
|
|
4131
4237
|
const w = new EnvelopeWorker({ worker: echo.url, languages: { jsonata: jsonata.worker } });
|
|
4132
4238
|
await w.setup();
|
|
4133
|
-
const record = {
|
|
4239
|
+
const record = { scoreA: { score: 40 }, scoreB: { score: 20 } }; // JP-R12's collection: `split`'s two entry ids
|
|
4134
4240
|
const total = await w.run({ settings: { factor: 2 }, input: record, transform: { lang: 'jsonata', expr: '$sum([*.score]) * $settings.factor' } });
|
|
4135
4241
|
if (total !== 120) throw new Error(`the worker received ${JSON.stringify(total)} — expected the reduction, 120`);
|
|
4136
|
-
const one = await w.run({ settings: {}, input: {
|
|
4242
|
+
const one = await w.run({ settings: {}, input: { scoreA: { score: 7 } }, transform: { lang: 'jsonata', expr: '$sum([*.score])' } });
|
|
4137
4243
|
if (one !== 7) throw new Error('arity one, the same expression: 7');
|
|
4138
4244
|
const none = await w.run({ settings: {}, input: {}, transform: { lang: 'jsonata', expr: '$sum([*.score])' } });
|
|
4139
4245
|
if (none !== 0) throw new Error('a join nothing reached: 0, because the reduction is over the ARRAY form');
|
|
@@ -4571,7 +4677,7 @@ Three things follow, and they are the whole of what you need:
|
|
|
4571
4677
|
|---|---|
|
|
4572
4678
|
| **`RunState.regions` is TWO-LEVEL** | `regions[openerId][openerPath]`. The opener's id is unchanged in meaning and in value; the second key is `-` for a region in no region, and inside a `map` region it is that map's item index |
|
|
4573
4679
|
| **every `RegionEvent` carries `regionPath` beside `regionId`** | so an event says WHICH instance it is about. The same for `UnsatisfiableJoin` |
|
|
4574
|
-
| **a join's collection is per INSTANCE** |
|
|
4680
|
+
| **a join's collection is per INSTANCE** | each instance of one opener holds its OWN collection, so two instances cannot collide — even though, since 0.12.0, their keys are each branch's own key (`JP-R12`) and two instances may hold the SAME key set ([ADR 0138](doc/decisions/0138-a-join-hands-on-each-branch-under-its-own-key-and-a-checkpoint-of-the-old-keying-is-refused-by-format.md): until 0.11.0 the keys were branch paths, carrying the instance's path as a prefix) |
|
|
4575
4681
|
|
|
4576
4682
|
**Both arities come from data, at both levels, and neither is in the document.** That is what
|
|
4577
4683
|
separates a `map` from a `fanout`: `FD-V9` requires a fan-out's branch entries to be written down, so
|
|
@@ -4657,11 +4763,12 @@ if (!arrivals.includes('perLine@0 <- 0.0') || !arrivals.includes('perLine@1 <- 1
|
|
|
4657
4763
|
}
|
|
4658
4764
|
|
|
4659
4765
|
// WHAT THE OUTER JOIN CARRIES IS TWO LEVELS DEEP. `JP-R12`: one entry per branch that arrived, keyed
|
|
4660
|
-
// by
|
|
4766
|
+
// by its OWN key — an item index at both levels — and each of the outer entries IS that instance's own
|
|
4767
|
+
// collection. So `report.0.1` is order 0's second line: the key path a gate or an `if` reads.
|
|
4661
4768
|
const report = Object.values(state.nodes).find((n) => n.nodeId === 'report')?.result as Record<string, Record<string, { sku: string }>>;
|
|
4662
4769
|
console.log(Object.keys(report).filter((k) => k !== 'reported').sort()); // [ '0', '1' ]
|
|
4663
|
-
console.log(report['0']!['
|
|
4664
|
-
if (report['0']!['
|
|
4770
|
+
console.log(report['0']!['1']!.sku); // ab-1
|
|
4771
|
+
if (report['0']!['1']!.sku !== 'ab-1' || report['1']!['0']!.sku !== 'c-0') {
|
|
4665
4772
|
throw new Error(`the outer collection's values are the inner collections: ${JSON.stringify(report)}`);
|
|
4666
4773
|
}
|
|
4667
4774
|
```
|
|
@@ -4794,7 +4901,7 @@ console.log(state.outcome); // completed
|
|
|
4794
4901
|
if (state.outcome !== 'completed') throw new Error(`expected completed, got ${state.outcome}`);
|
|
4795
4902
|
// The same answer a run nobody killed gives (section 17's):
|
|
4796
4903
|
const report = Object.values(state.nodes).find((n) => n.nodeId === 'report')?.result as Record<string, Record<string, { sku: string }>>;
|
|
4797
|
-
if (report['0']!['
|
|
4904
|
+
if (report['0']!['1']!.sku !== 'ab-1' || report['1']!['0']!.sku !== 'c-0') {
|
|
4798
4905
|
throw new Error(`the resumed run reaches the same report: ${JSON.stringify(report)}`);
|
|
4799
4906
|
}
|
|
4800
4907
|
|
|
@@ -4931,11 +5038,13 @@ if (JSON.stringify(store.header) !== JSON.stringify(driver.checkpoint().header))
|
|
|
4931
5038
|
([Q374](doc/questions/q374.md)) — and so is a stored `attempted` event's number, which a re-run task is handed as
|
|
4932
5039
|
the attempts it SPENT (one naming a task the replay never dispatched, carrying no positive integer, or not RISING over
|
|
4933
5040
|
the last stored for its task, is refused `diverged` at its position). A store you do not trust is a store you should not resume from.
|
|
4934
|
-
- **`ResumeRefused` names its `reason`**: `format` (not a checkpoint this version writes
|
|
5041
|
+
- **`ResumeRefused` names its `reason`**: `format` (not a checkpoint this version writes — **every checkpoint 0.11.0
|
|
5042
|
+
or earlier wrote is format 1 and refused here**: `CHECKPOINT_FORMAT` is 2 since 0.12.0, section 5), `run` (the
|
|
4935
5043
|
header's run is not the driver's), `flow` (another flow id or `version` — there is no migration),
|
|
4936
5044
|
`halted`, `cancelled` and `diverged` — the last three with the `index` of the position. **A HALTED
|
|
4937
|
-
run is not resumable**: the refusal that halted it would halt it again — and a 0.7.0
|
|
4938
|
-
|
|
5045
|
+
run is not resumable**: the refusal that halted it would halt it again — and a halt stored under 0.7.0's kind,
|
|
5046
|
+
`stopped`, is refused `halted` at that element's position (ADR 0102; a 0.7.0 checkpoint itself is format 1, refused
|
|
5047
|
+
`format` first). **Nor is a CANCELLED one**
|
|
4939
5048
|
(section 6): a cancel is its owner's act, and a resume would hand the scheduler again what the owner
|
|
4940
5049
|
stopped — `index` is the first cancelled settlement or the cancel request, whichever is first. A completed or `stuck` run resumes to itself and
|
|
4941
5050
|
dispatches nothing. **A STOPPED run is the one terminal run a resume TAKES** (below): its `interrupted` event is replayed, and `resumed` ends it.
|
|
@@ -5161,7 +5270,7 @@ rest, while every file gets a thumbnail* — had nowhere to keep that memory.
|
|
|
5161
5270
|
|
|
5162
5271
|
| | |
|
|
5163
5272
|
|---|---|
|
|
5164
|
-
| **it names a gate TYPE** | `distinct` is built in: routes `first` (its DEFAULT), `duplicate` and `nokey`, keyed by `settings.key` — a DOTTED path of object keys over the value that reached the gate, no
|
|
5273
|
+
| **it names a gate TYPE** | `distinct` is built in: routes `first` (its DEFAULT), `duplicate` and `nokey`, keyed by `settings.key` — a DOTTED path of object keys over the value that reached the gate, each key optionally followed by bracket indexes into an ARRAY (`files[0].series`, `m[0][2]`; `[n]` with no leading zero, at most 999,999,999, never leading the path — [ADR 0137](doc/decisions/0137-a-key-path-indexes-an-array-by-a-bracket-suffix-and-a-plain-segment-keeps-its-meaning.md)); no quoted key, no wildcard, no expression. **`[1]` and `.1` are two paths**: `xs[1]` is element 1 of a real array and nothing over an object, `xs.1` an object's own key `"1"` and nothing over an array. A key a flow needs COMPUTED is computed by the task before the gate. `if` and `switch` are built in too, and remember nothing (the end of this section) |
|
|
5165
5274
|
| **every route is wired, exactly once** | an edge with no `route` carries the type's DEFAULT route, never every route; a DROP is an edge to an `end`, and each is refused at load if it is missing (`FD-V41`) |
|
|
5166
5275
|
| **it computes nothing** | the branch leaves on the chosen route with EXACTLY the value that reached the gate |
|
|
5167
5276
|
| **its memory is ONE per gate per RUN** | shared by every branch that reaches it, whatever map item or region instance it comes from — and it starts empty with the run and ends with it |
|
|
@@ -5252,12 +5361,13 @@ for (const d of firsts) {
|
|
|
5252
5361
|
}
|
|
5253
5362
|
|
|
5254
5363
|
// A DROP IS AN `end`, so it is a path end like any other — and the thumbnail ran for EVERY file: each file's
|
|
5255
|
-
// `perFile` record holds its thumbnail
|
|
5364
|
+
// `perFile` record holds its thumbnail under `beside`'s entry id `thumbnail`, and nothing from the gate road,
|
|
5365
|
+
// which ended at an `end` in every branch.
|
|
5256
5366
|
console.log(state.pathEnds.map((p) => p.at).sort());
|
|
5257
5367
|
// [ 'done', 'dropDuplicate', 'dropDuplicate', 'dropUnidentified', 'seriesReady', 'seriesReady' ]
|
|
5258
5368
|
const published = Object.values(state.nodes).find((n) => n.nodeId === 'publish')!.result as
|
|
5259
5369
|
{ joinPayload: Record<string, Record<string, { thumbnail?: string }>> };
|
|
5260
|
-
const thumbs = Object.
|
|
5370
|
+
const thumbs = Object.values(published.joinPayload).map((perFile) => perFile['thumbnail']?.thumbnail);
|
|
5261
5371
|
if (thumbs.length !== files.length || thumbs.some((t) => t === undefined)) throw new Error(`a thumbnail per file: ${thumbs.join()}`);
|
|
5262
5372
|
|
|
5263
5373
|
// A GATE TYPE OF YOUR OWN is CODE, registered by name the way `distinct` is written. A route its `fn` returns that
|
|
@@ -5359,7 +5469,7 @@ registered:
|
|
|
5359
5469
|
|---|---|---|
|
|
5360
5470
|
| **settings** | `{ when }` — a comparison or a flat `all`/`any` group, section 4's grammar, whose paths start at **`input`**: the value that reached the gate | `{ key, cases }` — `key` a key path as `distinct`'s, `cases` a map from a CASE NAME to a scalar or a non-empty array of scalars |
|
|
5361
5471
|
| **routes** | `then`, and `else` — the DEFAULT, so an edge with no `route` is the fallback | every case name, then `default` — the DEFAULT. So a case left unwired is refused at load, NAMING the case (`FD-V41`) |
|
|
5362
|
-
| **matches** | `when` holds over `{ input }`, by the evaluator a task edge's `when` uses — the same operators, absent rule, `default` and `length` | the case one of whose values is `===` the resolved key: `"1"` and `1` are two values, and a value in two cases is refused at load |
|
|
5472
|
+
| **matches** | `when` holds over `{ input }`, by the evaluator a task edge's `when` uses — the same operators, absent rule, `default` and `length`, and since 0.13.0 a `valueFrom` over `input` too (`input.a` `lt` `valueFrom: "input.b"`; `input.b` absent is no match, so `else`) | the case one of whose values is `===` the resolved key: `"1"` and `1` are two values, and a value in two cases is refused at load |
|
|
5363
5473
|
| **refused at load (`FD-V40`)** | a registered predicate NAME (a gate has no outcome to hand it), a root other than `input` — or `input` alone — and every `when` defect section 4 refuses, named | a case named `default` or outside the route charset, a value that is not a scalar, an empty array, a value in two cases or twice in one, an empty `cases` |
|
|
5364
5474
|
|
|
5365
5475
|
- **Neither reads the gate's memory**: two branches with one key take the same route at a `switch`, where
|
|
@@ -5466,7 +5576,7 @@ its three routes, so the third is the control sitting in the same report.
|
|
|
5466
5576
|
| the author writes | the check says |
|
|
5467
5577
|
|---|---|
|
|
5468
5578
|
| a key path the producer FORBIDS at any step | **`no`** on every arc leaving by a key-resolved route, naming the gate, the key, the STEP it stopped on, the word the schema itself uses, and the route |
|
|
5469
|
-
| a key path the producer describes | those arcs carry `required` along the path, so a consumer requiring it **`fits`** |
|
|
5579
|
+
| a key path the producer describes | those arcs carry `required` along the path, so a consumer requiring it **`fits`** — and through a bracket index `[n]`, `minItems: n + 1` on that array and nothing about any element past it, since `items` speaks for every element ([ADR 0137](doc/decisions/0137-a-key-path-indexes-an-array-by-a-bracket-suffix-and-a-plain-segment-keeps-its-meaning.md)) |
|
|
5470
5580
|
| the same arc, with the type declaring no guarantee | **`no`** again — *consumer requires "owner", producer does not guarantee it* |
|
|
5471
5581
|
| an arc leaving by a route the type does NOT call key-resolved | its own verdict, unchanged — the refusal is the ROUTE's and not the gate's |
|
|
5472
5582
|
|
|
@@ -5612,8 +5722,9 @@ is underneath it.**
|
|
|
5612
5722
|
library's own sub-path — `import { jsonata } from 'fyflow-orchestration/jsonata'` — so it is one
|
|
5613
5723
|
import and one object entry with no logic in it, but it is not nothing and it is not a domain
|
|
5614
5724
|
worker. **A flow that only SELECTS needs no adapter; a flow that COMPUTES does.**
|
|
5615
|
-
2. **A comparison reads ONE path and compares it to ONE literal
|
|
5616
|
-
|
|
5725
|
+
2. **A comparison reads ONE path and compares it to ONE literal — or, since 0.13.0, to ONE other path
|
|
5726
|
+
(`valueFrom`).** It cannot do arithmetic, so *is the threshold this run carries* is a comparison now
|
|
5727
|
+
(`valueFrom: "needs.cfg.threshold"`, the edge declaring `cfg`) and *is the MEAN below it* is not.
|
|
5617
5728
|
**The route around it is data**: a tier-three `Transform` node computes the answer and the `when`
|
|
5618
5729
|
reads `outcome.result.<field>`. That is what the flow below does, and it is the one place the
|
|
5619
5730
|
three-tier rule's *reshape for … a decision* is load-bearing rather than advisory — a `transform`
|
|
@@ -5623,7 +5734,8 @@ is underneath it.**
|
|
|
5623
5734
|
> **A comparison whose `value` is meant to be a path is NOT refused** — `{ "op": "eq", "value":
|
|
5624
5735
|
> "needs.cfg.threshold" }` is a perfectly good string comparison against the literal text, and it is
|
|
5625
5736
|
> false for ever. The grammar catches a wrong ROOT (`FD-V43`) and nothing catches this
|
|
5626
|
-
> ([Q456](doc/questions/q456.md)). Write the literal
|
|
5737
|
+
> ([Q456](doc/questions/q456.md)). Write the literal — or, since 0.13.0, write the path as `valueFrom`, which is
|
|
5738
|
+
> what reads it.
|
|
5627
5739
|
|
|
5628
5740
|
> **This flow is `examples/flows/ui-emitted.flow.json` and this registry is
|
|
5629
5741
|
> `examples/ui-registry.json`, with the SCHEMAS reduced to what these three verdicts turn on.** The
|
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.13.0`.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
@@ -93,7 +93,7 @@ if (JSON.stringify(shout?.result) !== '{"text":"hello world !"}') throw new Erro
|
|
|
93
93
|
|
|
94
94
|
A **decision** is an edge with a `when` naming a predicate: code you supply, under a name the
|
|
95
95
|
registry declares. A **fan-out** opens branches that run concurrently; a **join** waits for them and
|
|
96
|
-
hands the next node every branch's result,
|
|
96
|
+
hands the next node every branch's result, each under its own entry id. Below, two runs at high and low
|
|
97
97
|
confidence take different roads. The worker is
|
|
98
98
|
[examples/getting-started/tag.ts](examples/getting-started/tag.ts).
|
|
99
99
|
|
|
@@ -116,7 +116,7 @@ const isLowConfidence: Predicate = (ctx) => {
|
|
|
116
116
|
|
|
117
117
|
// THE FLOW — a DECISION, a FAN-OUT, a JOIN. `ingest` goes to `review` when the predicate holds,
|
|
118
118
|
// otherwise to `split`. `split` runs two branches concurrently; `collect` waits for both and hands
|
|
119
|
-
// `publish` every branch's result,
|
|
119
|
+
// `publish` every branch's result, each under its own entry id.
|
|
120
120
|
const flow: FlowDefinition = {
|
|
121
121
|
flowId: 'decide', version: 1, entry: 'ingest',
|
|
122
122
|
nodes: [
|
|
@@ -144,13 +144,13 @@ const ran = (s: RunState) => Object.values(s.nodes).map((n) => n.nodeId).sort();
|
|
|
144
144
|
const resultOf = (s: RunState, id: string) => Object.values(s.nodes).find((n) => n.nodeId === id)?.result;
|
|
145
145
|
console.log(ran(high)); // [ 'classify', 'enrich', 'ingest', 'publish' ]
|
|
146
146
|
console.log(ran(low)); // [ 'ingest', 'review' ]
|
|
147
|
-
console.log(resultOf(high, 'publish')); // {
|
|
147
|
+
console.log(resultOf(high, 'publish')); // { enrich: { confidence: 0.9, ingest: true, enrich: true }, classify: { …, classify: true }, publish: true }
|
|
148
148
|
|
|
149
149
|
// Verified by the docs gate:
|
|
150
150
|
if (high.outcome !== 'completed' || low.outcome !== 'completed') throw new Error(`outcomes ${high.outcome} / ${low.outcome}`);
|
|
151
151
|
if (ran(high).join() !== 'classify,enrich,ingest,publish') throw new Error(`high ran ${ran(high).join()}`);
|
|
152
152
|
if (ran(low).join() !== 'ingest,review') throw new Error(`low ran ${ran(low).join()}`);
|
|
153
|
-
const want = '{"
|
|
153
|
+
const want = '{"enrich":{"confidence":0.9,"ingest":true,"enrich":true},"classify":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
|
|
154
154
|
if (JSON.stringify(resultOf(high, 'publish')) !== want) throw new Error(`publish received ${JSON.stringify(resultOf(high, 'publish'))}`);
|
|
155
155
|
```
|
|
156
156
|
|