@ai-digital-factory/state-flow-runtime 0.0.0-stage → 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 ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 coupleaddagaming@gmail.com. All rights reserved.
2
+
3
+ State Flow is proprietary software. No license is granted by downloading,
4
+ accessing, or receiving this software or its documentation. All third-party
5
+ use, including personal, noncommercial and commercial use, requires prior
6
+ written permission from the copyright holder. Copying, modifying, distributing,
7
+ sublicensing or incorporating this software into another product also requires
8
+ prior written permission. Request permission at coupleaddagaming@gmail.com.
9
+
10
+ Any permission granted separately is governed by its written terms. This notice
11
+ does not alter the licenses of third-party dependencies or bundled third-party
12
+ materials; their respective licenses continue to apply.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO MERCHANTABILITY, FITNESS FOR A PARTICULAR
16
+ PURPOSE AND NONINFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW,
17
+ THE COPYRIGHT HOLDER IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY
18
+ ARISING FROM THE SOFTWARE OR ITS USE.
package/README.md CHANGED
@@ -1,3 +1,301 @@
1
- # Temporary Holding Version
1
+ # @ai-digital-factory/state-flow-runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Application-owned durable execution with SQLite storage. Requires Node.js 22.13 or later.
4
+
5
+ Import from `@ai-digital-factory/state-flow-runtime`. There is one execution model: callback checkpoints, request deduplication, bounded recovery and manual resolution.
6
+
7
+ Every handlers package compiles for this runtime. No execution opt-in is needed. Each callback declares how it recovers, and `state-flow compile` type-checks it against its generated contract:
8
+
9
+ ```ts
10
+ import { z } from "zod";
11
+
12
+ export const payloadSchema = z.strictObject({ amount: z.number().positive() });
13
+ export type Payload = z.output<typeof payloadSchema>;
14
+
15
+ export const onTransition = {
16
+ run: async (context, payload) => {
17
+ await context.dependencies.payments.charge({
18
+ idempotencyKey: context.operation.operationId,
19
+ amount: payload.amount,
20
+ });
21
+ },
22
+ recovery: {
23
+ strategy: "reconcile",
24
+ reconcile: async (context) =>
25
+ (await context.dependencies.payments.find(context.operation.operationId))
26
+ ? { decision: "completed" }
27
+ : { decision: "safe-to-retry" },
28
+ },
29
+ } satisfies TransitionCallback<TransitionContext<Dependencies>, Payload>;
30
+ ```
31
+
32
+ Strategies: `safe-replay` (running again cannot duplicate an effect), `reconcile` (check the external system and return `completed`, `safe-to-retry`, `pending` or `needs-attention`), and `manual` (stop for an operator on failure).
33
+
34
+ Callbacks read service-supplied implementations through `context.dependencies` (formerly `context.config`). The handlers package owns the dependency interfaces; the service passes implementations to `createFlow(compiled, dependencies)` again after every restart, and State Flow never persists them.
35
+
36
+ ## Transition destinations and FinalState
37
+
38
+ A Transition declares its destinations with `<To>` children (ADR 0087). With one destination, `onTransition` is optional and returns nothing. With several, `onTransition` is required and returns one of the authored destination names; the generated `TransitionResult` type is that exact union:
39
+
40
+ ```ts
41
+ import type {
42
+ TransitionContext,
43
+ TransitionResult, // "placed" | "rejected"
44
+ } from "@scope/flows/place-order.submit-order";
45
+
46
+ export const onTransition = {
47
+ run: async (context, payload) => {
48
+ const decision = await context.dependencies.broker.submit({
49
+ operationId: context.operation.operationId,
50
+ ...payload,
51
+ });
52
+ return decision === "accepted" ? "placed" : "rejected";
53
+ },
54
+ recovery: {
55
+ strategy: "reconcile",
56
+ reconcile: async (context) => {
57
+ const decision = await context.dependencies.broker.lookup(
58
+ context.operation.operationId,
59
+ );
60
+ return decision === null
61
+ ? { decision: "safe-to-retry" }
62
+ : {
63
+ decision: "completed",
64
+ result: decision === "accepted" ? "placed" : "rejected",
65
+ };
66
+ },
67
+ },
68
+ } satisfies TransitionCallback<
69
+ TransitionContext<Dependencies>,
70
+ Payload,
71
+ TransitionResult
72
+ >;
73
+ ```
74
+
75
+ - Guards stay boolean admission checks. Guard and `onTransition` contexts expose the allowed `context.destinations`; there is no `toState`, because a branching destination is unknown until the operation returns.
76
+ - The lifecycle is unchanged: guard, source `onExit`, `onTransition`, commit the destination, destination `onEnter`. A false guard stops before `onExit`.
77
+ - The selected destination is checkpointed with the callback's completion before the state commits, so a restart resumes the same choice without running the callback again.
78
+ - Throwing, returning nothing or returning an undeclared destination is a failure handled by the declared recovery strategy; it never selects a branch implicitly. Returning an authored rejection destination is a successful business result.
79
+ - An operator confirms a branching operation with its destination: `recoveryTarget(work).confirm(reason, "rejected")`.
80
+ - While a transition runs, the snapshot keeps the source `state` and reports `status: { kind: "running", transition, requestId }` for UI progress.
81
+ - `applied` dispatch results carry `destination`: the state that request's execution recorded. Duplicates report the original destination even after the instance has moved on; use `snapshot` for the current condition.
82
+
83
+ A `<FinalState>` has no callbacks and no outgoing transitions. Committing it also sets `status.kind` to `completed`, atomically. Completed is distinct from operational termination and says nothing about business success. A new request against a completed instance resolves to the `completed` outcome without running callbacks; duplicates of applied requests still return their recorded results.
84
+
85
+ A service registers each flow once, ensures instances by ID, and maps every dispatch outcome at compile time (ADR 0090):
86
+
87
+ ```ts
88
+ import visitorAccess, {
89
+ type Dependencies,
90
+ } from "@scope/handlers/visitor-access";
91
+ import {
92
+ createRuntime,
93
+ instanceId,
94
+ requestId,
95
+ } from "@ai-digital-factory/state-flow-runtime";
96
+
97
+ await using runtime = createRuntime({
98
+ databasePath: "state-flow.db",
99
+ shutdownTimeoutMs: 10_000,
100
+ });
101
+ // Rejects with DefinitionError for an invalid module.
102
+ const flow = await runtime.createFlow(visitorAccess, dependencies);
103
+
104
+ // Opens the instance, or creates it once. Rejects with DefinitionMismatchError when the stored
105
+ // instance belongs to another definition or has unresolved work from other handler code.
106
+ const instance = await flow.ensureInstance({
107
+ instanceId: instanceId("visitor-1"),
108
+ });
109
+
110
+ // One callback per outcome; adding an outcome is a compile error until every map handles it.
111
+ // Build the dispatcher once per state and reuse it.
112
+ const fromGuest = instance.when("guest").handle({
113
+ applied: ({ duplicate, destination }) => ({
114
+ ok: true,
115
+ destination,
116
+ duplicate,
117
+ }),
118
+ blocked: () => ({ ok: false, reason: "blocked" }),
119
+ "invalid-payload": ({ issues }) => ({ ok: false, reason: "invalid", issues }),
120
+ busy: () => ({ ok: false, reason: "busy" }),
121
+ "recovery-pending": () => ({ ok: false, reason: "recovering" }),
122
+ "needs-attention": ({ reason }) => ({ ok: false, reason }),
123
+ "state-mismatch": ({ actualState }) => ({ ok: false, reason: actualState }),
124
+ "request-conflict": () => ({ ok: false, reason: "conflict" }),
125
+ terminated: ({ reason }) => ({ ok: false, reason }),
126
+ completed: () => ({ ok: false, reason: "completed" }),
127
+ });
128
+ const result = await fromGuest.dispatch({
129
+ requestId: requestId("unlock-1"),
130
+ transition: "unlock-succeeds",
131
+ payload: { operatorSessionCookie: "…" },
132
+ });
133
+
134
+ // Runtime cleanup is awaited automatically when this scope exits.
135
+ ```
136
+
137
+ `ensureInstance` is the only way to acquire an instance. A service that must not create one (for example, a webhook for an order that should already exist) checks first: `runtime.inspector.getSnapshot(instanceId)` returns `null` for an unknown ID. Programmer and infrastructure errors (an unknown transition, a closed runtime, a failing disk) reject the dispatch promise; they are not outcomes.
138
+
139
+ - **One owner per database.** A second runtime on the same file throws `OwnershipError`; a crashed process releases the lock.
140
+ - **Recovery** runs in the background with at most 4 attempts per callback (waits of 1 s, 5 s, 30 s), then marks the instance needs-attention. Budgets survive restarts. Opening an instance resumes interrupted work.
141
+ - **Observation:** `getSnapshot()`, `subscribe()` and paged `history()` per instance; `runtime.inspector` lists instances; authorized `runtime.controls` resolves work across them for operator tools such as the durable companion in `@ai-digital-factory/state-flow-observe`.
142
+ - **Manual recovery:** `instance.handleResolve({ accepted, rejected }).resolve({ action: "retry" | "confirm-completed" | "terminate", operationId, expectedAttempts, reason, … })`, or `runtime.controls.resolve(instanceId, …)` for operator tooling, taking `operationId` and `attempts` from the snapshot's unresolved work. An action based on an older attempt is rejected as `stale`. Your application authorizes the caller; `parseResolveInput` validates input from untrusted sources.
143
+ - **Handler changes:** unresolved work started under different handler code (handlers, local helpers, `dependencies.ts`, declared dependencies) is not resumed (`definition-mismatch` with `reason: "handler-revision"`). Finish it with the previous build, or terminate it with `runtime.controls.resolve(id, { action: "terminate", … })`, which runs no handler.
144
+ - **Stalls:** if an infrastructure error stops execution, `snapshot.stall` shows it; fix the cause and call `instance.handleRestart({ restarted, "not-restarted" }).restart()`.
145
+ - **Shutdown:** `close({ timeoutMs })` rejects with `ShutdownTimeoutError` if callbacks ignore their `AbortSignal`; the database stays owned until they settle or the process exits.
146
+
147
+ See ADR 0075 for the exact rules and what is not covered yet.
148
+
149
+ ## Composition: nested scopes (ADR 0095, 0096)
150
+
151
+ A Flow that places other Flows needs them registered first, each with its own dependencies:
152
+
153
+ ```ts
154
+ await runtime.createFlow(fileUpload, uploadDependencies);
155
+ await runtime.createFlow(placeOrder, orderDependencies);
156
+ const workspaces = await runtime.createFlow(
157
+ workspaceSession,
158
+ workspaceDependencies,
159
+ );
160
+ const session = await workspaces.ensureInstance({
161
+ instanceId: instanceId("user-1"),
162
+ });
163
+
164
+ const signedIn = session.scope("signed-in"); // compound State: its name
165
+ const upload = signedIn.scope("uploading", "report.pdf"); // FlowList member by key
166
+ const order = signedIn.scope("order-desk:order"); // parallel region: state:region
167
+
168
+ await upload
169
+ .when("queued")
170
+ .handle({
171
+ ...outcomes,
172
+ interrupted: ({ duplicate }) =>
173
+ "a parent Transition interrupted this request",
174
+ inactive: () => "no active upload with that key",
175
+ })
176
+ .dispatch({
177
+ requestId: requestId("send-1"),
178
+ transition: "send-file",
179
+ payload: null,
180
+ });
181
+ ```
182
+
183
+ - Each nested activation is a durable scope with its own State, executions and recovery; regions and members progress independently. `session.getSnapshot().scopes` lists active scopes and exited ones that still retain unresolved work.
184
+ - Scope handles resolve the current activation on every call. Their outcomes add `interrupted` (a parent `interrupt` fenced this execution) and `inactive` (no activation at that path). A request ID belongs to its original activation.
185
+ - `childWork="block"` parent Transitions answer `busy` while descendant work is running or unresolved; nothing queues. While a parent execution runs, its descendants answer `busy`.
186
+ - `childWork="interrupt"` aborts the descendants' `context.signal` and proceeds. Interrupted work never advances the graph and is never replayed: a failure is reconciled (`safe-to-retry` then needs attention), and `safe-replay` or `manual` work needs attention. Retry runs only a reconcile check, Confirm it completed records the outcome, and Terminate ends the root and every activation. Unresolved interrupted work blocks `handover()` and `checkDeployment()`.
187
+ - Late outcomes (a late result, a post-exit reconciliation or a confirmation) stay in `runtime.inspector.lateOutcomes()` and arrive through `subscribeLateOutcomes()` until `runtime.controls.acknowledgeLateOutcome(sequence)`, across restarts.
188
+ - Manual resolution takes the root instance ID and the operation ID of work at any depth. A root's handler revision covers every placed Flow.
189
+ - Databases from 0.1.0 upgrade in place to schema version 3; flat Flows keep their definition identity.
190
+
191
+ ## Schema contracts
192
+
193
+ Every durable transition module exports a Zod `payloadSchema`; use `z.null()` for an explicitly empty payload. Compile derives the service payload type from the schema and rejects missing declarations or mismatched handlers. The compiled module carries the schema so State Flow validates dynamic input before running a handler or reserving a request ID. Consumers must handle `invalid-payload` alongside every other dispatch outcome. Both invalid-payload and request-conflict contexts expose the rejected payload as unknown.
194
+
195
+ Use validation-only schemas. Async refinements are supported; State Flow rechecks admission and deduplication after validation yields. Schema output must preserve the JSON input; coercion, defaults, transformations and silently stripped fields are rejected when they change that input. A duplicate request is matched before validating it against a changed payload schema, preserving recorded outcomes across compatible handler deployments. Consequently, `applied` and `blocked` expose `input.payload` as unknown when `duplicate` is true; historic data must not be assumed to match the current schema. Schema refinements should be side-effect free; external I/O belongs in checkpointed handler callbacks. Domain-specific refinements belong in the handlers package; State Flow performs validation and maps its errors. Zod does not replace graph membership, freshness, authorization, or safe-replay decisions.
196
+
197
+ ## Scoped runtime lifetime
198
+
199
+ ```ts
200
+ import { withRuntime } from "@ai-digital-factory/state-flow-runtime";
201
+
202
+ await withRuntime(
203
+ { databasePath: "state-flow.db", shutdownTimeoutMs: 5000 },
204
+ async (runtime) => {
205
+ const flow = await runtime.createFlow(compiled, dependencies);
206
+ // Ensure instances and dispatch within this managed scope.
207
+ },
208
+ );
209
+ ```
210
+
211
+ The helper awaits application work and closes the runtime in `finally`, including when startup or response mapping fails. If work and shutdown both fail, an `AggregateError` preserves both failures. Applications decide their lifetime and signal handling; the library does not install global process handlers. Shutdown timeout retains ownership until active work settles.
212
+
213
+ ## Typed recovery and operator capabilities
214
+
215
+ ```ts
216
+ const snapshot = instance.getSnapshot();
217
+ if (snapshot.status.kind === "needs-attention") {
218
+ const target = recoveryTarget(snapshot.status.work);
219
+ const resolver = instance.handleResolve({
220
+ accepted: ({ snapshot }) => snapshot,
221
+ rejected: ({ reason }) => {
222
+ throw new Error(reason);
223
+ },
224
+ });
225
+ await resolver.resolve(target.retry("external dependency restored"));
226
+ }
227
+ ```
228
+
229
+ Import `recoveryTarget` from the durable entrypoint. It captures the operation ID and attempt count and exposes callback-specific confirmation input: boolean for a guard, an authored state for start-state resolution, the selected destination for a branching `onTransition`, and only a reason for void callbacks. Captured targets remain stale after later attempts; they never silently retarget refreshed work. `handleRestart({ restarted, "not-restarted" }).restart()` requires both explicit restart outcomes.
230
+
231
+ `runtime.inspector` exposes only listing, snapshot/history reads and subscriptions. `runtime.controls` is the explicit privileged capability used by authorized operator tooling, including companion recovery. Instances expose recovery only through these exhaustive interfaces; the raw primitives live on `runtime.controls`. Application authorization remains mandatory before forwarding untrusted actions.
232
+
233
+ Transient SQLite busy/locked infrastructure errors receive at most three automatic restart attempts (1 s, 5 s, 30 s). Other infrastructure failures remain visible stalls requiring explicit intervention. These in-memory infrastructure retries neither reset durable callback budgets nor bypass a callback's declared recovery strategy.
234
+
235
+ ## Deployment gate
236
+
237
+ Unfinished execution never resumes under different handler code (ADR 0076), so deploying over it would strand it. Run the deployment gate before a new build replaces the old one (ADR 0091, 0094). Instances that are **Running**, **Recovering** or **Needs attention** block a deployment; **Ready** instances waiting for their next event, **Completed** and **Terminated** instances do not. These are the durable companion's status filters: an empty result for those three filters is what the gate checks, but only the gate's check is protected against new work starting.
238
+
239
+ The running application performs the gate with `runtime.handover()`:
240
+
241
+ ```ts
242
+ // Called by the deployment system, for example through an authenticated admin endpoint.
243
+ app.post("/admin/deployment/handover", async (_request, response) => {
244
+ try {
245
+ const gate = await runtime.handover({ timeoutMs: 10_000 });
246
+ switch (gate.outcome) {
247
+ case "blocked":
248
+ // Nothing changed: this build keeps running so the work can finish or be resolved.
249
+ return response.status(409).json({ blockers: gate.blockers });
250
+ case "handed-over":
251
+ // The runtime is closed and the database released; stop this process now.
252
+ response.status(200).json({ outcome: "handed-over" });
253
+ setImmediate(() => process.exit(0));
254
+ return;
255
+ }
256
+ } catch (error) {
257
+ // A failed check or a timed-out shutdown is never permission to deploy.
258
+ response.status(503).json({ error: String(error) });
259
+ if (error instanceof ShutdownTimeoutError) {
260
+ // The runtime admits no more work but still owns the database: exit so the supervisor
261
+ // restarts this build, which recovers the interrupted work.
262
+ setImmediate(() => process.exit(1));
263
+ }
264
+ }
265
+ });
266
+ ```
267
+
268
+ ```bash
269
+ # Deployment script: deploy only after a 200 response.
270
+ curl --fail -X POST https://app.internal/admin/deployment/handover || exit 1
271
+ start-new-build || start-old-build # an abandoned deployment restarts the old build
272
+ ```
273
+
274
+ - `blocked` lists every blocking instance (ID, flow, state, status, reason) read from storage, including instances this process has not opened and beyond any listing page. It changes nothing: no waiting, termination or budget reset.
275
+ - When nothing blocks, `handover` stops admission and starts closing in the same step, so no dispatch, creation, recovery or manual action can start between the check and the handover. Later calls reject with `RuntimeClosedError`. It resolves `handed-over` once the database is released. If the deployment is then abandoned, start the old build again; every remaining instance is Ready, Completed or Terminated, so nothing needs repair.
276
+ - It rejects when the check fails, and with `ShutdownTimeoutError` when the close times out (the database stays owned until the process exits). Neither permits a deployment. `createRuntime`'s `shutdownTimeoutMs` applies when `timeoutMs` is omitted.
277
+
278
+ When no runtime owns the database, for example after a crash or a manual stop, use `checkDeployment({ databasePath })` or `state-flow deployment-check --database <file>`. It returns the same `ready` / `blocked` result and blockers. While a runtime owns the database it returns `owned`, never readiness; a missing file or one that is not a State Flow database throws. It never creates or migrates the database or changes its data (opening it can roll back a journal a crashed process left behind, as any open does). A new build can run it at startup and refuse to accept work unless it is `ready`.
279
+
280
+ There is no bypass. If a handler defect keeps an instance in Needs attention, the deployment that fixes the defect is blocked too: Terminate that instance with a reason through `runtime.controls` (or the companion's Terminate action), then deploy. Retry and Confirm it completed also resolve blockers, under the handler revision that started the work.
281
+
282
+ The deployment system must call the gate; the library cannot stop a process from being replaced without it. Passing the gate does not waive definition checks: a Ready instance still opens only under a compatible Flow definition.
283
+
284
+ ### Restarts and their limits
285
+
286
+ After a restart, callbacks durably recorded as completed are never run again and their results are reused; a callback that had started but not completed recovers by its declared strategy (reconcile, safe replay, or manual attention) under the same operation ID, with the interrupted run counted against its attempt budget; callbacks that had not started run normally (ADR 0093). State Flow does not provide exactly-once external effects: an effect can complete before its callback's completion is recorded, which is what reconciliation is for. It does not infer business expiry from a restart or a delay; validity checks belong in handlers. Terminating an instance stops its execution and keeps its history, but does not enter a business State or cancel external work already started.
287
+
288
+ ## Consumer linting
289
+
290
+ Use `serviceConfig` or `handlersConfig` from the corresponding `@ai-digital-factory/eslint-plugin-state-flow/eslint.*.config.js` entrypoint and run lint in CI alongside compile and typecheck. These type-aware configurations enforce promise observation, require automatic runtime ownership through await using or withRuntime for services, prevent legacy execution bypasses, and keep runtime construction out of handlers. Explicit framework/operator lifecycle adapters may override the constructor restriction. Prepared dispatchers and unrelated transport methods are permitted. Fire-and-forget work requires explicit rejection handling; `void` alone does not satisfy the lint rule.
291
+
292
+ `flow.ensureInstance({ instanceId })` opens an existing instance or reserves and creates it once, including for overlapping callers, and starts interrupted recovery. Its snapshot may be running, recovering, awaiting attention, terminated or completed; acquiring an instance does not imply it is ready. Pending recovery continues independently while the runtime remains open, and dispatch does not retry caller requests or wait for background recovery.
293
+
294
+ `createRuntime({ databasePath, shutdownTimeoutMs })` creates a standalone runtime synchronously. Use `await using runtime = createRuntime(...)` to call operations sequentially and automatically await shutdown on normal completion or exceptions. TypeScript projects should include `ESNext.Disposable` in `compilerOptions.lib`. The configured shutdown timeout applies to disposal and to `runtime.close()`; explicit `close({ timeoutMs })` overrides it. `withRuntime` remains the callback-style alternative. Applications with an existing framework lifecycle can explicitly close the runtime, with a deliberate override of the managed-runtime lint rule.
295
+
296
+ `runtime.createFlow(compiled, dependencies)` validates the module, including every handler's recovery declaration, and registers it before any handler runs. It rejects with `DefinitionError` for an invalid module and `InputError` for a duplicate registration; a failed registration does not reserve the flow name.
297
+
298
+ ## License
299
+
300
+ Proprietary software. All third-party use requires prior written permission from
301
+ coupleaddagaming@gmail.com. See LICENSE.