@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.
@@ -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
+ }