@statewalker/fsm 0.36.0 → 0.38.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/README.md CHANGED
@@ -1 +1,258 @@
1
1
  # @statewalker/fsm: Hierarchical Finite State Machine
2
+
3
+ A tiny, zero-dependency **hierarchical finite state machine (HFSM)** for TypeScript.
4
+ Declare a tree of nested states and event-driven transitions, attach behaviour to
5
+ each state, and drive the machine with events. The entire machine — its stack of
6
+ active states *and* the per-state data you record — can be dumped to a plain object
7
+ and restored, so a running process can be paused, persisted, and resumed later.
8
+
9
+ ## Why it exists
10
+
11
+ Event-driven control flow — UI wizards, agent reasoning loops, connection/session
12
+ lifecycles, long-running workflows — is painful to express with ad-hoc booleans and
13
+ callbacks. It degrades into "am I in this phase yet? did that step already run? what
14
+ must unwind on cancel?".
15
+
16
+ This package lets you write that flow **declaratively** as a nested state tree and
17
+ attach behaviour per state. The engine owns the hard parts:
18
+
19
+ - **descending** into sub-states (entering a composite state auto-enters its initial child),
20
+ - **bubbling** an unhandled event up to the parent state,
21
+ - running **enter/exit hooks** in the correct order (exit unwinds inner-to-outer),
22
+ - **serializing** the whole machine for pause/resume.
23
+
24
+ It is deliberately small (a single bundled entry, **zero runtime dependencies**) so it
25
+ can sit at the core of larger systems without pulling weight.
26
+
27
+ There are **two layers — pick your altitude**:
28
+
29
+ - **Engine** (`FsmProcess` + `FsmState`) — the low-level machine you drive by hand with
30
+ `dispatch(event)` and lifecycle hooks. Use it when you want full control.
31
+ - **Runner** (`startProcess`) — an ergonomic wrapper that attaches per-state handlers via
32
+ a single `load` callback and binds the machine into a shared context object. **Most
33
+ consumers use this.**
34
+
35
+ ## Mental model
36
+
37
+ A `FsmProcess` keeps a **stack** of active states from the root down to the current leaf.
38
+ Dispatching an event resolves a transition, unwinds the states that exit, and enters the
39
+ states that become active — always leaving the stack resting on a leaf:
40
+
41
+ ```
42
+ config (declarative) runtime stack (one FsmProcess)
43
+ Player Player ← root, always active
44
+ ├─ Idle └─ Active ← composite, entered on "play"
45
+ └─ Active (composite) └─ Playing ← leaf, the "current" state
46
+ ├─ Playing
47
+ └─ Paused dispatch("pause"): exit Playing → enter Paused (inside Active)
48
+ dispatch("stop") : neither Playing nor Active has a "stop" rule,
49
+ so it bubbles to Player's "*"→Idle, unwinding
50
+ Playing then Active
51
+ ```
52
+
53
+ Transitions are `[from, event, to]` tuples. `""` means *initial* (as `from`) or *final*
54
+ (as `to`); `"*"` is a wildcard matching *any* state or *any* event. When the current leaf
55
+ has no matching rule, the lookup walks up the parent chain — that is how an event handled
56
+ only by an outer state still fires from deep inside.
57
+
58
+ ## How to use
59
+
60
+ This package is consumed inside the workspace via `"@statewalker/fsm": "workspace:*"`.
61
+ Everything is exported from the package root:
62
+
63
+ ```typescript
64
+ import { startProcess, FsmProcess, type FsmStateConfig } from "@statewalker/fsm";
65
+ ```
66
+
67
+ ### 1. Declare the machine — `FsmStateConfig`
68
+
69
+ ```typescript
70
+ const config: FsmStateConfig = {
71
+ key: "Player",
72
+ transitions: [
73
+ ["", "", "Idle"], // initial → Idle (eventless start)
74
+ ["Idle", "play", "Active"], // play → Active (which enters its initial child)
75
+ ["*", "stop", "Idle"], // from ANY state, "stop" → Idle
76
+ ],
77
+ states: [
78
+ { key: "Idle" },
79
+ {
80
+ // A composite state: entering it descends into its initial child. The
81
+ // initial rule uses "*" (any event) so descent works whatever event caused
82
+ // the entry — here, "play".
83
+ key: "Active",
84
+ transitions: [
85
+ ["", "*", "Playing"], // initial child of Active
86
+ ["Playing", "pause", "Paused"],
87
+ ["Paused", "play", "Playing"],
88
+ ],
89
+ states: [{ key: "Playing" }, { key: "Paused" }],
90
+ },
91
+ ],
92
+ };
93
+ ```
94
+
95
+ ### 2. Run it — `startProcess`
96
+
97
+ Pass a `load(stateKey, event)` callback that returns the handlers for each state. A handler
98
+ runs on enter; what it *returns* wires up the rest:
99
+
100
+ ```typescript
101
+ const context: Record<string, unknown> = {};
102
+ await startProcess(context, config, (stateKey) => {
103
+ if (stateKey === "Playing") {
104
+ return [(ctx) => {
105
+ console.log("▶ playing");
106
+ return () => console.log("⏸ left Playing"); // returned fn → onExit cleanup
107
+ }];
108
+ }
109
+ return [];
110
+ });
111
+
112
+ // startProcess binds control functions into `context`:
113
+ const dispatch = context["fsm:dispatch"] as (e: string) => Promise<void>;
114
+ await dispatch("play"); // Idle → Active → Playing → logs "▶ playing"
115
+ await dispatch("pause"); // Playing → Paused → logs "⏸ left Playing"
116
+ ```
117
+
118
+ A handler may instead return an **async generator** — each string it yields is dispatched
119
+ back into the machine, which is how a state drives its own timed/reactive transitions:
120
+
121
+ ```typescript
122
+ // while in "Playing", emit a "pause" event after 1s:
123
+ return [async function* (ctx) {
124
+ await new Promise((r) => setTimeout(r, 1000));
125
+ yield "pause";
126
+ }];
127
+ ```
128
+
129
+ ### 3. Or drive the engine directly — `FsmProcess`
130
+
131
+ ```typescript
132
+ const process = new FsmProcess(config);
133
+ process.onStateCreate((state) => {
134
+ state.onEnter(() => console.log("→", state.key));
135
+ state.onExit(() => console.log("←", state.key)); // runs inner-to-outer
136
+ });
137
+ await process.dispatch(""); // enter the initial state (Player → Idle)
138
+ await process.dispatch("play"); // Idle → Active → Playing
139
+ ```
140
+
141
+ ### 4. Pause & resume — `dump` / `restore`
142
+
143
+ ```typescript
144
+ const snapshot = await process.dump(); // plain JSON-safe object
145
+ // ...persist it, ship it elsewhere, then on a fresh process:
146
+ await new FsmProcess(config).restore(snapshot);
147
+ ```
148
+
149
+ To record your own per-state data in the snapshot, register `dump`/`restore` hooks on the
150
+ state (they receive a mutable `data` bag):
151
+
152
+ ```typescript
153
+ state.dump((s, data) => { data.scrollTop = readScroll(); });
154
+ state.restore((s, data) => { applyScroll(data.scrollTop); });
155
+ ```
156
+
157
+ ### 5. Observe — printer & tracer
158
+
159
+ ```typescript
160
+ import { setProcessPrinter, setProcessTracer } from "@statewalker/fsm";
161
+
162
+ setProcessPrinter(process, { prefix: "[player]", lineNumbers: true });
163
+ setProcessTracer(process); // logs <Playing event="play"> … </Playing> <!-- event="pause" --> per state
164
+ ```
165
+
166
+ ## API reference — what each export is for
167
+
168
+ **Configuration (the declarative vocabulary)**
169
+
170
+ - **`FsmStateConfig`** — one plain-object shape to declare an entire nested machine, so configs
171
+ stay serializable, diffable, and toolable: a `key`, a list of `[from, event, to]` transition
172
+ tuples, and optional nested `states`.
173
+ - **`STATE_INITIAL` / `STATE_FINAL` (`""`), `STATE_ANY` / `EVENT_ANY` (`"*"`), `EVENT_EMPTY` (`""`)**
174
+ — named sentinels that make the meaning of the empty string and the wildcard explicit at the call
175
+ site (`["", "start", "Active"]` reads as "from *initial*"). The runtime values are shared; the
176
+ names document intent.
177
+
178
+ **Engine**
179
+
180
+ - **`FsmProcess`** — the running machine: it owns the active-state stack and the traversal algorithm.
181
+ `dispatch(event)` advances the machine and resolves to a leaf; `shutdown(event?)` unwinds every
182
+ active state; `state` is the current leaf; `onStateCreate(handler)` is the primary extension point
183
+ (fires once per state instance); `dump()` / `restore(data)` serialize and rehydrate it.
184
+ - **`FsmState`** — a live node in the stack you hang behaviour on. It carries the `onEnter` / `onExit`
185
+ (exit runs in reverse-registration, inner-to-outer) / `onStateError` lifecycle hooks and the `dump`
186
+ / `restore` hooks for per-state data; `key` and `parent` locate it in the tree.
187
+ - **`FsmStateDescriptor`** — the *compiled* form of a config subtree, since resolving a transition at
188
+ runtime must be cheap: an indexed transition table with wildcard-fallback lookup
189
+ (`getTargetStateKey`). You rarely touch it directly; `FsmProcess` builds it for you.
190
+ - **`getStateTransitions(state)` / `isStateTransitionEnabled(process, event)`** — read-only queries
191
+ over the graph for UIs, viewers, and dispatch guards ("which events are available here? will this
192
+ event do anything?"). The first lists the currently-reachable `[from, event, to]` transitions
193
+ (walking up the parent chain, nearest wins); the second is the boolean guard used internally before
194
+ dispatching.
195
+ - **`FsmBaseClass` / `bindMethods`** — the shared handler-registry substrate under both `FsmProcess`
196
+ and `FsmState` (add/run/remove typed handler lists, sequential with error routing). Mostly internal;
197
+ exported for subclassing.
198
+
199
+ **Runner**
200
+
201
+ - **`startProcess` / `startFsmProcess`** — wiring `onStateCreate` + `onEnter` + a loader by hand is
202
+ repetitive, so the runner does it once and binds the machine into a shared `context`: it creates the
203
+ process, on each state entry calls `load(stateKey, event)` and installs the returned `StageHandler`s,
204
+ and returns a `ProcessHandle`. `startFsmProcess` is a permanent equal alias.
205
+ - **`StageHandler`** — the contract for per-state behaviour, with its return type doing double duty: a
206
+ function of `context` returning `void` (nothing), a cleanup `function` (→ `onExit`), or an async/sync
207
+ generator (its yielded strings are dispatched as events).
208
+ - **`ProcessHandle`** — the caller's remote control after `startProcess` returns: `shutdown()`,
209
+ `dump(...)`, `restore(dump, ...)`.
210
+ - **`KEY_DISPATCH` / `KEY_TERMINATE` / `KEY_STATES` / `KEY_EVENT`** — the context keys under which
211
+ `startProcess` binds the dispatch fn, terminate fn, current state-stack, and last event, so handlers
212
+ reach the machine through the shared context rather than closures over the process.
213
+
214
+ **Debug / observability**
215
+
216
+ - **`setProcessPrinter` / `getProcessPrinter` / `getPrinter` / `preparePrinter` (+ `Printer`,
217
+ `PrinterConfig`)** — readable, hierarchy-indented logging you can attach without touching handler
218
+ code: build or attach a `Printer` that prefixes each line with the current nesting depth (and
219
+ optional line numbers).
220
+ - **`setProcessTracer` / `setStateTracer`** — see the machine's motion as a stream of enter/exit
221
+ events: emit `<state event="…">` on enter and `</state>` on exit, for a whole process or a single
222
+ state.
223
+
224
+ ## Internals
225
+
226
+ - **Traversal & the `status` bitmask.** `dispatch` pumps an enter/exit cycle over the state stack until
227
+ it rests on a leaf (`STATUS_LEAF`) or the machine finishes (`STATUS_FINISHED`). The `STATUS_*` bits
228
+ encode where in that cycle the process is: `FIRST`/`NEXT` while entering (descending to a first child
229
+ vs. advancing to a target), `LEAF` when settled, `LAST` when popping to a parent. Dispatching while
230
+ the machine is already running just queues the next event (`nextEvent`) and returns — runs never
231
+ re-enter.
232
+ - **Transition resolution order.** For `(state, event)` the descriptor tries, in order:
233
+ `(state, event)` → `(*, event)` → `(state, *)` → `(*, *)`, then falls back to `STATE_FINAL`. Unhandled
234
+ events bubble up the parent chain, so an outer state can catch events its children ignore.
235
+ - **Generator handlers.** A handler returning an async generator runs concurrently; its yielded events
236
+ feed back through `dispatch`, and the generator is `return()`-ed automatically when its state exits —
237
+ this is the mechanism for self-driving/timed states.
238
+ - **Serialization.** `dump()` walks root→leaf producing `{ status, event, stack: [{ key, data }] }`;
239
+ `restore()` rebuilds the stack and replays each state's `restore` hooks. Only what you record via
240
+ `state.dump(...)` is persisted — the engine keeps snapshots minimal.
241
+ - **Dependencies.** None. Zero runtime dependencies by design.
242
+
243
+ ## License
244
+
245
+ MIT.
246
+
247
+ ## Migration from pre-0.35
248
+
249
+ Removed in 0.35:
250
+ - `FsmBaseClass.data`, `.setData()`, `.getData()` — use closures or context instead
251
+ - `FsmState.getData(key, recursive)`, `.useData(key)` — use closures
252
+ - `FsmBaseClass._runHandlerParallel()` — handlers run sequentially now
253
+ - `newFsmProcess()` — use `startProcess()`
254
+ - `utils/handlers.ts` (`addSubstateHandlers`, `callStateHandlers`) — pass a `load` callback to `startProcess()`
255
+ - `utils/process.ts` — use `startProcess()` directly
256
+
257
+ Removed in 0.38 (see `CHANGELOG.md`): the unused orchestration layer (`launcher`,
258
+ `createHandlerRegistry`, the `fsm` CLI). Use `startProcess` + your own `load` callback.