@kindgi/flow 0.0.0-bootstrap.0 → 0.1.1
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/LICENSE +201 -0
- package/README.md +262 -2
- package/dist/define.d.ts +36 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +29 -0
- package/dist/define.js.map +1 -0
- package/dist/errors.d.ts +252 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator.d.ts +69 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +220 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/flow.schema.json +517 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +43 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +1051 -0
- package/dist/loader.js.map +1 -0
- package/dist/scheduler.d.ts +91 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +187 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/types.d.ts +792 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +71 -0
- package/dist/types.js.map +1 -0
- package/package.json +51 -4
- package/src/define.ts +44 -0
- package/src/errors.ts +306 -0
- package/src/evaluator.ts +254 -0
- package/src/flow.schema.json +517 -0
- package/src/index.ts +85 -0
- package/src/loader.ts +1382 -0
- package/src/scheduler.ts +274 -0
- package/src/types.ts +853 -0
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { EdgeId, NodeId } from '@kindgi/types';
|
|
5
|
+
|
|
6
|
+
import { type EvalEnv, evaluateExpr } from './evaluator.js';
|
|
7
|
+
import type { Flow, FlowEdge } from './types.js';
|
|
8
|
+
import { EDGE_POLICY_PRIORITY_DEFAULT, END_NODE, START_NODE } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* State the scheduler needs from the kernel to decide what to dispatch next.
|
|
12
|
+
* Everything here is derived from journal entries — the kernel is the source
|
|
13
|
+
* of truth. The scheduler is pure and stateless.
|
|
14
|
+
*/
|
|
15
|
+
export interface SchedulerState {
|
|
16
|
+
/** Nodes whose `step.completed` has been journaled. */
|
|
17
|
+
readonly completedNodes: ReadonlySet<NodeId>;
|
|
18
|
+
/** Nodes currently dispatched (in-flight) but not yet completed. */
|
|
19
|
+
readonly inFlightNodes: ReadonlySet<NodeId>;
|
|
20
|
+
/**
|
|
21
|
+
* Per-edge evaluation outcomes recorded so far. An edge appears here iff
|
|
22
|
+
* its `edge.evaluated` journal entry has been written. Missing edges have
|
|
23
|
+
* not yet been evaluated.
|
|
24
|
+
*/
|
|
25
|
+
readonly edgeDecisions: ReadonlyMap<EdgeId, boolean>;
|
|
26
|
+
/** Evaluation environment (runInput, state, nodeOutputs) for predicate evaluation. */
|
|
27
|
+
readonly env: EvalEnv;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The scheduler's per-tick output.
|
|
32
|
+
*
|
|
33
|
+
* - `ready` → nodes to dispatch this tick (not yet started, all
|
|
34
|
+
* upstream edges resolved, at least one upstream taken).
|
|
35
|
+
* - `edgeEvals`→ edges the caller must evaluate + journal before the
|
|
36
|
+
* next tick. Contains one entry per edge whose `from` has
|
|
37
|
+
* completed (or is `$start`) but whose `edge.evaluated`
|
|
38
|
+
* hasn't been written.
|
|
39
|
+
* - `done` → true iff every node is completed or dead (can never
|
|
40
|
+
* fire), every edge whose source completed has a decision,
|
|
41
|
+
* and nothing is in flight. The kernel should write
|
|
42
|
+
* `run.completed` after this tick.
|
|
43
|
+
*/
|
|
44
|
+
export interface SchedulerTick {
|
|
45
|
+
readonly ready: readonly NodeId[];
|
|
46
|
+
readonly edgeEvals: readonly PendingEdgeEval[];
|
|
47
|
+
readonly done: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PendingEdgeEval {
|
|
51
|
+
readonly edgeId: EdgeId;
|
|
52
|
+
readonly edge: FlowEdge;
|
|
53
|
+
/**
|
|
54
|
+
* Precomputed decision if the caller wants to short-circuit and just
|
|
55
|
+
* write the journal entry. If `when` is absent, decision is always true.
|
|
56
|
+
*/
|
|
57
|
+
readonly decision: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute the next set of actions the kernel should take. Idempotent: the
|
|
62
|
+
* same state produces the same output. Safe to call from a replay path.
|
|
63
|
+
*
|
|
64
|
+
* The scheduler classifies edges into three buckets:
|
|
65
|
+
* 1. Edges from $start or from a completed node → evaluate now (if not
|
|
66
|
+
* already), then contribute to downstream readiness.
|
|
67
|
+
* 2. Edges from a not-yet-completed node → skipped this tick.
|
|
68
|
+
* 3. Edges from a node that completed and edges already evaluated → their
|
|
69
|
+
* recorded decisions drive readiness only.
|
|
70
|
+
*
|
|
71
|
+
* An incoming edge is TAKEN (source completed, evaluated true), NOT TAKEN
|
|
72
|
+
* (source completed and evaluated false, or source dead) or PENDING
|
|
73
|
+
* (source not completed yet, or not evaluated yet).
|
|
74
|
+
*
|
|
75
|
+
* A node is ready iff:
|
|
76
|
+
* - it exists in `flow.nodes`
|
|
77
|
+
* - it is not in `completedNodes` or `inFlightNodes`
|
|
78
|
+
* - none of its incoming edges is pending
|
|
79
|
+
* - at least one incoming edge is taken
|
|
80
|
+
*
|
|
81
|
+
* So a fan-in node waits for every upstream edge to be resolved before
|
|
82
|
+
* dispatching — no partial firing — and a node whose every incoming edge
|
|
83
|
+
* is not taken is dead: it never fires, and its outgoing edges count as
|
|
84
|
+
* not taken. That is what lets a conditional branch rejoin: the join node
|
|
85
|
+
* doesn't wait on the skipped arm forever.
|
|
86
|
+
*/
|
|
87
|
+
export function schedulerTick(flow: Flow, s: SchedulerState): SchedulerTick {
|
|
88
|
+
const edgesByTo = groupEdgesByDestination(flow);
|
|
89
|
+
|
|
90
|
+
const edgeEvals = computePendingEdgeEvals(flow, s);
|
|
91
|
+
const effectiveDecisions = mergeDecisions(s.edgeDecisions, edgeEvals);
|
|
92
|
+
const dead = computeDeadNodes(flow, s, edgesByTo, effectiveDecisions);
|
|
93
|
+
const ready = computeReady(flow, s, edgesByTo, effectiveDecisions, dead);
|
|
94
|
+
const done = computeDone(flow, s, effectiveDecisions, dead);
|
|
95
|
+
|
|
96
|
+
return { ready, edgeEvals, done };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Serial: `evaluateExpr` is synchronous and CPU-only. Ordering of `out`
|
|
101
|
+
* follows `flow.edges` and is stable — callers that journal edge
|
|
102
|
+
* decisions rely on that.
|
|
103
|
+
*/
|
|
104
|
+
function computePendingEdgeEvals(flow: Flow, s: SchedulerState): PendingEdgeEval[] {
|
|
105
|
+
const out: PendingEdgeEval[] = [];
|
|
106
|
+
for (const edge of flow.edges) {
|
|
107
|
+
if (s.edgeDecisions.has(edge.id)) continue;
|
|
108
|
+
if (!sourceCompleted(edge, s.completedNodes)) continue;
|
|
109
|
+
const decision = edge.when === undefined ? true : evaluateExpr(edge.when, s.env);
|
|
110
|
+
out.push({ edgeId: edge.id, edge, decision });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function mergeDecisions(
|
|
116
|
+
base: ReadonlyMap<EdgeId, boolean>,
|
|
117
|
+
additions: readonly PendingEdgeEval[],
|
|
118
|
+
): ReadonlyMap<EdgeId, boolean> {
|
|
119
|
+
const merged = new Map<EdgeId, boolean>(base);
|
|
120
|
+
for (const pe of additions) merged.set(pe.edgeId, pe.decision);
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function computeReady(
|
|
125
|
+
flow: Flow,
|
|
126
|
+
s: SchedulerState,
|
|
127
|
+
edgesByTo: ReadonlyMap<NodeId, readonly FlowEdge[]>,
|
|
128
|
+
effectiveDecisions: ReadonlyMap<EdgeId, boolean>,
|
|
129
|
+
dead: ReadonlySet<NodeId>,
|
|
130
|
+
): NodeId[] {
|
|
131
|
+
const ready: NodeId[] = [];
|
|
132
|
+
for (const node of flow.nodes) {
|
|
133
|
+
if (isNodeReady(node.id, s, edgesByTo, effectiveDecisions, dead)) ready.push(node.id);
|
|
134
|
+
}
|
|
135
|
+
// Priority-ordered dispatch. Sort DESCENDING by
|
|
136
|
+
// `EdgePolicy.priority` on the node's sole incoming edge; ties broken
|
|
137
|
+
// by ASCENDING lexical node id. Deterministic + replay-safe: the same
|
|
138
|
+
// flow + same ready set always produces the same order, so callers
|
|
139
|
+
// that slice `ready.slice(0, maxParallelism)` pick the highest-
|
|
140
|
+
// priority nodes without needing any additional coordination. Fan-in
|
|
141
|
+
// nodes and nodes whose edge has no `priority` field use the default
|
|
142
|
+
// (0), so flows without priorities sort by node id alone (the loader
|
|
143
|
+
// forbids duplicate ids across a flow, so the order is total).
|
|
144
|
+
ready.sort((a, b) => {
|
|
145
|
+
const pa = getEffectivePriority(edgesByTo, a);
|
|
146
|
+
const pb = getEffectivePriority(edgesByTo, b);
|
|
147
|
+
if (pa !== pb) return pb - pa; // higher priority first
|
|
148
|
+
return a.localeCompare(b);
|
|
149
|
+
});
|
|
150
|
+
return ready;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the effective scheduler priority for a ready node. Priority is
|
|
155
|
+
* declared on `FlowEdge.policy.priority` and applies ONLY when the
|
|
156
|
+
* destination has exactly one incoming edge — fan-in nodes (2+ incoming)
|
|
157
|
+
* inherit the default (`EDGE_POLICY_PRIORITY_DEFAULT`). Same fan-in rule
|
|
158
|
+
* as retry / timeoutMs / concurrencyKey.
|
|
159
|
+
*
|
|
160
|
+
* Exported for tests + observability. The kernel does not journal
|
|
161
|
+
* priority (it's a scheduling hint, not durable state); code that needs
|
|
162
|
+
* to inspect the effective priority of a node reads this directly.
|
|
163
|
+
*/
|
|
164
|
+
export function getEffectivePriority(
|
|
165
|
+
edgesByTo: ReadonlyMap<NodeId, readonly FlowEdge[]>,
|
|
166
|
+
nodeId: NodeId,
|
|
167
|
+
): number {
|
|
168
|
+
const incoming = edgesByTo.get(nodeId) ?? [];
|
|
169
|
+
if (incoming.length !== 1) return EDGE_POLICY_PRIORITY_DEFAULT;
|
|
170
|
+
const edge = incoming[0];
|
|
171
|
+
const p = edge?.policy?.priority;
|
|
172
|
+
return typeof p === 'number' ? p : EDGE_POLICY_PRIORITY_DEFAULT;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type EdgeState = 'taken' | 'not-taken' | 'pending';
|
|
176
|
+
|
|
177
|
+
function edgeState(
|
|
178
|
+
edge: FlowEdge,
|
|
179
|
+
completed: ReadonlySet<NodeId>,
|
|
180
|
+
effectiveDecisions: ReadonlyMap<EdgeId, boolean>,
|
|
181
|
+
dead: ReadonlySet<NodeId>,
|
|
182
|
+
): EdgeState {
|
|
183
|
+
if (edge.from !== START_NODE && dead.has(edge.from as NodeId)) return 'not-taken';
|
|
184
|
+
if (!sourceCompleted(edge, completed)) return 'pending';
|
|
185
|
+
const decision = effectiveDecisions.get(edge.id);
|
|
186
|
+
if (decision === undefined) return 'pending';
|
|
187
|
+
return decision ? 'taken' : 'not-taken';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isNodeReady(
|
|
191
|
+
nodeId: NodeId,
|
|
192
|
+
s: SchedulerState,
|
|
193
|
+
edgesByTo: ReadonlyMap<NodeId, readonly FlowEdge[]>,
|
|
194
|
+
effectiveDecisions: ReadonlyMap<EdgeId, boolean>,
|
|
195
|
+
dead: ReadonlySet<NodeId>,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (s.completedNodes.has(nodeId)) return false;
|
|
198
|
+
if (s.inFlightNodes.has(nodeId)) return false;
|
|
199
|
+
const incoming = edgesByTo.get(nodeId) ?? [];
|
|
200
|
+
if (incoming.length === 0) return false;
|
|
201
|
+
|
|
202
|
+
let anyTaken = false;
|
|
203
|
+
for (const edge of incoming) {
|
|
204
|
+
const state = edgeState(edge, s.completedNodes, effectiveDecisions, dead);
|
|
205
|
+
if (state === 'pending') return false;
|
|
206
|
+
if (state === 'taken') anyTaken = true;
|
|
207
|
+
}
|
|
208
|
+
return anyTaken;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function computeDone(
|
|
212
|
+
flow: Flow,
|
|
213
|
+
s: SchedulerState,
|
|
214
|
+
effectiveDecisions: ReadonlyMap<EdgeId, boolean>,
|
|
215
|
+
dead: ReadonlySet<NodeId>,
|
|
216
|
+
): boolean {
|
|
217
|
+
const allNodesResolved = flow.nodes.every((n) => s.completedNodes.has(n.id) || dead.has(n.id));
|
|
218
|
+
const allEdgesResolved = flow.edges.every((edge) => {
|
|
219
|
+
if (!sourceCompleted(edge, s.completedNodes)) return true;
|
|
220
|
+
return effectiveDecisions.has(edge.id);
|
|
221
|
+
});
|
|
222
|
+
return allNodesResolved && allEdgesResolved && s.inFlightNodes.size === 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Nodes that will never fire: no incoming edge at all, or every incoming
|
|
227
|
+
* edge not taken — including edges out of other dead nodes, so deadness
|
|
228
|
+
* propagates down a skipped branch. Iterates to a fixpoint (the loader
|
|
229
|
+
* guarantees the edges form a DAG, so it terminates in at most one pass
|
|
230
|
+
* per node). Unreached nodes — some incoming edge still pending — are
|
|
231
|
+
* NOT dead; they might still fire once upstream completes.
|
|
232
|
+
*/
|
|
233
|
+
function computeDeadNodes(
|
|
234
|
+
flow: Flow,
|
|
235
|
+
s: SchedulerState,
|
|
236
|
+
edgesByTo: ReadonlyMap<NodeId, readonly FlowEdge[]>,
|
|
237
|
+
effectiveDecisions: ReadonlyMap<EdgeId, boolean>,
|
|
238
|
+
): ReadonlySet<NodeId> {
|
|
239
|
+
const dead = new Set<NodeId>();
|
|
240
|
+
let changed = true;
|
|
241
|
+
while (changed) {
|
|
242
|
+
changed = false;
|
|
243
|
+
for (const node of flow.nodes) {
|
|
244
|
+
if (dead.has(node.id) || s.completedNodes.has(node.id) || s.inFlightNodes.has(node.id)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const incoming = edgesByTo.get(node.id) ?? [];
|
|
248
|
+
const allNotTaken = incoming.every(
|
|
249
|
+
(edge) => edgeState(edge, s.completedNodes, effectiveDecisions, dead) === 'not-taken',
|
|
250
|
+
);
|
|
251
|
+
if (allNotTaken) {
|
|
252
|
+
dead.add(node.id);
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return dead;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sourceCompleted(edge: FlowEdge, completed: ReadonlySet<NodeId>): boolean {
|
|
261
|
+
if (edge.from === START_NODE) return true;
|
|
262
|
+
return completed.has(edge.from as NodeId);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function groupEdgesByDestination(flow: Flow): ReadonlyMap<NodeId, readonly FlowEdge[]> {
|
|
266
|
+
const by = new Map<NodeId, FlowEdge[]>();
|
|
267
|
+
for (const edge of flow.edges) {
|
|
268
|
+
if (edge.to === END_NODE) continue;
|
|
269
|
+
const arr = by.get(edge.to as NodeId) ?? [];
|
|
270
|
+
arr.push(edge);
|
|
271
|
+
by.set(edge.to as NodeId, arr);
|
|
272
|
+
}
|
|
273
|
+
return by;
|
|
274
|
+
}
|