@statewalker/fsm 0.37.0 → 0.38.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/README.md CHANGED
@@ -1,106 +1,248 @@
1
1
  # @statewalker/fsm: Hierarchical Finite State Machine
2
2
 
3
- Class-based HFSM with nested states, event-driven transitions, lifecycle hooks, and dump/restore serialization.
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.
4
8
 
5
- ## Core Classes
9
+ ## Why it exists
6
10
 
7
- ### FsmStateConfig
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?".
8
15
 
9
- Declarative state tree definition:
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`
10
68
 
11
69
  ```typescript
12
70
  const config: FsmStateConfig = {
13
- key: "Main",
71
+ key: "Player",
14
72
  transitions: [
15
- ["", "start", "Active"], // initial → Active
16
- ["Active", "done", ""], // Activefinal
17
- ["*", "reset", "Active"], // anyActive
73
+ ["", "", "Idle"], // initial → Idle (eventless start)
74
+ ["Idle", "play", "Active"], // playActive (which enters its initial child)
75
+ ["*", "stop", "Idle"], // from ANY state, "stop" Idle
18
76
  ],
19
77
  states: [
20
- { key: "Active", states: [
21
- { key: "Step1" },
22
- { key: "Step2" },
23
- ], transitions: [
24
- ["", "", "Step1"], // initial → Step1 (eventless)
25
- ["Step1", "next", "Step2"],
26
- ]},
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
+ },
27
91
  ],
28
92
  };
29
93
  ```
30
94
 
31
- ### FsmProcess
95
+ ### 2. Run it — `startProcess`
32
96
 
33
- Runtime state machine. Maintains a state stack (root ... leaf), dispatches events, manages lifecycle.
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:
34
99
 
35
- - `dispatch(event)` — trigger a transition
36
- - `shutdown()` exit all states gracefully
37
- - `state` current leaf state
38
- - `status` bitmask tracking enter/exit cycle
39
- - `onStateCreate(handler)` called for every new state (primary extension point)
40
- - `dump()` / `restore(data)` — serialization hooks
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
+ });
41
111
 
42
- ### FsmState
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
+ ```
43
117
 
44
- Individual node in the state hierarchy.
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:
45
120
 
46
- - `key` — state name
47
- - `parent` parent state
48
- - `onEnter(handler)` — run when entering
49
- - `onExit(handler)` run when exiting (reverse order)
50
- - `onStateError(handler)` — error handling
51
- - `dump()` / `restore(data)` — per-state serialization
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
+ ```
52
128
 
53
- ## Orchestrator
129
+ ### 3. Or drive the engine directly — `FsmProcess`
54
130
 
55
- ### startProcess(context, config, load, startEvent?)
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
+ ```
56
140
 
57
- High-level entry point: creates FsmProcess, wires handlers, binds FSM into context.
141
+ ### 4. Pause & resume `dump` / `restore`
58
142
 
59
- Context keys bound:
60
- - `fsm:dispatch` dispatch function
61
- - `fsm:terminate` shutdown function
62
- - `fsm:states` — current state stack
63
- - `fsm:event` — last event
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
+ ```
64
148
 
65
- `load(stateKey)` returns handler(s) for each state. Handlers can return:
66
- - `void` — no cleanup
67
- - `Function` — registered as onExit cleanup
68
- - `AsyncGenerator` — yielded events are dispatched to FSM
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):
69
151
 
70
- ### HandlerRegistry
152
+ ```typescript
153
+ state.dump((s, data) => { data.scrollTop = readScroll(); });
154
+ state.restore((s, data) => { applyScroll(data.scrollTop); });
155
+ ```
71
156
 
72
- Convention-based handler discovery via `createHandlerRegistry()`:
157
+ ### 5. Observe printer & tracer
73
158
 
74
159
  ```typescript
75
- const registry = createHandlerRegistry();
76
- registry.addConfig("MyProcess", config);
77
- registry.addHandlers("MyProcess", { "Active": activeHandler, "Step1": step1Handler });
78
- const load = registry.getLoader("MyProcess");
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
79
164
  ```
80
165
 
81
- ### launcher(config)
166
+ ## API reference — what each export is for
82
167
 
83
- Multi-process launcher with strict types:
168
+ **Configuration (the declarative vocabulary)**
84
169
 
85
- ```typescript
86
- interface LauncherConfig {
87
- processes: ProcessDef[];
88
- start?: string[];
89
- context?: (parent: Record<string, unknown>) => Record<string, unknown>;
90
- }
91
-
92
- interface ProcessDef {
93
- name: string;
94
- config: FsmStateConfig;
95
- handlers?: (StageHandler | Record<string, StageHandler | StageHandler[]>)[];
96
- start?: boolean;
97
- }
98
- ```
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.
99
177
 
100
- ## Utilities
178
+ **Engine**
101
179
 
102
- - `printer(process)`log state transitions to console
103
- - `tracer(process)` collect transition trace for testing
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.
104
246
 
105
247
  ## Migration from pre-0.35
106
248
 
@@ -108,6 +250,9 @@ Removed in 0.35:
108
250
  - `FsmBaseClass.data`, `.setData()`, `.getData()` — use closures or context instead
109
251
  - `FsmState.getData(key, recursive)`, `.useData(key)` — use closures
110
252
  - `FsmBaseClass._runHandlerParallel()` — handlers run sequentially now
111
- - `newFsmProcess()` — use `startProcess()` from orchestrator
112
- - `utils/handlers.ts` (`addSubstateHandlers`, `callStateHandlers`) — use HandlerRegistry
253
+ - `newFsmProcess()` — use `startProcess()`
254
+ - `utils/handlers.ts` (`addSubstateHandlers`, `callStateHandlers`) — pass a `load` callback to `startProcess()`
113
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.