fyflow-orchestration 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -818,7 +818,8 @@ entries are held to the id charset, a non-string refused — as `validateDefinit
818
818
  tripped over it, and is refused now. **And the other way, since 0.12.0** (`spec/flow-definition.md` v0.53,
819
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
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.
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*.)*
822
823
 
823
824
  | | |
824
825
  |---|---|
@@ -894,14 +895,14 @@ a flow document can say what a branch depends on without any code at all, which
894
895
  an agent emit a complete flow. A name still works and is what a predicate that COMPUTES uses.
895
896
 
896
897
  ```ts run
897
- import { validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
898
+ import { evaluateWhen, validateDefinition, type FlowDefinition, type When } from 'fyflow-orchestration';
898
899
 
899
900
  // A `when` is EXACTLY ONE of three shapes, told apart by which keys it carries.
900
901
  const flow: FlowDefinition = {
901
902
  flowId: 'declared', version: 1, entry: 'score',
902
903
  nodes: [
903
904
  { id: 'score', kind: 'task', type: 'ScoreWorker', edges: [
904
- // 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).
905
906
  { when: { path: 'outcome.result.confidence', op: 'lt', value: 0.5 }, to: 'review' },
906
907
  // 2. a flat GROUP — `all` or `any`, whose members are comparisons.
907
908
  { when: { any: [
@@ -940,16 +941,41 @@ if (refuse({ path: 'result.confidence', op: 'lt', value: 0.5 })[0] !== 'FD-V43')
940
941
  if (refuse({ path: 'outcome.result.name', op: 'matches', value: '^a' })[0] !== 'FD-V44') throw new Error('an unknown operator is FD-V44');
941
942
  // FD-V45 — a group is FLAT, and a member naming a registered predicate could be IMPURE.
942
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');
943
962
  ```
944
963
 
945
964
  | | |
946
965
  |---|---|
947
- | `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 |
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 |
948
967
  | `op` | one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `startsWith`, and **nothing else** |
949
- | `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** |
950
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 |
951
971
  | `length` | optional `true` — compare the LENGTH of an array or a string, after `default` |
952
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
+
953
979
  **Three things to know before you write one.**
954
980
  **A comparison between kinds is no match, never a coercion** — `{ op: 'lt', value: 50 }` is false
955
981
  against the string `"9"` where JavaScript's `<` would say true.
@@ -960,6 +986,10 @@ holding `null` as well as a missing one.
960
986
  that holds over NUMBERS, and over an absent path where you declared a `default`. Over a value an
961
987
  ordering operator cannot compare, a string or a boolean, **both it and its complement are false**.
962
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.
963
993
  **A declared predicate CANNOT be impure.** There is no clock, no random source and no way to name
964
994
  anything but the context, so re-evaluating it over the same log gives the same answer. A registered
965
995
  name has no such guarantee — which is the trade, and why a regular expression, a date window or a
@@ -1097,6 +1127,58 @@ the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, se
1097
1127
  node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
1098
1128
  and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
1099
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
+
1100
1182
  ---
1101
1183
 
1102
1184
  ## 5. Fan-out, join, and which policy to pass
@@ -2605,7 +2687,7 @@ flow, and the class says which document to open.
2605
2687
  member and the group's key; `GroupNotBuildable` — a group type with no `create`; since W89, `RunIdInUse`
2606
2688
  — a `start` or `resume` naming a run id that runtime has already built a driver for, a finished run's included (`RN-R25`, ADR 0091);
2607
2689
  and, since W163, **`LimitKeyMissing`** — a `start` or `resume` given no `createTask`, of a flow a node of which reaches a
2608
- group the built-in `KeyedRateLimitGroup` factory built (`FD-R6`, [ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md), section 12), its `at` the node.
2690
+ group a built-in keyed factory built — `KeyedRateLimitGroup`, or since spec v0.56 `KeyedConcurrentLimitGroup` (`FD-R6`, [ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md), section 12), its `at` the node.
2609
2691
  *(`LanguageNotBuilt` — a flow transforming on a type no pool was built for — was another until W162 removed it
2610
2692
  with `createRuntime`'s `flows`: every typed pool carries every registered language since
2611
2693
  [ADR 0096](doc/decisions/0096-a-runtime-is-its-registry-and-start-refuses-from-runtime-check.md), section 12.)* A bare
@@ -3155,7 +3237,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
3155
3237
  |---|---|
3156
3238
  | flow-level **retry** — re-dispatching a NODE under a new attempt, possibly routing elsewhere | **task-level retry IS built** since [W58](doc/work-items/w58-what-a-node-says-about-its-own-task.md): declare `retryPolicy` on the node (section 3) and the scheduler retries inside the same task id, reported on the record's `attempts`. Re-dispatching the node is [Q16](doc/questions/q16.md)'s other half and the task id's fourth segment, and nothing mints one |
3157
3239
  | **`runtime.register()`** — adding node types, pools, groups or languages to a RUNNING runtime | **declined** — [P37](doc/plans/p37-a-runtime-is-its-registry.md) decision 1, [ADR 0096](doc/decisions/0096-a-runtime-is-its-registry-and-start-refuses-from-runtime-check.md): `fyflow-scheduler` cannot add a pool after construction. A changed registry is a NEW runtime; a run on the old one finishes there or moves by stop-and-resume ([P34](doc/plans/p34-what-cancelling-stopping-and-pausing-a-run-do.md)). **A FLOW is not a registry entry**: `runtime.check(flow)`, then `runtime.start(flow, …)`, takes any flow the registry can run, at any time (section 12) |
3158
- | **a `limitKey` from the FLOW** — a node or a document saying which key a task of a keyed group is counted under | **deferred** — [Q248](doc/questions/q248.md). Pass `start` a `createTask` that sets `limitKey` on the task it builds, or register a group type whose `create` builds a `KeyedRateLimitGroup` with a `keyFrom` (section 12). A node reaching a document's built-in keyed group with neither is refused before its run starts — `FD-R6`, `LimitKeyMissing` |
3240
+ | **a `limitKey` from the FLOW** — a node or a document saying which key a task of a keyed group is counted under | **deferred** — [Q248](doc/questions/q248.md). Pass `start` a `createTask` that sets `limitKey` on the task it builds, or register a group type whose `create` builds a `KeyedRateLimitGroup` or a `KeyedConcurrentLimitGroup` with a `keyFrom` (section 12). A node reaching a document's built-in keyed group with neither is refused before its run starts — `FD-R6`, `LimitKeyMissing` |
3159
3241
  | **a `start()` that checks a TRANSFORMING task's payload** — the transform run first and its result held to the `inputSchema` | **no** — [ADR 0101](doc/decisions/0101-a-transforming-task-is-not-held-at-start.md): `start()` is synchronous and the transform runs only in the worker, so a task declaring one is UNCHECKED at `start` (`FD-R4`), as a worker declaring no `inputSchema` is. Call `givenVerdict(registry, flow, task, received)` — `tryTransform(…, { input: received })` on the declared transform — first, on each transforming target with the value it RECEIVES (section 15), then start |
3160
3242
  | **redaction by `writeOnly`** — a schema annotation keeping a value out of what a run retains or reports | **no** — [ADR 0098](doc/decisions/0098-four-annotations-join-the-schema-subset-and-constrain-nothing.md) rule 4: `writeOnly`, like `readOnly`, `title` and `description`, is admitted as an annotation since v0.36, held to its shape and read by nothing after. Redact in your own code, before the value reaches a run (section 13) |
3161
3243
  | **pool options on a CLASS type's instance**, and the scheduler's `idleCheckIntervalMs`, `requeueFailedTasks` and `maxWorkerRestarts` anywhere | **no** — [ADR 0099](doc/decisions/0099-a-named-instance-of-a-document-type-may-declare-its-own-pool-options.md) rules 4 and 7: a named instance of a DOCUMENT type may declare `pool` (section 13); a class computes its pool from the instance's `config` and key, so give it a `configSchema` knob. The three are refused as undeclared keys |
@@ -3262,7 +3344,7 @@ if (Object.keys(runtime.groups).join() !== 'gate') throw new Error(`groups: ${Ob
3262
3344
 
3263
3345
  **A custom group type is a FACTORY, and the runtime holds its output to what the scheduler CALLS.**
3264
3346
  The document names a group by KEY with a `type`; the code supplies `{ configSchema?, create(config,
3265
- key) }` under that type name; the three built-ins are pre-registered in the same shape. What comes
3347
+ key) }` under that type name; the five built-ins are pre-registered in the same shape (section 13). What comes
3266
3348
  back is checked for ADR 0055's six members and refused by name otherwise. Read the counts off the
3267
3349
  object the runtime built — [W70](doc/work-items/w70-a-flow-that-decides-and-a-custom-group-on-the-runtime.md):
3268
3350
 
@@ -3294,8 +3376,9 @@ class CountingGroup extends EventTarget implements ResourceGroup {
3294
3376
  }
3295
3377
 
3296
3378
  // THE REGISTRY declares the group by KEY, with its TYPE by name and its config; the CODE supplies the
3297
- // factory for that type name — `{ configSchema?, create(config, key) }`. The three built-ins,
3298
- // `ConcurrentLimitGroup`, `RateLimitGroup` and `KeyedRateLimitGroup`, are pre-registered in the same
3379
+ // factory for that type name — `{ configSchema?, create(config, key) }`. The five built-ins,
3380
+ // `ConcurrentLimitGroup`, `RateLimitGroup`, `KeyedRateLimitGroup`, `UnitGroup` and
3381
+ // `KeyedConcurrentLimitGroup` (section 13), are pre-registered in the same
3299
3382
  // shape, so `"type": "ConcurrentLimitGroup"` needs no code at all. A node type names the groups its
3300
3383
  // pool's tasks acquire.
3301
3384
  const registry: RegistryDocument = {
@@ -3362,14 +3445,14 @@ so a flow published after the runtime started runs on it, transforming on any ty
3362
3445
  rules — every one but `FD-R6`, below — before any run id is spent, so the two cannot disagree: *register* a flow by checking it, then
3363
3446
  start it. `check` leaves out what depends on a RUN rather than the flow — a payload `FD-R4` refuses,
3364
3447
  and a run id in use. **One finding it returns is not a definition defect: `FD-R6`**
3365
- ([ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md)) — a node reaching a group of the BUILT-IN `KeyedRateLimitGroup` type, through the
3448
+ ([ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md)) — a node reaching a group of a BUILT-IN keyed type — `KeyedRateLimitGroup`, or (spec v0.56) `KeyedConcurrentLimitGroup` (section 13) — through the
3366
3449
  pool it runs on — the named instance its `pool` says, else its type's default — or its `workerGroups`. The runtime builds that group with no `keyFrom`, so the scheduler counts each task
3367
3450
  under its `limitKey` — and nothing sets one unless you pass `start` a `createTask` that does, `(c) => new FyflowTask({
3368
3451
  ...c, limitKey: tenant })`; without one the scheduler's `addTask` throws. So `start` with no `createTask` throws
3369
3452
  **`LimitKeyMissing`** for it — after any `InvalidDefinition`, before any run id is spent — and with one, `FD-R6` refuses
3370
3453
  nothing (a factory that sets no key meets the scheduler's own throw). `check` cannot know what you will pass, so it
3371
3454
  reports `FD-R6` as if you pass none: filter it out by `f.rule` if you start with a keying `createTask`. A group a CODE
3372
- group type builds — a `KeyedRateLimitGroup` with a `keyFrom` — is never `FD-R6`. A pool's `residentGroups` is not read
3455
+ group type builds — a keyed group with a `keyFrom` — is never `FD-R6`. A pool's `residentGroups` is not read
3373
3456
  here: a built-in keyed group held RESIDENT is refused earlier, at `createRuntime` (`DerivationRefused`, section 13,
3374
3457
  [Q619](doc/questions/q619.md)). A key derived from the flow is
3375
3458
  [Q248](doc/questions/q248.md), deferred. **And `createRuntime` refuses a malformed `defaultRetryPolicy`** —
@@ -3539,10 +3622,10 @@ anywhere else.)* This library's listener boundary is written against **0.3.0 sem
3539
3622
  `task.progress`'s detail changed shape at 0.3.0 (the id is `id`, not `taskId`), the second
3540
3623
  `task.user_action` dispatch site arrived there, and `task.timestamp` did not exist before it.
3541
3624
  **`core/FyflowScheduler.ts` was byte-identical between 0.3.0, 0.5.1 and 0.5.2**; it is not at the
3542
- current pin, and W157 re-read what this library rests on there rather than inferring it: the
3625
+ current pin, and W157 and W224 re-read what this library rests on there rather than inferring it: the
3543
3626
  `user_action` pair is unchanged on both of its paths, and `task.progress` is built from the same
3544
3627
  fields. What 0.5.1 added is a `worker.setup.*` / `worker.teardown.*` event family this library
3545
- subscribes to not at all. **The current pin adds a sixth task state, `cancelled`, reached only through
3628
+ subscribes to not at all. **A sixth task state, `cancelled`, arrived at 0.6.0 and is still the pin's (re-read by W224), reached only through
3546
3629
  `scheduler.cancel(id)`**, and a run hears it: a cancelled task ends its run `cancelled` and settles
3547
3630
  its `done` (section 6, [P33](doc/plans/p33-what-a-run-does-when-a-task-ends-cancelled.md)) — on real
3548
3631
  workers in ladder L6, a task cancelled while running and while queued — and `driver.cancel` asks for one on
@@ -3874,11 +3957,88 @@ a hand-written `NodeTypeDefinition`, or a copy of a resolved one, is a class to
3874
3957
  **A `residentGroups` naming a group of the built-in `KeyedRateLimitGroup` type is refused at `createRuntime`** (v0.39,
3875
3958
  [Q619](doc/questions/q619.md), W169) — `DerivationRefused`, naming the pool and the group, whether a type, a named
3876
3959
  instance or your class's `pool()` put it there. The scheduler resolves no key for a RESIDENT group, so every worker
3877
- holding it would be counted in ONE `""` bucket, in silence. Name a keyed group in `groups` instead — each task is then
3878
- counted under its `limitKey` (`FD-R6`, section 12) — or hold a `ConcurrentLimitGroup` or `RateLimitGroup` resident. A
3960
+ holding it would be counted in ONE `""` bucket, in silence. **A built-in `KeyedConcurrentLimitGroup` is refused the same
3961
+ way** (spec v0.56, W225) — the scheduler rejects one held resident at construction. Name a keyed group in `groups` instead — each task is then
3962
+ counted under its `limitKey` (`FD-R6`, section 12) — or hold a `ConcurrentLimitGroup` or `RateLimitGroup` resident (or a `UnitGroup`). A
3879
3963
  group your own `code.groupTypes` factory builds is never refused. **BREAKING in 0.8.0**: such a document built a
3880
- runtime at 0.7.0. **`bootstrap` called by hand is not covered** — it is handed group OBJECTS and cannot tell the
3964
+ runtime at orchestration 0.7.0. **`bootstrap` called by hand is not covered** — it is handed group OBJECTS and cannot tell the
3881
3965
  built-in factory's keyed group from your own — as a `Driver` built by hand is not covered by `FD-R6`.
3966
+
3967
+ **Units with names, a limit per key, and a resident REQUEST — as data** — `spec/flow-definition.md` v0.56,
3968
+ [ADR 0143](doc/decisions/0143-unit-and-keyed-concurrent-groups-are-built-ins-and-a-resident-request-is-checked-against-the-group.md),
3969
+ [W225](doc/work-items/w225-unit-and-keyed-concurrent-groups-and-a-resident-request.md). Two more built-in group types, beside
3970
+ `ConcurrentLimitGroup`, `RateLimitGroup` and `KeyedRateLimitGroup`, each the scheduler's own class:
3971
+
3972
+ | `type` | `config` | built as |
3973
+ |---|---|---|
3974
+ | `UnitGroup` | `{ "units": 2 }` (units `0` and `1`) or `{ "units": ["gpu0", "gpu1"] }` — at least one, the names DISTINCT | `new UnitGroup(units, { id: key })` — a hold is granted named units; no `requestFrom` |
3975
+ | `KeyedConcurrentLimitGroup` | `{ "limit": 4, "limits": { "E:": 1 } }` — every per-key limit a positive integer | `new KeyedConcurrentLimitGroup(limit, { id: key, limitFor })`, `limitFor(k)` reading a COPY of `limits`, else `limit`; no `keyFrom` |
3976
+
3977
+ A repeated unit, or a per-key limit that is not a positive integer, is `GroupConfigRefused` naming the group. **A
3978
+ `KeyedConcurrentLimitGroup` is held to `KeyedRateLimitGroup`'s rules**: a node reaching one is `FD-R6` (section 12), and one
3979
+ held resident is refused, as above. **A `residentGroups` value may be a REQUEST** — `{ "gpu": { "cost": 1, "from":
3980
+ ["gpu1"] } }`, `{ cost?, from?, prefer? }`: `from` the units a worker of the pool may hold, `prefer` a hint that never
3981
+ blocks, each a non-empty list of distinct ids (an integer for a `UnitGroup` declared by count). **A resident cost is a
3982
+ positive INTEGER** — a fraction, which the scheduler throws on, was accepted until spec v0.56. Every cost and request is
3983
+ held against the group it names when the runtime is built, `DerivationRefused` naming the pool and the group, each a pool
3984
+ the scheduler would throw on at construction: an id a `UnitGroup` lacks, a cost above `from`'s size or above a group's
3985
+ limit (a `RateLimitGroup`'s smallest window), and `from` / `prefer` on a `ConcurrentLimitGroup` or `RateLimitGroup`, which
3986
+ take no request — `{ cost }` alone is a cost for any group. A request to a group your `code.groupTypes` factory builds is
3987
+ handed on untouched WITHIN the request shape `{ cost?, from?, prefer? }`, for its own `checkRequest` — a request
3988
+ language beyond that shape is not declarable in a document. A TASK's request is its `units`, set by your `createTask`; nothing in a
3989
+ flow declares one.
3990
+
3991
+ ```ts run
3992
+ import { createRuntime } from 'fyflow-orchestration/scheduler';
3993
+ import { DerivationRefused, GroupConfigRefused, type FlowDefinition, type RegistryDocument } from 'fyflow-orchestration';
3994
+ import { KeyedConcurrentLimitGroup, UnitGroup } from 'fyflow-scheduler';
3995
+
3996
+ // Two cards, a worker per card, and this pool pinned to the second; a per-disk limit with a slow USB disk.
3997
+ const registry = (resident: unknown, units: unknown = ['gpu0', 'gpu1']): RegistryDocument => ({
3998
+ workers: { ingest: { url: '../../examples/workers/ingest.ts' } },
3999
+ groups: {
4000
+ gpu: { type: 'UnitGroup', config: { units } },
4001
+ disk: { type: 'KeyedConcurrentLimitGroup', config: { limit: 4, limits: { 'E:': 1 } } },
4002
+ },
4003
+ nodeTypes: {
4004
+ Model: { worker: 'ingest', inline: true, pool: { idleTimeout: 1000, residentGroups: resident as never } },
4005
+ Copy: { worker: 'ingest', inline: true, groups: ['disk'] },
4006
+ },
4007
+ });
4008
+
4009
+ const runtime = createRuntime(registry({ gpu: { cost: 1, from: ['gpu1'] } }), {}, { base: import.meta.url });
4010
+ const gpu = runtime.groups['gpu'] as unknown as UnitGroup;
4011
+ const disk = runtime.groups['disk'] as unknown as KeyedConcurrentLimitGroup;
4012
+ if (!(gpu instanceof UnitGroup) || gpu.getFreeUnits().join() !== 'gpu0,gpu1') throw new Error('the scheduler`s own UnitGroup, both cards free');
4013
+ if (!(disk instanceof KeyedConcurrentLimitGroup) || disk.getKeyMetrics('E:').limit !== 1 || disk.getKeyMetrics('C:').limit !== 4) {
4014
+ throw new Error('the per-key limits: E: 1, any other disk 4');
4015
+ }
4016
+ // The pool is handed the request as declared — the scheduler grants this pool's workers gpu1 and nothing else.
4017
+ if (JSON.stringify(runtime.pools['Model']!.spec.residentGroups) !== '{"gpu":{"cost":1,"from":["gpu1"]}}') throw new Error('the request');
4018
+
4019
+ // A node reaching the keyed group needs a `limitKey`, which only your createTask sets: FD-R6, as for KeyedRateLimitGroup.
4020
+ const copy: FlowDefinition = {
4021
+ flowId: 'copy', version: 1, entry: 'c',
4022
+ nodes: [{ id: 'c', kind: 'task', type: 'Copy', edges: [{ to: 'done' }] }, { id: 'done', kind: 'end', outcome: 'completed' }],
4023
+ };
4024
+ if (runtime.check(copy).map((f) => f.rule).join() !== 'FD-R6') throw new Error('FD-R6 at the node reaching `disk`');
4025
+ await runtime.shutdown();
4026
+
4027
+ // What the scheduler would throw on at construction is refused here, naming the pool and the group.
4028
+ const refusal = (doc: RegistryDocument): Error => {
4029
+ try { createRuntime(doc, {}, { base: import.meta.url }); } catch (e) { return e as Error; }
4030
+ return new Error('expected a refusal'); // not a refusal class, so every check below throws
4031
+ };
4032
+ const missing = refusal(registry({ gpu: { from: ['gpu7'] } }));
4033
+ if (!(missing instanceof DerivationRefused) || !missing.message.includes('node type "Model"') || !missing.message.includes('unit "gpu7"')) throw new Error(missing.message);
4034
+ const tooMany = refusal(registry({ gpu: { cost: 2, from: ['gpu1'] } }));
4035
+ if (!(tooMany instanceof DerivationRefused) || !tooMany.message.includes('a cost of 2 from 1')) throw new Error(tooMany.message);
4036
+ const keyedResident = refusal(registry(['disk']));
4037
+ if (!(keyedResident instanceof DerivationRefused) || !keyedResident.message.includes('KeyedConcurrentLimitGroup')) throw new Error(keyedResident.message);
4038
+ const twice = refusal(registry(['gpu'], ['gpu0', 'gpu0']));
4039
+ if (!(twice instanceof GroupConfigRefused) || twice.at !== 'gpu') throw new Error(twice.message);
4040
+ ```
4041
+
3882
4042
  **Your worker's `run(payload, context)` gets the scheduler's own task context as its second argument** —
3883
4043
  the same object the scheduler handed the envelope worker, so `context.signal` (an `AbortSignal`, aborted
3884
4044
  when `scheduler.cancel(taskId)` is called for the task) and `context.emit(name, data)` (a `task.message`
@@ -3888,6 +4048,62 @@ pass `onTaskMessage` to `start` (since 0.8.0, section 8) and each message of the
3888
4048
  its run, node, branch and attempts as fields — or listen for it on `runtime.scheduler` yourself, as before; what
3889
4049
  cancelling a task does to its RUN is section 6's.
3890
4050
 
4051
+ **Which UNITS your worker holds reaches it in both places the scheduler puts it** — P46,
4052
+ [W226](doc/work-items/w226-grants-reach-a-types-worker-the-reply-and-p46s-close.md), held on real workers by ladder L6's
4053
+ *W226 exit* block, threaded and inline, in Node 24 and Deno. The constructor's second argument is the scheduler's own
4054
+ **worker context**, so **`workerContext.grants`** is what the worker's `residentGroups` granted it — a `UnitGroup`'s
4055
+ units as an array, `{ gpu: ['gpu1'] }` — set BEFORE the constructor runs, so `setup()` can load onto that card; keep the
4056
+ argument (a class extending the scheduler's `BaseWorker` passes it to `super` and reads `this.workerContext`). `run`'s
4057
+ second argument is the task's **`context`**, and **`context.grants`** is that attempt's: the task's own units plus its
4058
+ worker's resident ones — in the scheduler's words, *"its own winning on a clash"*, so a task holding the group its worker
4059
+ holds resident reads its own unit there — per attempt (a retry may get another unit, so read it every time), *"frozen one
4060
+ level down - a copy, on inline pools too"*, and `{}` when nothing was granted — a `ConcurrentLimitGroup` grants nothing. **A task's request is `units` on the task, and `units` set by your
4061
+ `createTask` reaches the scheduler as `limitKey` does**: `(c) => new FyflowTask({ ...c, units: { gpu: { from: ['gpu1']
4062
+ } } })`. A named instance's request reaches the scheduler the same way (section 13's `residentGroups` above); what `from`
4063
+ and `prefer` each mean is the scheduler's own.
4064
+
4065
+ ```ts run
4066
+ import { createRuntime, type StartOptions } from 'fyflow-orchestration/scheduler';
4067
+ import type { FlowDefinition, RegistryDocument } from 'fyflow-orchestration';
4068
+ import { FyflowTask } from 'fyflow-scheduler';
4069
+
4070
+ // A type's worker reading both: the card its worker holds (read in the constructor) and what its task was granted.
4071
+ const worker = `data:text/javascript,${encodeURIComponent(`export default class {
4072
+ constructor(config, workerContext) { this.card = workerContext.grants.gpu?.[0] ?? null; }
4073
+ async setup() {}
4074
+ run(p, context) { return { card: this.card, task: context.grants }; } }`)}`;
4075
+ const registry: RegistryDocument = {
4076
+ workers: { model: { url: worker } },
4077
+ groups: { gpu: { type: 'UnitGroup', config: { units: ['gpu0', 'gpu1'] } } },
4078
+ nodeTypes: {
4079
+ Model: { worker: 'model', inline: true, pool: { residentGroups: { gpu: 1 } } }, // a card per WORKER
4080
+ Batch: { worker: 'model', inline: true, groups: ['gpu'] }, // a card per TASK
4081
+ },
4082
+ pools: { 'model-1': { type: 'Model', pool: { residentGroups: { gpu: { cost: 1, from: ['gpu1'] } } } } },
4083
+ };
4084
+ const seen = async (type: string, pool?: string, options: StartOptions = {}): Promise<string> => {
4085
+ const flow: FlowDefinition = { flowId: type, version: 1, entry: 't', nodes: [
4086
+ { id: 't', kind: 'task', type, ...(pool === undefined ? {} : { pool }), edges: [{ to: 'done' }] },
4087
+ { id: 'done', kind: 'end', outcome: 'completed' }] };
4088
+ const runtime = createRuntime(registry, {}, { base: import.meta.url }); // its own gpu, both cards free
4089
+ try {
4090
+ const run = runtime.start(flow, {}, options);
4091
+ if ((await run.done).outcome !== 'completed') return `${type} did not complete`; // a sentinel no check below accepts
4092
+ const results = run.driver.foldInputs().flatMap((i) => (i.kind === 'outcome' && i.record.kind === 'outcome' ? [i.record.result] : []));
4093
+ return JSON.stringify(results);
4094
+ } finally {
4095
+ await runtime.shutdown();
4096
+ }
4097
+ };
4098
+ // Resident: the worker holds a card from its constructor on, and its task sees it; the named instance's request reaches it.
4099
+ if (await seen('Model') !== '[{"card":"gpu0","task":{"gpu":["gpu0"]}}]') throw new Error('the lowest free card');
4100
+ if (await seen('Model', 'model-1') !== '[{"card":"gpu1","task":{"gpu":["gpu1"]}}]') throw new Error('the named instance`s request');
4101
+ // Per task: the worker holds nothing, and `units` set by createTask picks the card.
4102
+ const pinned: StartOptions = { createTask: (c) => new FyflowTask({ ...c, units: { gpu: { from: ['gpu1'] } } }) };
4103
+ if (await seen('Batch', undefined, pinned) !== '[{"card":null,"task":{"gpu":["gpu1"]}}]') throw new Error('units through createTask');
4104
+ if (await seen('Batch') !== '[{"card":null,"task":{"gpu":["gpu0"]}}]') throw new Error('no request, the lowest free card');
4105
+ ```
4106
+
3891
4107
  **A transform — what a node may DO to its input before its worker sees it** — `spec/flow-definition.md`
3892
4108
  v0.18, [ADR 0051](doc/decisions/0051-a-transform-is-a-declared-expression-in-a-registered-language-applied-in-the-types-worker-wrapper.md),
3893
4109
  [W61](doc/work-items/w61-what-a-transform-is.md). When the value a typed node receives is not the
@@ -5387,7 +5603,7 @@ registered:
5387
5603
  |---|---|---|
5388
5604
  | **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 |
5389
5605
  | **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`) |
5390
- | **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 |
5606
+ | **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 |
5391
5607
  | **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` |
5392
5608
 
5393
5609
  - **Neither reads the gate's memory**: two branches with one key take the same route at a `switch`, where
@@ -5640,8 +5856,9 @@ is underneath it.**
5640
5856
  library's own sub-path — `import { jsonata } from 'fyflow-orchestration/jsonata'` — so it is one
5641
5857
  import and one object entry with no logic in it, but it is not nothing and it is not a domain
5642
5858
  worker. **A flow that only SELECTS needs no adapter; a flow that COMPUTES does.**
5643
- 2. **A comparison reads ONE path and compares it to ONE literal.** It cannot name a second path and
5644
- it cannot do arithmetic, so *is the mean below the threshold this run carries* is not a comparison.
5859
+ 2. **A comparison reads ONE path and compares it to ONE literal — or, since 0.13.0, to ONE other path
5860
+ (`valueFrom`).** It cannot do arithmetic, so *is the threshold this run carries* is a comparison now
5861
+ (`valueFrom: "needs.cfg.threshold"`, the edge declaring `cfg`) and *is the MEAN below it* is not.
5645
5862
  **The route around it is data**: a tier-three `Transform` node computes the answer and the `when`
5646
5863
  reads `outcome.result.<field>`. That is what the flow below does, and it is the one place the
5647
5864
  three-tier rule's *reshape for … a decision* is load-bearing rather than advisory — a `transform`
@@ -5651,7 +5868,8 @@ is underneath it.**
5651
5868
  > **A comparison whose `value` is meant to be a path is NOT refused** — `{ "op": "eq", "value":
5652
5869
  > "needs.cfg.threshold" }` is a perfectly good string comparison against the literal text, and it is
5653
5870
  > false for ever. The grammar catches a wrong ROOT (`FD-V43`) and nothing catches this
5654
- > ([Q456](doc/questions/q456.md)). Write the literal, or compute the comparison in a `Transform` node.
5871
+ > ([Q456](doc/questions/q456.md)). Write the literal — or, since 0.13.0, write the path as `valueFrom`, which is
5872
+ > what reads it.
5655
5873
 
5656
5874
  > **This flow is `examples/flows/ui-emitted.flow.json` and this registry is
5657
5875
  > `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.12.0`.
20
+ Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.14.0`.
21
21
 
22
22
  ## Install
23
23