@kindgi/runtime 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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +65 -1
  3. package/dist/bindings.d.ts +186 -0
  4. package/dist/bindings.d.ts.map +1 -0
  5. package/dist/bindings.js +4 -0
  6. package/dist/bindings.js.map +1 -0
  7. package/dist/derivation.d.ts +123 -0
  8. package/dist/derivation.d.ts.map +1 -0
  9. package/dist/derivation.js +249 -0
  10. package/dist/derivation.js.map +1 -0
  11. package/dist/errors.d.ts +78 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +4 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/event-bus.d.ts +41 -0
  16. package/dist/event-bus.d.ts.map +1 -0
  17. package/dist/event-bus.js +13 -0
  18. package/dist/event-bus.js.map +1 -0
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +13 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/inputs.d.ts +160 -0
  24. package/dist/inputs.d.ts.map +1 -0
  25. package/dist/inputs.js +4 -0
  26. package/dist/inputs.js.map +1 -0
  27. package/dist/runs.d.ts +73 -0
  28. package/dist/runs.d.ts.map +1 -0
  29. package/dist/runs.js +4 -0
  30. package/dist/runs.js.map +1 -0
  31. package/dist/schedulers/registry.d.ts +142 -0
  32. package/dist/schedulers/registry.d.ts.map +1 -0
  33. package/dist/schedulers/registry.js +4 -0
  34. package/dist/schedulers/registry.js.map +1 -0
  35. package/dist/schedulers/types.d.ts +99 -0
  36. package/dist/schedulers/types.d.ts.map +1 -0
  37. package/dist/schedulers/types.js +4 -0
  38. package/dist/schedulers/types.js.map +1 -0
  39. package/dist/types.d.ts +317 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +6 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/versioning.d.ts +59 -0
  44. package/dist/versioning.d.ts.map +1 -0
  45. package/dist/versioning.js +89 -0
  46. package/dist/versioning.js.map +1 -0
  47. package/package.json +50 -4
  48. package/src/bindings.ts +244 -0
  49. package/src/derivation.ts +354 -0
  50. package/src/errors.ts +92 -0
  51. package/src/event-bus.ts +51 -0
  52. package/src/index.ts +13 -0
  53. package/src/inputs.ts +169 -0
  54. package/src/runs.ts +84 -0
  55. package/src/schedulers/registry.ts +197 -0
  56. package/src/schedulers/types.ts +112 -0
  57. package/src/types.ts +378 -0
  58. package/src/versioning.ts +110 -0
