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 +239 -21
- package/README.md +1 -1
- package/dist/browser/chunks/{chunk-4JFQX7DL.js → chunk-5KUYTCXI.js} +177 -30
- package/dist/browser/index.js +12 -4
- package/dist/browser/scheduler/index.js +17 -6
- package/dist/node/chunks/{chunk-4JFQX7DL.js → chunk-5KUYTCXI.js} +177 -30
- package/dist/node/index.js +12 -4
- package/dist/node/scheduler/index.js +17 -6
- package/dist/types/src/definition/condition.d.ts +7 -4
- package/dist/types/src/definition/registry.d.ts +18 -10
- package/dist/types/src/definition/types.d.ts +56 -12
- package/dist/types/src/definition/validate.d.ts +3 -2
- package/dist/types/src/scheduler/runtime.d.ts +14 -10
- package/package.json +3 -3
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
|
|
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
|
|
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
|
|
3298
|
-
// `ConcurrentLimitGroup`, `RateLimitGroup
|
|
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
|
|
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
|
|
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. **
|
|
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.
|
|
3878
|
-
|
|
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
|
|
5644
|
-
|
|
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
|
|
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.
|
|
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
|
|