@kindgi/flow 0.0.0-bootstrap.0 → 0.1.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/src/types.ts ADDED
@@ -0,0 +1,853 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { EdgeId, FlowId, NodeId } from '@kindgi/types';
5
+
6
+ /**
7
+ * Path roots addressable in edge predicates.
8
+ *
9
+ * Every path in an operand starts with one of these roots. The kernel resolves
10
+ * each root against the current run state at the moment an edge is evaluated:
11
+ *
12
+ * - `runInput` → the input value passed to `runGraph()`.
13
+ * - `nodeOutputs.<id>` → the resolved output of a previously-completed node,
14
+ * keyed by its declared `NodeId`. Immutable — one
15
+ * writer per node, no fan-in contention.
16
+ * - `state` → the run's cumulative state channel. Last-writer-wins
17
+ * under concurrent writes; use `nodeOutputs.<id>` for
18
+ * conflict-free per-node results.
19
+ * - `iterationIndex` → 0-based iteration counter. Only meaningful inside a
20
+ * loop node's `exitCondition`. Resolves to `undefined`
21
+ * in any other context.
22
+ * - `iterationOutput` → the `$loop-end` output of the just-completed
23
+ * iteration. Only meaningful inside a loop node's
24
+ * `exitCondition`. Resolves to `undefined` elsewhere.
25
+ */
26
+ export type PathRoot =
27
+ | 'runInput'
28
+ | 'state'
29
+ | `nodeOutputs.${string}`
30
+ | 'iterationIndex'
31
+ | 'iterationOutput';
32
+
33
+ /**
34
+ * A path into the run's addressable state. Dot-separated segments, always
35
+ * starting with a recognized root. Path syntax is deliberately narrow — no
36
+ * wildcards, no array indexing, no filters. If a predicate needs more, the
37
+ * caller should compute a normalized field into `state` from a prior node
38
+ * and reference that.
39
+ *
40
+ * @example
41
+ * 'runInput.userId'
42
+ * 'nodeOutputs.classify.category'
43
+ * 'state.retryCount'
44
+ * 'iterationOutput.finishReason' // only inside a loop exitCondition
45
+ * 'iterationIndex' // only inside a loop exitCondition
46
+ */
47
+ export type Path = string;
48
+
49
+ /** Literal operand — a JSON-serializable value compared against a path. */
50
+ export interface LiteralOperand {
51
+ readonly literal: unknown;
52
+ }
53
+
54
+ /** Path operand — resolves at eval time against the current run's environment. */
55
+ export interface PathOperand {
56
+ readonly path: Path;
57
+ }
58
+
59
+ /**
60
+ * Operand = one side of a comparison. Every operand is either a static literal
61
+ * or a path reference. Keeping them symmetric means predicates naturally
62
+ * express path-vs-path comparisons (`state.attempts` vs `state.maxAttempts`)
63
+ * without a second operator flavor.
64
+ */
65
+ export type Operand = LiteralOperand | PathOperand;
66
+
67
+ /**
68
+ * A declarative object (schema-version 1.8.0+): each key maps to an
69
+ * Operand — a `{ literal }`, or a `{ path }` rooted at `runInput`,
70
+ * `state` or `nodeOutputs.<nodeId>`. Resolved against the run
71
+ * environment with `resolveMapping`; a path that doesn't resolve omits
72
+ * its key, so a consumer can tell "not provided" from any real value.
73
+ *
74
+ * Used for a leaf node's `inputMapping`, a subgraph node's
75
+ * `inputMapping` and a flow's `output`.
76
+ */
77
+ export type Mapping = Readonly<Record<string, Operand>>;
78
+
79
+ /**
80
+ * Predicate expression. Serializable JSON. Evaluated to a boolean at edge
81
+ * dispatch time; an edge fires iff its `when` evaluates true (or is absent).
82
+ *
83
+ * Design constraint: closed operator set + JSON-serializable operand shape.
84
+ * This is deliberately narrower than a JS closure so flows can round-trip
85
+ * through storage, get rendered in a visual editor, and stay reproducible
86
+ * across process restarts.
87
+ */
88
+ export type Expr =
89
+ | {
90
+ readonly op: 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte';
91
+ readonly left: Operand;
92
+ readonly right: Operand;
93
+ }
94
+ | { readonly op: 'in' | 'notIn'; readonly value: Operand; readonly set: Operand }
95
+ | { readonly op: 'exists' | 'notExists' | 'truthy' | 'falsy'; readonly value: Operand }
96
+ | { readonly op: 'and' | 'or'; readonly children: readonly Expr[] }
97
+ | { readonly op: 'not'; readonly child: Expr };
98
+
99
+ /** The set of operator names recognized by the DSL, exported for tooling. */
100
+ export const OPERATORS = [
101
+ 'eq',
102
+ 'ne',
103
+ 'lt',
104
+ 'lte',
105
+ 'gt',
106
+ 'gte',
107
+ 'in',
108
+ 'notIn',
109
+ 'exists',
110
+ 'notExists',
111
+ 'truthy',
112
+ 'falsy',
113
+ 'and',
114
+ 'or',
115
+ 'not',
116
+ ] as const;
117
+
118
+ export type OperatorName = (typeof OPERATORS)[number];
119
+
120
+ /**
121
+ * A leaf node — the classic "one handler" primitive. `kind` distinguishes what
122
+ * this node executes:
123
+ * - `tool` → an in-process or MCP tool call.
124
+ * - `agent` → an agent invocation.
125
+ *
126
+ * Invoking another flow is not a leaf kind: it is the `SubflowNode`
127
+ * variant (`kind: 'subgraph'`, schema-version 1.7.0+) with `flowRef` /
128
+ * `inputMapping` / `outputSchema` / `convergence`.
129
+ *
130
+ * The flow package holds no handler bindings; the runtime looks `ref` up
131
+ * in its `HandlerRegistry` (`@kindgi/handler`).
132
+ */
133
+ export interface LeafNode {
134
+ /** Node id (unique within the containing flow). */
135
+ readonly id: NodeId;
136
+ /**
137
+ * Which handler kind runs at this node. `'tool'` dispatches
138
+ * `ref` as a `ToolId`; `'agent'` dispatches `ref` as an `AgentId`.
139
+ */
140
+ readonly kind: 'tool' | 'agent';
141
+ /**
142
+ * Reference to the concrete implementation. Interpretation depends on
143
+ * `kind` and is opaque to the flow package.
144
+ */
145
+ readonly ref: string;
146
+ readonly config?: Readonly<Record<string, unknown>>;
147
+ /**
148
+ * What this node's handler receives (schema-version 1.8.0+): each key
149
+ * resolved when the node dispatches, so a node can combine the run
150
+ * input, state and the outputs of any earlier nodes. Absent → the
151
+ * output of the node's single upstream node, or the run input for a
152
+ * `$start` edge. The loader rejects paths that aren't rooted at
153
+ * `runInput` / `state` / `nodeOutputs.<existing node>`.
154
+ */
155
+ readonly inputMapping?: Mapping;
156
+ }
157
+
158
+ /**
159
+ * JSON Schema (draft 2020-12) describing the shape a loop's iteration
160
+ * outputs must conform to. Stored as an opaque object at the flow layer;
161
+ * the kernel validates each iteration's `$loop-end` output against this
162
+ * schema at iteration boundary. Malformed schema surfaces at flow load
163
+ * time via `LoopOutputSchemaError`.
164
+ *
165
+ * Kept as `object` here (not a specific TypeScript type) because JSON
166
+ * Schema's full expressiveness doesn't map cleanly onto a static type.
167
+ * The loader validates that the schema conforms to the draft-2020-12
168
+ * meta-schema; the kernel treats it as opaque input to Ajv.
169
+ */
170
+ export type LoopOutputSchema = Readonly<Record<string, unknown>>;
171
+
172
+ /**
173
+ * Fields shared by every loop variant. See the concrete variants
174
+ * (`WhileLoopNode`, `ForeachLoopNode`) for variant-specific fields.
175
+ *
176
+ * Every iteration produces distinct journal entries (`iteration.started`,
177
+ * `iteration.completed`); body-node journal entries carry a `loopContext`
178
+ * field attributing them to the enclosing iteration. Iterations run one
179
+ * at a time, except in a foreach loop that sets `concurrency` (see
180
+ * `ForeachLoopNode`).
181
+ *
182
+ * Node ids in the body must be globally unique across the entire flow
183
+ * (including sibling loop bodies at any nesting level). This is enforced
184
+ * by the loader.
185
+ *
186
+ * State inside a loop is always loop-internal: body-handler `stateDelta`
187
+ * writes do NOT leak to outer flow state after loop completion. The
188
+ * escape contract is `outputSchema` — required on every loop, no
189
+ * exceptions. There is no `stateScope` option.
190
+ */
191
+ interface LoopNodeCommon {
192
+ /** Node id (unique across the ENTIRE flow including nested loop bodies). */
193
+ readonly id: NodeId;
194
+ /** Discriminant — always `'loop'` for this node kind. */
195
+ readonly kind: 'loop';
196
+ /** The body sub-flow iterated per loop pass. */
197
+ readonly body: LoopBody;
198
+ /**
199
+ * Hard cap on iteration count. Required. Bounded to `1 ≤ maxIterations ≤ 10000`
200
+ * by the loader. When the cap is reached, the loop exits with
201
+ * `stopReason: 'max-iterations'`.
202
+ */
203
+ readonly maxIterations: number;
204
+ /**
205
+ * REQUIRED. JSON Schema (draft 2020-12) describing the shape every
206
+ * iteration's `$loop-end` output must satisfy. Also constrains
207
+ * `finalOutput` and (when `collectAllIterations`) each element of
208
+ * `outputs[]`. Every loop declares a typed contract — no exceptions.
209
+ */
210
+ readonly outputSchema: LoopOutputSchema;
211
+ /**
212
+ * When true, the loop node's output includes an `outputs` array with
213
+ * every iteration's `$loop-end` output. When false (default), only the
214
+ * last iteration's output is exposed via `finalOutput`; callers wanting
215
+ * history read the journal.
216
+ */
217
+ readonly collectAllIterations?: boolean;
218
+ }
219
+
220
+ /**
221
+ * A `while`-style loop node: iteration is condition-driven. Runs the
222
+ * body until `exitCondition` evaluates true (or `maxIterations` fires).
223
+ *
224
+ * `evaluationTiming` selects do-while (default) vs while semantics:
225
+ * - `'after'` → body runs first, then exit condition is checked
226
+ * against the just-completed iteration output. Loop
227
+ * always runs at least once. This is `do { body } while (!exit)`.
228
+ * - `'before'` → exit condition is checked first, against the previous
229
+ * iteration's output (or `null` on iteration 0). Body
230
+ * may run zero times. This is `while (!exit) { body }`.
231
+ *
232
+ * Exit condition path roots: `state`, `iterationIndex`, `iterationOutput`,
233
+ * and `nodeOutputs.<bodyNodeId>` (of the just-completed iteration; not
234
+ * available on `before`-timed iteration 0).
235
+ */
236
+ export interface WhileLoopNode extends LoopNodeCommon {
237
+ readonly loopKind: 'while';
238
+ readonly exitCondition: Expr;
239
+ /**
240
+ * When the exit condition is evaluated relative to the body. Default `'after'`.
241
+ */
242
+ readonly evaluationTiming?: 'before' | 'after';
243
+ }
244
+
245
+ /**
246
+ * A `foreach`-style loop node: iteration is array-driven. Runs the body
247
+ * once per element of `iterateOver` (up to `maxIterations`).
248
+ *
249
+ * `iterateOver` is resolved ONCE at loop-enter time (not re-resolved per
250
+ * iteration) — the array is pinned for the duration of the loop. If it
251
+ * resolves to a non-array, the loop fails immediately with
252
+ * `stopReason: 'iterate-over-not-array'`.
253
+ *
254
+ * Each iteration's `$loop-start` input is the corresponding array element.
255
+ * The loop exits when the array is exhausted (`stopReason: 'array-exhausted'`)
256
+ * or `maxIterations` fires. No exit condition — attempting to declare one
257
+ * is a load-time validation error.
258
+ *
259
+ * `state.$loop.currentElement` inside a foreach body handler is the
260
+ * element fed to the current iteration. `iterationIndex` is the 0-based
261
+ * position in the source array.
262
+ */
263
+ export interface ForeachLoopNode extends LoopNodeCommon {
264
+ readonly loopKind: 'foreach';
265
+ /**
266
+ * Operand that must resolve to an array at loop-enter time. Typical
267
+ * shape: `{ path: 'runInput.items' }` or `{ path: 'nodeOutputs.list.records' }`.
268
+ */
269
+ readonly iterateOver: Operand;
270
+ /**
271
+ * Parallel-worker count for iteration dispatch. Default `1` (strictly
272
+ * sequential). When set to N > 1, the
273
+ * kernel spins up N workers that pull iteration indices from a shared
274
+ * counter — iteration `i` is assigned in array order (0, 1, 2, …) but
275
+ * may settle in wall-clock order.
276
+ *
277
+ * Bounded by the loader to `1 ≤ concurrency ≤ min(maxIterations, 32)`.
278
+ * The upper cap of 32 is a fixed safety limit.
279
+ *
280
+ * Canonical guarantees under `concurrency > 1`:
281
+ * - `outputs[]` (when `collectAllIterations` is true) is sorted by
282
+ * iteration index, NOT settle order.
283
+ * - Journal `iteration.*` entries reflect wall-clock settle order.
284
+ * Consumers sort by `iteration` for canonical projection.
285
+ * - Handler failure in one iteration cancels all in-flight siblings
286
+ * cleanly (each journals `iteration.completed` with
287
+ * `stopReason: 'cancelled'`); the failed iteration journals
288
+ * `stopReason: 'body-failure'` and the loop node fails.
289
+ *
290
+ * State inside a loop body is per-iteration already (by design),
291
+ * so parallel iterations cannot race on shared body state.
292
+ */
293
+ readonly concurrency?: number;
294
+ }
295
+
296
+ /**
297
+ * A loop node — first-class iteration primitive (schema-version 1.2.0+).
298
+ * Discriminated by `loopKind` into `while` and `foreach` variants.
299
+ */
300
+ export type LoopNode = WhileLoopNode | ForeachLoopNode;
301
+
302
+ /**
303
+ * Convergence mode for a `fanout` node — declared at flow-authoring time,
304
+ * decidable at flow-load time (so downstream consumers know exactly what
305
+ * shape they'll receive).
306
+ *
307
+ * - `'all-succeed'` — fanout fails on the FIRST branch failure. In-flight
308
+ * sibling branches are cancelled through the standard kernel abort
309
+ * path. Output emitted only when EVERY branch succeeds; shape is
310
+ * `{ [branchId: string]: output }`.
311
+ * - `'any-succeed'` — fanout succeeds on the FIRST branch success. In-flight
312
+ * sibling branches are cancelled through the standard kernel abort path.
313
+ * Output shape is `{ winnerBranchId, output }`. Fails only when every
314
+ * branch fails.
315
+ * - `'settle-all'` — fanout waits for every branch to reach a terminal
316
+ * state (success or fail); no sibling cancellation on failure. Output
317
+ * shape is `{ [branchId: string]: { status: 'succeeded' | 'failed',
318
+ * output? | error? } }`. This is the default recommended mode for
319
+ * supervisor-style "invoke N in parallel and reduce" patterns.
320
+ */
321
+ export type ConvergenceMode = 'all-succeed' | 'any-succeed' | 'settle-all';
322
+
323
+ /**
324
+ * The set of convergence-mode names recognized by the runtime, exported for
325
+ * tooling that needs the closed enum without repeating literals.
326
+ */
327
+ export const CONVERGENCE_MODES = ['all-succeed', 'any-succeed', 'settle-all'] as const;
328
+
329
+ /**
330
+ * One branch of a `fanout` node. Each branch is a distinct handler-registry
331
+ * entry executed concurrently with its siblings; a branch is NOT a sub-flow
332
+ * (unlike a loop body). `branchId` is the stable label used in the fan-in
333
+ * output object and the journal; `handler` is the string key used to look
334
+ * the handler up in the kernel's `HandlerRegistry` (same lookup mechanism
335
+ * as a leaf node's `ref`). `outputSchema` is REQUIRED — the typed contract
336
+ * for this branch's output, validated at settle time.
337
+ */
338
+ export interface FanoutBranch {
339
+ readonly branchId: string;
340
+ readonly handler: string;
341
+ readonly outputSchema: LoopOutputSchema;
342
+ }
343
+
344
+ /**
345
+ * A fanout node — first-class parallel-branch primitive (schema-version
346
+ * 1.6.0+). Runs N DIFFERENT handler bodies in parallel from a single
347
+ * dispatch and converges by a declared mode. Complementary to `foreach`
348
+ * (N iterations of the SAME body): fanout invokes N distinct handlers
349
+ * concurrently.
350
+ *
351
+ * The fanout node itself is treated by the outer scheduler as an ordinary
352
+ * node with a single output — its incoming edges resolve the input passed
353
+ * to every branch, and its outgoing edges receive the fan-in output. The
354
+ * fan-in shape depends on `convergence` (see `ConvergenceMode`).
355
+ *
356
+ * Sibling cancellation for `all-succeed` / `any-succeed` propagates via a
357
+ * shared `AbortController` composed with the outer abort signal, so
358
+ * cooperative branch handlers observe cancellation through their
359
+ * `NodeContext.abortSignal` — same discipline as parallel foreach and
360
+ * the outer executor's cancelRun path.
361
+ *
362
+ * Replay semantics: the outer envelope (`step.started` / `step.completed`
363
+ * / `step.failed` for the fanout node id) is journaled as with any other
364
+ * node. Per-branch progress is journaled through the fanout-specific
365
+ * `JournalKind` values (`fanout.dispatched`, `fanout.branch-completed`,
366
+ * `fanout.branch-failed`, `fanout.converged`, `fanout.cancelled-siblings`).
367
+ * On resume, if the fanout's outer `step.completed` is present the
368
+ * converged output is already in `nodeOutputs`; otherwise the whole
369
+ * fanout re-dispatches (same rule as loops — branch handlers are
370
+ * expected to be idempotent, or to use waitForToken for durable side
371
+ * effects).
372
+ */
373
+ export interface FanoutNode {
374
+ /** Node id (unique within the containing flow). */
375
+ readonly id: NodeId;
376
+ /** Discriminant — always `'fanout'` for this node kind. */
377
+ readonly kind: 'fanout';
378
+ /**
379
+ * Parallel branches to dispatch. Each branch is one handler (see
380
+ * `FanoutBranch`), not a sub-flow. All branches receive the same
381
+ * input; convergence collects their outputs.
382
+ */
383
+ readonly branches: readonly FanoutBranch[];
384
+ /**
385
+ * Optional bounded worker-pool count. Default: `branches.length` (all
386
+ * branches dispatch concurrently). Loader enforces `1 ≤ concurrency ≤
387
+ * branches.length`. When `concurrency < branches.length`, branches are
388
+ * pulled from the queue in declaration order (deterministic +
389
+ * replay-safe).
390
+ */
391
+ readonly concurrency?: number;
392
+ /** The convergence rule. Decides the fan-in shape (see `ConvergenceMode`). */
393
+ readonly convergence: ConvergenceMode;
394
+ }
395
+
396
+ /**
397
+ * Convergence mode for a `subgraph` node (schema-version 1.7.0+). Declared
398
+ * at flow-authoring time; decidable at flow-load time so downstream
399
+ * consumers know exactly what shape they'll receive from the parent node.
400
+ *
401
+ * - `'success-only'` — child failure fails the PARENT node. The
402
+ * parent's `nodeOutputs` entry is never written; the journal records
403
+ * `subgraph.failed` and the parent step transitions to `step.failed`
404
+ * with an attribution that names the child run id + stop reason.
405
+ * - `'settle-all'` — the parent node's output is always a settled
406
+ * envelope: `{ status: 'succeeded'; output }` when the child run
407
+ * completed cleanly + its terminal output validated against the
408
+ * subgraph node's declared `outputSchema`; `{ status: 'failed';
409
+ * error }` otherwise (child failure, cancellation, or output
410
+ * schema violation). The parent step transitions to `step.completed`
411
+ * regardless of child outcome — the failure is DATA at the parent
412
+ * boundary, not a fault. This is the default recommended mode for
413
+ * supervisor-style orchestration where "sub-run failed" is a
414
+ * first-class outcome the parent flow handles explicitly.
415
+ */
416
+ export type SubflowConvergence = 'success-only' | 'settle-all';
417
+
418
+ /**
419
+ * The set of subgraph convergence-mode names recognized by the runtime,
420
+ * exported for tooling that needs the closed enum without duplicating
421
+ * literals.
422
+ */
423
+ export const SUBGRAPH_CONVERGENCE_MODES = ['success-only', 'settle-all'] as const;
424
+
425
+ /**
426
+ * Reference to another registered flow resolvable via
427
+ * `FlowRegistryBinding.getVersion(...)`. Version is an EXACT semver — no
428
+ * ranges — so deterministic replay always resolves the same child flow.
429
+ * Cross-tenant lookup is rejected by construction: the kernel invokes the
430
+ * resolver with the PARENT run's tenantId; a flow registered under a
431
+ * different tenant will not be found.
432
+ */
433
+ export interface SubflowRef {
434
+ readonly flowId: string;
435
+ readonly version: string;
436
+ }
437
+
438
+ /**
439
+ * A first-class subgraph-invocation node (schema-version 1.7.0+).
440
+ * Dispatches a full kernel run of another registered flow as one
441
+ * outer-node step. The sub-run has its own independent journal, replay
442
+ * semantics, lease/cancel machinery — every kernel durability
443
+ * guarantee is inherited without re-invention.
444
+ *
445
+ * Complementary to the loop + fanout primitives:
446
+ * - `loop.foreach` runs the SAME body × N iterations,
447
+ * - `fanout` runs N DIFFERENT bodies concurrently,
448
+ * - `subgraph` runs a whole OTHER flow inline as a node.
449
+ *
450
+ * `inputMapping` is a declarative object whose values are `Operand`s
451
+ * (path into `runInput` / `state` / `nodeOutputs.<id>`, or a `literal`).
452
+ * The sub-run's `runInput` is the object produced by resolving every
453
+ * mapping at dispatch time.
454
+ *
455
+ * `outputSchema` is the ESCAPE CONTRACT declared on the parent — decoupled
456
+ * from the sub-flow's own internal shape. Parents state what they can
457
+ * consume; the kernel validates the sub-run's terminal output against
458
+ * this schema at settle time. A validation failure is a `settle-all`
459
+ * `{ status: 'failed', error }` OR a `success-only` parent-step failure.
460
+ *
461
+ * A kernel-enforced max depth (default 10, configurable via
462
+ * `RunOptions.maxSubflowDepth`) prevents runaway recursion. Exceeding
463
+ * the depth fails the parent node at dispatch with attribution
464
+ * `subgraph-depth-exceeded`.
465
+ */
466
+ export interface SubflowNode {
467
+ /** Node id (unique within the parent flow). */
468
+ readonly id: NodeId;
469
+ /** Discriminant — always `'subgraph'` for this node kind. */
470
+ readonly kind: 'subgraph';
471
+ /**
472
+ * Which flow to invoke. `{flowId, version}` — `loadFlow` checks
473
+ * only the shape (exact semver). The runtime resolves the sub-flow
474
+ * through the run's `FlowResolver` (`@kindgi/runtime`) when the node
475
+ * dispatches; a flow it can't find fails the node with
476
+ * `subgraph-flow-not-found`. The exact version pins the child flow,
477
+ * so a mid-flight sub-run survives parent-flow edits.
478
+ */
479
+ readonly flowRef: SubflowRef;
480
+ /**
481
+ * Declarative object mapping each top-level key of the sub-run's
482
+ * `runInput` to an Operand. Loader statically verifies every value is
483
+ * a valid Operand; runtime resolution mirrors edge-predicate operand
484
+ * resolution.
485
+ */
486
+ readonly inputMapping: Mapping;
487
+ /**
488
+ * REQUIRED. JSON Schema (draft 2020-12) describing the shape the
489
+ * sub-run's terminal output must satisfy at the parent boundary. The
490
+ * loader validates well-formedness via `InvalidSubflowNodeError`;
491
+ * the kernel validates the sub-run's output at settle time.
492
+ */
493
+ readonly outputSchema: LoopOutputSchema;
494
+ /**
495
+ * Convergence mode — closed enum, extensible additively. See
496
+ * `SubflowConvergence` for the semantics.
497
+ */
498
+ readonly convergence: SubflowConvergence;
499
+ }
500
+
501
+ /**
502
+ * A flow node — either a leaf (tool / agent), a loop (iterating a body
503
+ * sub-flow until an exit condition or array is exhausted), a fanout
504
+ * (running N different bodies in parallel with declared convergence),
505
+ * or a subgraph (invoking another registered flow as a full kernel
506
+ * sub-run). Discriminated by `kind`.
507
+ *
508
+ * Leaf nodes are the majority case. Loop nodes are used for iterative
509
+ * primitives (agent turn loops, retrieval refinement, batch ingest,
510
+ * retry-with-backoff). Fanout nodes are used for supervisor "invoke N
511
+ * sub-agents in parallel and reduce" patterns. Subgraph nodes are used
512
+ * for hierarchical orchestration — composing a large workflow out of
513
+ * smaller registered flows.
514
+ */
515
+ export type FlowNode = LeafNode | LoopNode | FanoutNode | SubflowNode;
516
+
517
+ /**
518
+ * Backoff shape for `RetryPolicy`. Selects how the base `delayMs` grows
519
+ * across successive retry attempts.
520
+ *
521
+ * - `'fixed'` → same `delayMs` every retry.
522
+ * - `'linear'` → `delayMs * attempt` (capped by `maxDelayMs`).
523
+ * - `'exponential'` → `delayMs * 2^(attempt - 1)` (capped by `maxDelayMs`).
524
+ *
525
+ * `attempt` is 1-based: the first retry (after the initial failure) uses
526
+ * `attempt = 1`, the second retry `attempt = 2`, etc.
527
+ */
528
+ export type BackoffShape = 'fixed' | 'linear' | 'exponential';
529
+
530
+ /**
531
+ * Declarative retry policy attached to a `FlowEdge` via `policy.retry`.
532
+ * When the destination node's handler fails and the destination has exactly
533
+ * one incoming edge carrying this policy, the kernel schedules successive
534
+ * attempts up to `maxAttempts` (inclusive of the initial call) before
535
+ * journaling `step.failed` terminally.
536
+ *
537
+ * Fan-in nodes (destinations with 2+ incoming edges) intentionally do NOT
538
+ * apply retry — there is no rule for which incoming edge's retry policy
539
+ * would win. A fan-in node with a retry policy on any
540
+ * incoming edge fails on first handler failure exactly as it would with no
541
+ * policy declared.
542
+ */
543
+ export interface RetryPolicy {
544
+ /**
545
+ * Total handler attempts, including the first invocation. Bounded to
546
+ * `1 ≤ maxAttempts ≤ 10` by the loader. `1` is equivalent to omitting
547
+ * the policy (no retries). Values above 10 are a design smell — retry-
548
+ * heavy control flow belongs in a supervising flow, not on an edge.
549
+ */
550
+ readonly maxAttempts: number;
551
+ /**
552
+ * Base delay between retries in milliseconds. Default `0`. Combined with
553
+ * `backoff` to produce the actual per-attempt delay. Must be non-negative.
554
+ */
555
+ readonly delayMs?: number;
556
+ /**
557
+ * Backoff shape. Default `'fixed'`. `'exponential'` REQUIRES `maxDelayMs`
558
+ * so the delay cannot grow unbounded.
559
+ */
560
+ readonly backoff?: BackoffShape;
561
+ /**
562
+ * Ceiling on the per-retry delay for `'linear'` / `'exponential'` backoff.
563
+ * Required when `backoff = 'exponential'`; ignored when `backoff = 'fixed'`.
564
+ * For `'linear'`, defaults to `60_000` ms if unset.
565
+ */
566
+ readonly maxDelayMs?: number;
567
+ }
568
+
569
+ /**
570
+ * Per-edge runtime policy. Every field is optional. Four fields are
571
+ * honored by the kernel and are the complete set: `retry`,
572
+ * `timeoutMs`, `concurrencyKey`, and `priority`. There is no `suspend`
573
+ * field — declarative suspension uses `ctx.waitForToken` at handler
574
+ * level.
575
+ * Unknown keys are rejected at load time (`additionalProperties:
576
+ * false` on the JSON schema).
577
+ */
578
+ export interface EdgePolicy {
579
+ /**
580
+ * Retry policy for the destination handler. When the sole incoming
581
+ * edge has this set, the kernel retries `step.failed` up to
582
+ * `maxAttempts` with the configured backoff. `retry` is bounded to
583
+ * `1 ≤ maxAttempts ≤ 10`.
584
+ */
585
+ readonly retry?: RetryPolicy;
586
+ /**
587
+ * Per-edge handler-invocation timeout in milliseconds. When set on the
588
+ * SOLE incoming edge of a destination node, the kernel aborts the
589
+ * handler's `ctx.abortSignal` after `timeoutMs` and journals
590
+ * `step.failed` with `payload.reason: 'timeout'`, `limitMs`, and
591
+ * `elapsedMs`. Fan-in nodes (2+ incoming) ignore the timeout — same
592
+ * constraint as `retry`.
593
+ *
594
+ * Bounded to `1 ≤ timeoutMs ≤ 3_600_000` (1 hour). Larger values are
595
+ * almost always a design mistake: long-running work belongs behind
596
+ * `ctx.waitForToken` (durable) or in a `foreach` with `concurrency`
597
+ * (parallelization).
598
+ *
599
+ * Interaction with `retry`: a timed-out attempt counts as one failed
600
+ * attempt. If `retry.maxAttempts > 1`, the node retries the timed-out
601
+ * attempt on the standard schedule.
602
+ *
603
+ * Interaction with cancel: `cancelRun` and the timeout both abort the
604
+ * same handler. Whichever aborts first wins in the journal — cancel
605
+ * fires with `reason: 'cancelled'`, timeout with `reason: 'timeout'`.
606
+ */
607
+ readonly timeoutMs?: number;
608
+ /**
609
+ * Per-key kernel semaphore. When set on the SOLE incoming edge of a
610
+ * destination node, the kernel serializes dispatch across every node
611
+ * (across every run, across every flow, within a single tenant) that
612
+ * shares the same key: at most one such node runs at a time.
613
+ *
614
+ * A node whose lease is unavailable at dispatch time is journaled as
615
+ * `step.concurrency-deferred` and re-considered on the next tick. When
616
+ * the current holder releases the lease (when that node completes,
617
+ * fails or is cancelled), a deferred waiter can acquire it and
618
+ * dispatch.
619
+ *
620
+ * Constraint: max length 256, printable ASCII only (Unicode >0x7E
621
+ * rejected — the key is a coordination handle, not a display label).
622
+ * Empty strings rejected.
623
+ *
624
+ * Fan-in nodes (2+ incoming) ignore `concurrencyKey` — same rule as
625
+ * retry + timeoutMs.
626
+ *
627
+ * Interaction with `retry`: retry-scheduled attempts RE-ACQUIRE the
628
+ * lease on each retry (they don't hold across the backoff delay).
629
+ *
630
+ * Interaction with `timeoutMs`: a timed-out handler releases its
631
+ * lease when the terminal `step.failed` lands.
632
+ *
633
+ * Interaction with cancellation: `cancelRun` releases the lease when
634
+ * the run's holding node journals its terminal event.
635
+ *
636
+ * Interaction with `waitForToken`: a suspending handler RELEASES the
637
+ * lease so peers can proceed. Resumed handler re-acquires the lease
638
+ * before continuing.
639
+ *
640
+ * Sweeper: on `resumeRun` boot, orphaned leases (holder run terminal)
641
+ * are cleaned before scheduling continues.
642
+ */
643
+ readonly concurrencyKey?: string;
644
+ /**
645
+ * Declarative scheduler priority. When the ready set has more than one
646
+ * node, higher-priority nodes dispatch first; ties are broken by
647
+ * ascending lexical node id (deterministic + replay-safe). Applied to
648
+ * the destination node when it has EXACTLY ONE incoming edge — fan-in
649
+ * nodes (2+ incoming) inherit the default (`0`) regardless of any
650
+ * declared priority. Same fan-in rule as retry / timeoutMs /
651
+ * concurrencyKey.
652
+ *
653
+ * Integer, `-100 ≤ priority ≤ 100`. Default `0` (baseline). Positive
654
+ * = more urgent than baseline; negative = background. Integer-only:
655
+ * floats introduce IEEE-754 comparison ambiguity in a sort key that
656
+ * must round-trip identically across every replay. Bounded ±100
657
+ * because the primitive expresses tiers ("urgent" / "normal" /
658
+ * "background") plus finer-grained ordering within a tier — wider
659
+ * ranges add no expressive power.
660
+ *
661
+ * `priority` orders WHICH nodes dispatch first, not HOW MANY dispatch
662
+ * at once. `maxParallelism` still caps the concurrent dispatch count
663
+ * — priority just picks the top-N of the ready set.
664
+ *
665
+ * Interaction with `concurrencyKey`: when multiple nodes contend for
666
+ * the same lease within one executor tick, the priority sort applies
667
+ * before lease acquisition — the higher-priority node attempts (and
668
+ * wins) the lease first; the lower-priority sibling journals
669
+ * `step.concurrency-deferred` and re-attempts on the next tick.
670
+ * Priority does not order nodes that are already waiting on a lease
671
+ * across runs or processes.
672
+ *
673
+ * Interaction with loop bodies: iterations of a loop body dispatch
674
+ * per-iteration; each iteration reads the priority from the body
675
+ * node's edges independently.
676
+ *
677
+ * Determinism: because priority is declarative (from the flow) and
678
+ * tiebreak is lexical (from the node id), the same flow + same
679
+ * journal + same ready set produce the same dispatch order on every
680
+ * replay. No JournalKind is emitted — priority is a scheduling hint,
681
+ * not a durable state transition.
682
+ */
683
+ readonly priority?: number;
684
+ }
685
+
686
+ /**
687
+ * Minimum + maximum values for `EdgePolicy.priority`. Kept as exported
688
+ * constants so downstream tooling (linters, UI editors, docs) can share
689
+ * the same bounds without duplicating literals.
690
+ */
691
+ export const EDGE_POLICY_PRIORITY_MIN = -100 as const;
692
+ export const EDGE_POLICY_PRIORITY_MAX = 100 as const;
693
+ /** The `EdgePolicy.priority` default when the field is unset. */
694
+ export const EDGE_POLICY_PRIORITY_DEFAULT = 0 as const;
695
+
696
+ /**
697
+ * A directed edge from one node (or the flow's virtual start) to another
698
+ * (or the flow's virtual end).
699
+ *
700
+ * Sentinel endpoints:
701
+ * - `$start` — the flow entry point. At least one edge must originate here.
702
+ * - `$end` — the flow exit. Zero or more edges may terminate here.
703
+ */
704
+ export interface FlowEdge {
705
+ readonly id: EdgeId;
706
+ readonly from: NodeId | '$start';
707
+ readonly to: NodeId | '$end';
708
+ /**
709
+ * Optional predicate. When absent, the edge fires unconditionally as soon
710
+ * as its `from` node completes. When present, the edge fires iff the
711
+ * predicate evaluates true against the current run environment.
712
+ */
713
+ readonly when?: Expr;
714
+ /**
715
+ * Optional per-edge runtime policy. Complete set of honored fields
716
+ * (each applies only when the destination has EXACTLY ONE incoming
717
+ * edge; fan-in destinations ignore per-edge policy): `retry`,
718
+ * `timeoutMs`, `concurrencyKey`, `priority`.
719
+ */
720
+ readonly policy?: EdgePolicy;
721
+ }
722
+
723
+ /**
724
+ * A directed edge inside a loop body. Same structure as `FlowEdge` but
725
+ * uses `$loop-start` / `$loop-end` sentinels instead of `$start` / `$end`.
726
+ * Enforcing distinct sentinels per scope makes journal queries unambiguous
727
+ * ("was this `$end` for the outer flow or a loop body?").
728
+ */
729
+ export interface LoopEdge {
730
+ readonly id: EdgeId;
731
+ readonly from: NodeId | '$loop-start';
732
+ readonly to: NodeId | '$loop-end';
733
+ readonly when?: Expr;
734
+ }
735
+
736
+ /**
737
+ * The sub-flow a loop node iterates. Must be a DAG (own cycle check runs
738
+ * at load time). Node ids must not collide with any other node in the
739
+ * enclosing flow or in sibling loop bodies at any nesting level.
740
+ */
741
+ export interface LoopBody {
742
+ readonly nodes: readonly FlowNode[];
743
+ readonly edges: readonly LoopEdge[];
744
+ }
745
+
746
+ /**
747
+ * A durable, versioned task flow. Serializable JSON — the same flow
748
+ * definition can execute on any conforming runtime. Runs pin to a specific
749
+ * `version` so mid-flight executions survive flow edits.
750
+ */
751
+ export interface Flow {
752
+ /**
753
+ * Globally-unique flow identifier. Convention:
754
+ * `<pack-id>.<flow-name>` (kebab-case, dot-namespaced) — e.g.
755
+ * `acme.brief-review-flow`. The framework's built-in agent-turn
756
+ * flow is `agent.turn` (`AGENT_TURN_FLOW_ID` in `@kindgi/agents`).
757
+ */
758
+ readonly id: FlowId;
759
+ /**
760
+ * Semver — REQUIRED. Runs pin to a specific flow version at
761
+ * `runGraph` time so mid-flight executions survive later flow
762
+ * edits. `SubflowNode.flowRef.version` names one of these versions
763
+ * exactly.
764
+ */
765
+ readonly version: string;
766
+ /** Human-readable name shown in UI + provenance. Doesn't affect execution. */
767
+ readonly name?: string;
768
+ /** Prose describing what the flow does. Surfaced in the flow catalog. */
769
+ readonly description?: string;
770
+ /**
771
+ * All nodes in the flow. Each node has a `kind` (`tool`, `agent`,
772
+ * `loop`, `fanout`, `subgraph`) and kind-specific fields.
773
+ * Order in the array doesn't matter — execution follows the edge
774
+ * DAG, not array order.
775
+ */
776
+ readonly nodes: readonly FlowNode[];
777
+ /**
778
+ * Directed edges connecting node ids. Each edge carries an optional
779
+ * `policy` (`retry`, `timeoutMs`, `concurrencyKey`, `priority`). `$start` + `$end`
780
+ * sentinels are the outer flow's entry/exit.
781
+ */
782
+ readonly edges: readonly FlowEdge[];
783
+ /**
784
+ * Flow-level default for concurrent node dispatch. Kernel uses this
785
+ * when `runGraph` is called without an explicit `options.maxParallelism`.
786
+ * Absent → `DEFAULT_MAX_PARALLELISM` (8) from `@kindgi/runtime`.
787
+ * Per-invocation overrides always win.
788
+ */
789
+ readonly maxParallelism?: number;
790
+ /**
791
+ * Free-form key/value metadata that rides with the flow. Not
792
+ * interpreted by the framework — passthrough for pack-authored
793
+ * annotations (owner, ticket, tags).
794
+ */
795
+ readonly metadata?: Readonly<Record<string, unknown>>;
796
+ /**
797
+ * The run's declared output (schema-version 1.8.0+), resolved when the
798
+ * run completes. Absent → the output of the node feeding the run's
799
+ * `$end` edge (the lowest-id taken one when several fire).
800
+ */
801
+ readonly output?: FlowOutputSpec;
802
+ }
803
+
804
+ /**
805
+ * A flow's declared output: what the run returns, built from the
806
+ * finished run's environment rather than from whichever node happened to
807
+ * reach `$end` last.
808
+ */
809
+ export interface FlowOutputSpec {
810
+ readonly mapping: Mapping;
811
+ /**
812
+ * Optional JSON Schema (draft 2020-12) the resolved output must
813
+ * satisfy. The loader checks it compiles; the kernel fails the run on
814
+ * a violation.
815
+ */
816
+ readonly schema?: LoopOutputSchema;
817
+ }
818
+
819
+ /**
820
+ * Sentinel node ids used by flow edges. `$start` / `$end` are the outer
821
+ * flow's entry/exit. `$loop-start` / `$loop-end` are the body-scope
822
+ * entry/exit inside a loop node.
823
+ *
824
+ * These sentinels are never present in `flow.nodes` or `LoopBody.nodes` —
825
+ * they're virtual, and no handler executes at them.
826
+ */
827
+ export const START_NODE = '$start' as const;
828
+ export const END_NODE = '$end' as const;
829
+ export const LOOP_START_NODE = '$loop-start' as const;
830
+ export const LOOP_END_NODE = '$loop-end' as const;
831
+
832
+ /** All sentinel ids. Useful for validators that need to reject them as node ids. */
833
+ export const SENTINEL_IDS = [START_NODE, END_NODE, LOOP_START_NODE, LOOP_END_NODE] as const;
834
+
835
+ /** Type guard: is this node a loop node? */
836
+ export function isLoopNode(node: FlowNode): node is LoopNode {
837
+ return node.kind === 'loop';
838
+ }
839
+
840
+ /** Type guard: is this node a fanout node? */
841
+ export function isFanoutNode(node: FlowNode): node is FanoutNode {
842
+ return node.kind === 'fanout';
843
+ }
844
+
845
+ /** Type guard: is this node a subgraph-invocation node? */
846
+ export function isSubflowNode(node: FlowNode): node is SubflowNode {
847
+ return node.kind === 'subgraph';
848
+ }
849
+
850
+ /** Type guard: is this node a leaf? */
851
+ export function isLeafNode(node: FlowNode): node is LeafNode {
852
+ return node.kind !== 'loop' && node.kind !== 'fanout' && node.kind !== 'subgraph';
853
+ }