@@ -0,0 +1,244 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // Kernel-runtime binding interfaces. The public shape of what a
6
+ // deployment plugs into `createApp` for run lifecycle, scheduling,
7
+ // waitpoint timers, and retention. Implementations are supplied by the
8
+ // Kindgi runtime; a deployment can substitute individual sub-bindings.
9
+ //
10
+
11
+ import type { Result, RunId, TenantId } from '@kindgi/types';
12
+
13
+ import type { KernelError } from './errors.js';
14
+ import type { KernelEventBusBinding } from './event-bus.js';
15
+ import type {
16
+ DeleteRunError,
17
+ DeleteRunParams,
18
+ ResumeRunInput,
19
+ RunFlowInput,
20
+ StartRunError,
21
+ StartRunParams,
22
+ } from './inputs.js';
23
+ import type { KernelRunRecord, ListRunsInput, ListRunsPage } from './runs.js';
24
+ import type { TriggerRegistryBinding } from './schedulers/registry.js';
25
+ import type { JournalEntry, RunResult } from './types.js';
26
+
27
+ /**
28
+ * Run-lifecycle binding. Every enforcement site (@kindgi/api routes,
29
+ * @kindgi/agents invocation flow) that wants to start / resume /
30
+ * cancel / read a run goes through this contract. The implementation
31
+ * is supplied by the Kindgi runtime.
32
+ */
33
+ export interface RunBinding {
34
+ /**
35
+ * Run a flow until it completes, fails, is cancelled or suspends.
36
+ * Starts a new run, or — when `input.runId` is set — runs the
37
+ * `pending` run `startRun` created.
38
+ */
39
+ runGraph<TOutput = unknown>(
40
+ input: RunFlowInput,
41
+ ): Promise<Result<RunResult<TOutput>, KernelError>>;
42
+
43
+ /**
44
+ * Resume a suspended or interrupted run. The journal is the source
45
+ * of truth; every completed step is skipped and only outstanding
46
+ * work is dispatched. `flow` + `handlers` must match the flow the
47
+ * run was started with — versioned pinning refuses re-versioned
48
+ * flows with `flow-mismatch`.
49
+ */
50
+ resumeRun<TOutput = unknown>(
51
+ input: ResumeRunInput,
52
+ ): Promise<Result<RunResult<TOutput>, KernelError>>;
53
+
54
+ /**
55
+ * Cancel an in-flight or suspended run. Journals `run.cancelled`;
56
+ * outstanding handlers observe `ctx.abortSignal`. Optional
57
+ * `eventBus` is used to publish the cancellation event alongside
58
+ * the journal write.
59
+ */
60
+ cancelRun(
61
+ tenantId: TenantId,
62
+ runId: RunId,
63
+ eventBus?: KernelEventBusBinding,
64
+ ): Promise<Result<void, KernelError>>;
65
+
66
+ /**
67
+ * Cancel an outstanding waitpoint. The handler observing the token
68
+ * via `ctx.waitForToken(...)` throws `WaitpointCancelledError` on
69
+ * resume with the supplied `reason`. Journals `wait.cancelled`.
70
+ */
71
+ cancelToken(
72
+ tenantId: TenantId,
73
+ runId: RunId,
74
+ tokenId: string,
75
+ reason: string,
76
+ eventBus?: KernelEventBusBinding,
77
+ ): Promise<Result<void, KernelError>>;
78
+
79
+ /**
80
+ * Resolve an outstanding waitpoint with a value. The handler
81
+ * observing the token via `ctx.waitForToken(...)` returns the value
82
+ * on resume. Journals `wait.resumed`.
83
+ */
84
+ completeToken(
85
+ tenantId: TenantId,
86
+ runId: RunId,
87
+ tokenId: string,
88
+ value: unknown,
89
+ eventBus?: KernelEventBusBinding,
90
+ ): Promise<Result<void, KernelError>>;
91
+
92
+ /**
93
+ * Read the full ordered journal for a run — every entry, in
94
+ * sequence order. The primitive routes + agents consume this for
95
+ * SSE tailing + replay reconstruction.
96
+ */
97
+ readJournal(
98
+ tenantId: TenantId,
99
+ runId: RunId,
100
+ ): Promise<Result<readonly JournalEntry[], KernelError>>;
101
+
102
+ /**
103
+ * Create a `pending` run without dispatching it. Used by callers
104
+ * that want to hand back a `runId` synchronously and start the
105
+ * run out-of-band (background workers, HTTP `POST /v1/runs` with
106
+ * `wait: false` answering `202`, subgraph child dispatch); the run
107
+ * is then executed with `runGraph({ ..., runId })`.
108
+ */
109
+ startRun(params: StartRunParams): Promise<Result<{ readonly runId: RunId }, StartRunError>>;
110
+
111
+ /**
112
+ * Delete a run + its journal + waitpoints. Retention path;
113
+ * `not-found` returned for missing runs so the caller can be
114
+ * idempotent under concurrent deletes.
115
+ */
116
+ deleteRun(params: DeleteRunParams): Promise<Result<void, DeleteRunError>>;
117
+
118
+ /**
119
+ * Fetch a single run by id, scoped to tenant. Returns null if
120
+ * the run doesn't exist or belongs to a different tenant. Callers
121
+ * do additional authz gating (per-project scope) at the route
122
+ * layer; this method is a straight tenant-scoped point-read.
123
+ */
124
+ getRun(tenantId: TenantId, runId: RunId): Promise<KernelRunRecord | null>;
125
+
126
+ /**
127
+ * List runs in `(createdAt desc, id desc)` order, cursor-
128
+ * paginated. Filter by content scope: absent = tenant-wide,
129
+ * `{ kind: 'project' }` narrows to one project, `{ kind: 'org' }`
130
+ * includes every project in the org. `parent` narrows to a run's
131
+ * children; `topLevelOnly` excludes child runs.
132
+ */
133
+ listRuns(input: ListRunsInput): Promise<ListRunsPage>;
134
+ }
135
+
136
+ /**
137
+ * Scheduler bootstrap binding. Deployments start/stop these long-
138
+ * lived processes at boot; the trigger CRUD flows through
139
+ * `TriggerRegistryBinding` separately.
140
+ */
141
+ export interface SchedulerBinding {
142
+ /**
143
+ * Start the cron scheduler process. Returns a handle whose
144
+ * `stop()` cleanly shuts it down. Deployments call this once at
145
+ * boot and hold the handle for graceful-shutdown wiring.
146
+ */
147
+ startCronScheduler(options: CronSchedulerOptions): CronSchedulerHandle;
148
+
149
+ /** Start the event-trigger scheduler process. */
150
+ startEventTriggerScheduler(options: EventTriggerSchedulerOptions): EventTriggerSchedulerHandle;
151
+
152
+ /**
153
+ * Fire a specific webhook trigger by its webhookId. Called by a
154
+ * webhook receiver AFTER it has verified the request's HMAC
155
+ * signature. The scheduler resolves the trigger, dispatches the
156
+ * flow, and journals the fire.
157
+ */
158
+ fireByWebhookId(input: FireByWebhookIdInput): Promise<Result<{ readonly runId: RunId }, unknown>>;
159
+
160
+ /**
161
+ * Compute the initial `nextFireAt` for a cron config. Called at
162
+ * trigger register/update to precompute the first-fire timestamp
163
+ * in the same write.
164
+ */
165
+ initialNextFireAt(
166
+ config: { readonly cronExpression: string; readonly timezone?: string },
167
+ now?: Date,
168
+ ): Date;
169
+ }
170
+
171
+ export interface CronSchedulerOptions {
172
+ readonly runFlowBinding: unknown; // caller-supplied RunFlowBinding; see @kindgi/runtime.RunFlowBinding
173
+ readonly pollIntervalMs?: number;
174
+ readonly logger?: (msg: string, ctx?: Record<string, unknown>) => void;
175
+ }
176
+
177
+ export interface CronSchedulerHandle {
178
+ stop(): Promise<void>;
179
+ }
180
+
181
+ export interface EventTriggerSchedulerOptions {
182
+ readonly runFlowBinding: unknown;
183
+ readonly logger?: (msg: string, ctx?: Record<string, unknown>) => void;
184
+ }
185
+
186
+ export interface EventTriggerSchedulerHandle {
187
+ stop(): Promise<void>;
188
+ }
189
+
190
+ export interface FireByWebhookIdInput {
191
+ readonly tenantId: TenantId;
192
+ readonly webhookId: string;
193
+ readonly runFlowBinding: unknown;
194
+ readonly requestBody: unknown;
195
+ }
196
+
197
+ /**
198
+ * Waitpoint-timeout sleeper binding. A long-lived process that finds
199
+ * waitpoints whose timeout has passed and cancels them with
200
+ * `reason: 'timeout'`. Deployments start this at boot alongside the
201
+ * schedulers.
202
+ */
203
+ export interface WaitpointBinding {
204
+ startTimeoutSleeper(options: WaitpointTimeoutSleeperOptions): WaitpointTimeoutSleeperHandle;
205
+ }
206
+
207
+ export interface WaitpointTimeoutSleeperOptions {
208
+ readonly pollIntervalMs?: number;
209
+ readonly logger?: (msg: string, ctx?: Record<string, unknown>) => void;
210
+ }
211
+
212
+ export interface WaitpointTimeoutSleeperHandle {
213
+ stop(): Promise<void>;
214
+ }
215
+
216
+ /**
217
+ * Run retention binding. Deployments plug this in to have expired
218
+ * runs (per tenant retention policy) reaped on a schedule.
219
+ */
220
+ export interface RunRetentionBinding {
221
+ createRetentionAdapter(options: RunRetentionOptions): RunRetentionAdapter;
222
+ }
223
+
224
+ export interface RunRetentionOptions {
225
+ readonly logger?: (msg: string, ctx?: Record<string, unknown>) => void;
226
+ }
227
+
228
+ export interface RunRetentionAdapter {
229
+ reapExpired(input: { readonly tenantId: TenantId }): Promise<{ readonly deleted: number }>;
230
+ }
231
+
232
+ /**
233
+ * Umbrella that groups every kernel-runtime binding. What
234
+ * `CreateAppInput.kernelBinding` in `@kindgi/api` accepts. The Kindgi
235
+ * runtime supplies the implementation.
236
+ */
237
+ export interface KernelBinding {
238
+ readonly run: RunBinding;
239
+ readonly scheduler: SchedulerBinding;
240
+ readonly waitpoint: WaitpointBinding;
241
+ readonly retention: RunRetentionBinding;
242
+ readonly triggers: TriggerRegistryBinding;
243
+ readonly eventBus?: KernelEventBusBinding;
244
+ }
@@ -0,0 +1,354 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { HandlerResult, LoopContext } from '@kindgi/handler';
5
+ import type { EdgeId, NodeId } from '@kindgi/types';
6
+
7
+ import type { JournalEntry } from './types.js';
8
+
9
+ /**
10
+ * Pure functions that project journal entries into the state shapes the
11
+ * scheduler + evaluator consume. The journal is the sole source of truth
12
+ * for run state — every derivation goes through here.
13
+ */
14
+
15
+ export interface DerivedRunState {
16
+ readonly completedNodes: Set<NodeId>;
17
+ readonly inFlightNodes: Set<NodeId>;
18
+ readonly failedNodes: Set<NodeId>;
19
+ /**
20
+ * Nodes whose most recent state transition is `wait.suspended` with no
21
+ * matching `wait.resumed` yet. The executor treats these as "in-progress
22
+ * from the scheduler's POV" (merged with `inFlightNodes` when building
23
+ * `SchedulerState`) so they don't get re-dispatched while the wait is
24
+ * outstanding, and terminates the tick loop as `suspended` when every
25
+ * outstanding node is in this bucket.
26
+ */
27
+ readonly suspendedNodes: Set<NodeId>;
28
+ /**
29
+ * Nodes whose most recent transition is `step.concurrency-deferred` with
30
+ * no subsequent `step.started` / `step.failed`. Recorded so the tick
31
+ * loop knows the ready-set entry is "waiting on a lease" — dispatch
32
+ * skips these until the lease releases, then re-attempts acquisition
33
+ * on the next tick. Not merged into `inFlightNodes` (they haven't run
34
+ * yet); a deferred-only run isn't `suspended` (which is a wait-token
35
+ * concept) — it's transiently blocked on a peer.
36
+ */
37
+ readonly deferredNodes: Set<NodeId>;
38
+ readonly edgeDecisions: Map<EdgeId, boolean>;
39
+ readonly nodeOutputs: Map<NodeId, unknown>;
40
+ readonly state: Record<string, unknown>;
41
+ /**
42
+ * Ordered log of step.failed messages, first-seen order. Used to build
43
+ * `run.failed`'s summary payload.
44
+ */
45
+ readonly failureMessages: string[];
46
+ /**
47
+ * Resolved wait values, keyed by `${nodeId}::${tokenId}`. Populated from
48
+ * `wait.resumed` journal entries. Handlers re-invoked after resume read
49
+ * from this map inside `ctx.waitForToken()` to return the resolved value
50
+ * instead of suspending again.
51
+ */
52
+ readonly waitResolutions: Map<string, unknown>;
53
+ /**
54
+ * Cancelled wait reasons, keyed by `${nodeId}::${tokenId}`. Populated from
55
+ * `wait.cancelled` journal entries. Handlers re-invoked after cancel read
56
+ * from this map inside `ctx.waitForToken()` to throw a
57
+ * `WaitpointCancelledError` instead of returning a value.
58
+ */
59
+ readonly waitCancellations: Map<string, string>;
60
+ /**
61
+ * Attempt counter per node, derived by counting `step.retry-scheduled`
62
+ * journal entries for that node. `0` on the initial dispatch. After N
63
+ * retries the counter is `N`. Exposed for observability + the executor's
64
+ * retry decision path; not consulted by the scheduler otherwise.
65
+ */
66
+ readonly nodeAttempts: Map<NodeId, number>;
67
+ /**
68
+ * Loop-body steps that completed, keyed by `bodyStepKey(nodeId,
69
+ * loopContext)`. A loop replays from iteration zero on resume; the loop
70
+ * executor hands a body step that already completed its journaled
71
+ * result instead of running it again, as the outer flow does for its
72
+ * own completed steps.
73
+ */
74
+ readonly completedBodySteps: Map<string, CompletedBodyStep>;
75
+ }
76
+
77
+ /** What a loop-body step journaled when it completed. */
78
+ export interface CompletedBodyStep {
79
+ readonly output: unknown;
80
+ readonly stateDelta?: Readonly<Record<string, unknown>>;
81
+ }
82
+
83
+ /** Composite key for `waitResolutions` lookups. */
84
+ export function waitResolutionKey(nodeId: NodeId, tokenId: string): string {
85
+ return `${nodeId}::${tokenId}`;
86
+ }
87
+
88
+ /** A loop-body step's key in `completedBodySteps`: the node, at its iteration of every enclosing loop. */
89
+ export function bodyStepKey(nodeId: NodeId, loopContext: LoopContext): string {
90
+ return `${nodeId}@${loopContext.path.map((p) => `${p.loopNodeId}#${p.iteration}`).join('/')}`;
91
+ }
92
+
93
+ /**
94
+ * Fold journal entries into the run state view the scheduler needs.
95
+ *
96
+ * Rules:
97
+ * - `step.started` puts the node into `inFlightNodes`.
98
+ * - `step.completed` removes it from `inFlightNodes`, adds to `completedNodes`,
99
+ * records its `output` in `nodeOutputs`, and shallow-merges any `stateDelta`
100
+ * into `state` (last-writer-wins by journal sequence).
101
+ * - `step.failed` removes it from `inFlightNodes`, adds to `failedNodes`, and
102
+ * records the failure message.
103
+ * - `edge.evaluated` records the decision keyed by edgeId.
104
+ * - `wait.suspended` moves the node from `inFlightNodes` to
105
+ * `suspendedNodes`; `wait.resumed` / `wait.cancelled` take it out of
106
+ * `suspendedNodes` and record the value / reason by
107
+ * `waitResolutionKey(nodeId, tokenId)`.
108
+ * - `step.retry-scheduled` clears the node's failed / in-flight markers
109
+ * and increments `nodeAttempts`.
110
+ * - `step.concurrency-deferred` adds the node to `deferredNodes`.
111
+ * - a `step.completed` with a `loopContext` (a loop-body step) is also
112
+ * recorded in `completedBodySteps`.
113
+ * - Every other kind is informational (run.started, run.completed, etc.).
114
+ */
115
+ export function deriveRunState(journal: readonly JournalEntry[]): DerivedRunState {
116
+ const state = createRunState();
117
+ for (const entry of journal) applyJournalEntry(state, entry);
118
+ return state;
119
+ }
120
+
121
+ /** The state of a run with no journal entries — `deriveRunState([])`. */
122
+ export function createRunState(): DerivedRunState {
123
+ return {
124
+ completedNodes: new Set<NodeId>(),
125
+ inFlightNodes: new Set<NodeId>(),
126
+ failedNodes: new Set<NodeId>(),
127
+ suspendedNodes: new Set<NodeId>(),
128
+ deferredNodes: new Set<NodeId>(),
129
+ edgeDecisions: new Map<EdgeId, boolean>(),
130
+ nodeOutputs: new Map<NodeId, unknown>(),
131
+ state: {},
132
+ failureMessages: [],
133
+ waitResolutions: new Map<string, unknown>(),
134
+ waitCancellations: new Map<string, string>(),
135
+ nodeAttempts: new Map<NodeId, number>(),
136
+ completedBodySteps: new Map<string, CompletedBodyStep>(),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Apply one journal entry to `state`, in place, by the rules of
142
+ * `deriveRunState` (which folds the journal with it). A runtime that
143
+ * keeps a run's state in memory reads the journal once, then applies each
144
+ * entry it writes, instead of reading the journal again.
145
+ */
146
+ export function applyJournalEntry(state: DerivedRunState, entry: JournalEntry): void {
147
+ applyEntry(state as MutableDerivedState, entry);
148
+ }
149
+
150
+ /**
151
+ * A copy of `state` that later `applyJournalEntry` calls on `state` don't
152
+ * change — what a step sees should be the run as it stood when the step
153
+ * was dispatched. Values (node outputs, state values) are shared, not
154
+ * copied; the journal never changes a value in place.
155
+ */
156
+ export function cloneRunState(state: DerivedRunState): DerivedRunState {
157
+ return {
158
+ completedNodes: new Set(state.completedNodes),
159
+ inFlightNodes: new Set(state.inFlightNodes),
160
+ failedNodes: new Set(state.failedNodes),
161
+ suspendedNodes: new Set(state.suspendedNodes),
162
+ deferredNodes: new Set(state.deferredNodes),
163
+ edgeDecisions: new Map(state.edgeDecisions),
164
+ nodeOutputs: new Map(state.nodeOutputs),
165
+ state: { ...state.state },
166
+ failureMessages: [...state.failureMessages],
167
+ waitResolutions: new Map(state.waitResolutions),
168
+ waitCancellations: new Map(state.waitCancellations),
169
+ nodeAttempts: new Map(state.nodeAttempts),
170
+ completedBodySteps: new Map(state.completedBodySteps),
171
+ };
172
+ }
173
+
174
+ interface MutableDerivedState {
175
+ completedNodes: Set<NodeId>;
176
+ inFlightNodes: Set<NodeId>;
177
+ failedNodes: Set<NodeId>;
178
+ suspendedNodes: Set<NodeId>;
179
+ deferredNodes: Set<NodeId>;
180
+ edgeDecisions: Map<EdgeId, boolean>;
181
+ nodeOutputs: Map<NodeId, unknown>;
182
+ state: Record<string, unknown>;
183
+ failureMessages: string[];
184
+ waitResolutions: Map<string, unknown>;
185
+ waitCancellations: Map<string, string>;
186
+ nodeAttempts: Map<NodeId, number>;
187
+ completedBodySteps: Map<string, CompletedBodyStep>;
188
+ }
189
+
190
+ function applyEntry(acc: MutableDerivedState, entry: JournalEntry): void {
191
+ switch (entry.kind) {
192
+ case 'step.started':
193
+ applyStepStarted(acc, entry);
194
+ return;
195
+ case 'step.completed':
196
+ applyStepCompleted(acc, entry);
197
+ return;
198
+ case 'step.failed':
199
+ applyStepFailed(acc, entry);
200
+ return;
201
+ case 'edge.evaluated':
202
+ applyEdgeEvaluated(acc, entry);
203
+ return;
204
+ case 'wait.suspended':
205
+ applyWaitSuspended(acc, entry);
206
+ return;
207
+ case 'wait.resumed':
208
+ applyWaitResumed(acc, entry);
209
+ return;
210
+ case 'wait.cancelled':
211
+ applyWaitCancelled(acc, entry);
212
+ return;
213
+ case 'step.retry-scheduled':
214
+ applyStepRetryScheduled(acc, entry);
215
+ return;
216
+ case 'step.concurrency-deferred':
217
+ applyStepConcurrencyDeferred(acc, entry);
218
+ return;
219
+ default:
220
+ // iteration.*, fanout.*, subgraph.*, run.*, clock.read: informational.
221
+ // Derivation observes them via their paired step.* entries.
222
+ return;
223
+ }
224
+ }
225
+
226
+ function applyStepStarted(acc: MutableDerivedState, entry: JournalEntry): void {
227
+ if (entry.nodeId === undefined) return;
228
+ // Re-dispatch after wait.resumed: node moves out of suspendedNodes and
229
+ // back into inFlightNodes. Duplicate step.started entries collapse
230
+ // idempotently — first occurrence wins, subsequent ones are no-ops
231
+ // beyond ensuring the node is in inFlightNodes.
232
+ acc.suspendedNodes.delete(entry.nodeId);
233
+ // A prior step.concurrency-deferred no longer applies once the node
234
+ // starts — clear the deferred marker so the scheduler no longer treats
235
+ // this node as waiting on a lease.
236
+ acc.deferredNodes.delete(entry.nodeId);
237
+ acc.inFlightNodes.add(entry.nodeId);
238
+ }
239
+
240
+ function applyStepCompleted(acc: MutableDerivedState, entry: JournalEntry): void {
241
+ if (entry.nodeId === undefined) return;
242
+ acc.inFlightNodes.delete(entry.nodeId);
243
+ // Clear residual suspended-marker in case this completion is a body
244
+ // node re-run after a loop-level resume (the suspend was written
245
+ // against the body node too).
246
+ acc.suspendedNodes.delete(entry.nodeId);
247
+ acc.deferredNodes.delete(entry.nodeId);
248
+ acc.completedNodes.add(entry.nodeId);
249
+ const p = entry.payload as
250
+ | {
251
+ readonly output?: unknown;
252
+ readonly stateDelta?: Record<string, unknown>;
253
+ readonly loopContext?: LoopContext;
254
+ }
255
+ | undefined;
256
+ if (p !== undefined && 'output' in p) acc.nodeOutputs.set(entry.nodeId, p.output);
257
+ if (p?.stateDelta !== undefined) {
258
+ for (const [k, v] of Object.entries(p.stateDelta)) acc.state[k] = v;
259
+ }
260
+ if (p?.loopContext !== undefined) {
261
+ acc.completedBodySteps.set(bodyStepKey(entry.nodeId, p.loopContext), {
262
+ output: p.output,
263
+ ...(p.stateDelta !== undefined && { stateDelta: p.stateDelta }),
264
+ });
265
+ }
266
+ }
267
+
268
+ function applyStepFailed(acc: MutableDerivedState, entry: JournalEntry): void {
269
+ if (entry.nodeId === undefined) return;
270
+ acc.inFlightNodes.delete(entry.nodeId);
271
+ acc.suspendedNodes.delete(entry.nodeId);
272
+ acc.deferredNodes.delete(entry.nodeId);
273
+ acc.failedNodes.add(entry.nodeId);
274
+ const m = (entry.payload as { readonly message?: unknown } | undefined)?.message;
275
+ if (typeof m === 'string') acc.failureMessages.push(m);
276
+ }
277
+
278
+ function applyEdgeEvaluated(acc: MutableDerivedState, entry: JournalEntry): void {
279
+ const p = entry.payload as { readonly edgeId?: string; readonly decision?: unknown } | undefined;
280
+ if (p?.edgeId !== undefined && typeof p.decision === 'boolean') {
281
+ acc.edgeDecisions.set(p.edgeId as EdgeId, p.decision);
282
+ }
283
+ }
284
+
285
+ function applyWaitSuspended(acc: MutableDerivedState, entry: JournalEntry): void {
286
+ if (entry.nodeId === undefined) return;
287
+ // Handler suspended mid-flight: drop from inFlightNodes, mark suspended.
288
+ // The step.started for this node stays "logically active" — resume
289
+ // dispatch re-fires step.started, which flips node back to inFlightNodes.
290
+ acc.inFlightNodes.delete(entry.nodeId);
291
+ acc.suspendedNodes.add(entry.nodeId);
292
+ }
293
+
294
+ function applyStepRetryScheduled(acc: MutableDerivedState, entry: JournalEntry): void {
295
+ if (entry.nodeId === undefined) return;
296
+ // Retry-scheduled means: the handler just failed, the executor decided
297
+ // to retry, and is about to re-dispatch. Clear residual failure /
298
+ // in-flight markers so the node is re-dispatched cleanly on the next
299
+ // tick's ready set. Attempt counter increments monotonically.
300
+ acc.failedNodes.delete(entry.nodeId);
301
+ acc.inFlightNodes.delete(entry.nodeId);
302
+ const current = acc.nodeAttempts.get(entry.nodeId) ?? 0;
303
+ acc.nodeAttempts.set(entry.nodeId, current + 1);
304
+ }
305
+
306
+ function applyStepConcurrencyDeferred(acc: MutableDerivedState, entry: JournalEntry): void {
307
+ if (entry.nodeId === undefined) return;
308
+ // Deferred means: the dispatcher tried to acquire the lease and failed.
309
+ // The node is not yet in-flight; a subsequent step.started (once the
310
+ // lease is free) clears this marker. Duplicate deferred entries collapse
311
+ // idempotently — the node is either in deferredNodes or not, count is
312
+ // observability-only via the raw journal.
313
+ acc.deferredNodes.add(entry.nodeId);
314
+ }
315
+
316
+ function applyWaitResumed(acc: MutableDerivedState, entry: JournalEntry): void {
317
+ if (entry.nodeId === undefined) return;
318
+ acc.suspendedNodes.delete(entry.nodeId);
319
+ const p = entry.payload as { readonly tokenId?: unknown; readonly value?: unknown } | undefined;
320
+ if (p !== undefined && typeof p.tokenId === 'string') {
321
+ // Record the resolved value against the (nodeId, tokenId) pair so the
322
+ // handler re-invocation reads it back through `ctx.waitForToken()`.
323
+ acc.waitResolutions.set(waitResolutionKey(entry.nodeId, p.tokenId), p.value);
324
+ }
325
+ }
326
+
327
+ function applyWaitCancelled(acc: MutableDerivedState, entry: JournalEntry): void {
328
+ if (entry.nodeId === undefined) return;
329
+ // Cancel resolves the suspension too — the node moves out of suspendedNodes
330
+ // and is redispatched so its handler re-runs, reads the cancellation
331
+ // from waitCancellations via ctx.waitForToken(), and throws
332
+ // WaitpointCancelledError to propagate through the flow as a failure.
333
+ acc.suspendedNodes.delete(entry.nodeId);
334
+ const p = entry.payload as { readonly tokenId?: unknown; readonly reason?: unknown } | undefined;
335
+ if (p !== undefined && typeof p.tokenId === 'string') {
336
+ const reason = typeof p.reason === 'string' ? p.reason : 'unknown';
337
+ acc.waitCancellations.set(waitResolutionKey(entry.nodeId, p.tokenId), reason);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Normalize a raw handler return into a HandlerResult shape. Bare values
343
+ * become `{ output: value }`; already-structured values pass through.
344
+ */
345
+ export function normalizeHandlerResult(raw: unknown): HandlerResult {
346
+ if (raw !== null && typeof raw === 'object' && 'output' in raw) {
347
+ const r = raw as Partial<HandlerResult>;
348
+ if (r.stateDelta !== undefined) {
349
+ return { output: r.output, stateDelta: r.stateDelta };
350
+ }
351
+ return { output: r.output };
352
+ }
353
+ return { output: raw };
354
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ export type KernelError =
5
+ | RunNotFoundError
6
+ | FlowMismatchError
7
+ | RunAlreadyTerminalError
8
+ | NodeExecutionError
9
+ | JournalError
10
+ | WaitpointError
11
+ | HandlerMissingError
12
+ | StuckRunError
13
+ | RunLeaseLostError;
14
+
15
+ export interface RunNotFoundError {
16
+ readonly code: 'run-not-found';
17
+ readonly message: string;
18
+ readonly runId: string;
19
+ }
20
+
21
+ /** `resumeRun` was called with a flow whose id/version does not match the run's. */
22
+ export interface FlowMismatchError {
23
+ readonly code: 'flow-mismatch';
24
+ readonly message: string;
25
+ readonly expected: { readonly id: string; readonly version: string };
26
+ readonly got: { readonly id: string; readonly version: string };
27
+ }
28
+
29
+ /** Attempted to resume or step a run that has already completed / failed / cancelled. */
30
+ export interface RunAlreadyTerminalError {
31
+ readonly code: 'run-already-terminal';
32
+ readonly message: string;
33
+ readonly runId: string;
34
+ readonly status: string;
35
+ }
36
+
37
+ /** A node handler threw. The runtime catches, journals, and returns this. */
38
+ export interface NodeExecutionError {
39
+ readonly code: 'node-execution-error';
40
+ readonly message: string;
41
+ readonly runId: string;
42
+ readonly nodeId: string;
43
+ readonly cause: unknown;
44
+ }
45
+
46
+ /** Journal read/write failed at the storage layer. */
47
+ export interface JournalError {
48
+ readonly code: 'journal-error';
49
+ readonly message: string;
50
+ readonly cause: unknown;
51
+ }
52
+
53
+ /** Waitpoint-related error (double-completion, missing token, etc.). */
54
+ export interface WaitpointError {
55
+ readonly code: 'waitpoint-error';
56
+ readonly message: string;
57
+ readonly tokenId: string;
58
+ }
59
+
60
+ /**
61
+ * Handlers are missing for some of a flow's nodes — returned by
62
+ * `runGraph` / `resumeRun`, or by a `HandlerResolver` binding a child flow.
63
+ */
64
+ export interface HandlerMissingError {
65
+ readonly code: 'handler-missing';
66
+ readonly message: string;
67
+ readonly nodeIds: readonly string[];
68
+ }
69
+
70
+ /**
71
+ * The run's scheduling loop reached a state where no node is ready, no
72
+ * edge remains to evaluate, nothing is in flight or suspended, and the
73
+ * flow is not done. Marked failed to prevent an infinite loop.
74
+ */
75
+ export interface StuckRunError {
76
+ readonly code: 'stuck';
77
+ readonly message: string;
78
+ readonly runId: string;
79
+ }
80
+
81
+ /**
82
+ * This executor no longer holds the run's lease: another executor claimed
83
+ * it (after this one's lease expired) and owns the run now. The executor
84
+ * stops without failing or finishing the run; whoever holds the lease
85
+ * decides what happens to it. Returned by `runGraph` / `resumeRun` when a
86
+ * write is refused because the run's lease epoch moved on.
87
+ */
88
+ export interface RunLeaseLostError {
89
+ readonly code: 'run-lease-lost';
90
+ readonly message: string;
91
+ readonly runId: string;
92
+ }