Initial Commit
This commit is contained in:
139
openspec/changes/fsm-completion/proposal.md
Normal file
139
openspec/changes/fsm-completion/proposal.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# FSM completion — hierarchy, parallel regions, history, deferred events, hooks, visualization
|
||||
|
||||
**Package**: `@suaveplan/fsm`
|
||||
**Status**: PROPOSED
|
||||
**Type**: spec-conformance + feature
|
||||
|
||||
## Why
|
||||
|
||||
A 2026-05-02 audit of the genesis `@suaveplan/fsm` package against the upstream library at `../suaveplan/packages/universal/fsm/src` (and against this package's own archived proposal `openspec/archive/2026-04-19-enterprise-fsm/`) found six advertised capabilities that ship in upstream but are entirely missing from genesis. CLAUDE.md describes the package as "typed finite state machine: `FSMInterpreter<TContext>`, `createMachine()`, transition guards + actions, history entries, **nested state validation**, `assign()`/`pure()` context helpers" and `package.json` description claims "**hierarchical states, parallel regions, deferred events**". Genesis ships only a flat interpreter (`packages/flow/fsm/src/core/interpreter/fsm-interpreter.ts`) — the FSM type system has placeholder slots for `type: 'parallel'` and `states: Record<...>` in `StateDefinition` but no executor honours them.
|
||||
|
||||
Concretely, six features upstream ship and genesis silently dropped:
|
||||
|
||||
1. **Hierarchical FSM** — `HierarchicalStateMachine<TEvent, TContext>` (203 L) with nested-state UML semantics, dot-path resolution, common-ancestor exit/enter chain, per-node `onEnter` / `onExit` hooks. Genesis `StateDefinition.states` and `StateDefinition.initial` exist as types but are unused.
|
||||
2. **Parallel regions** — `ParallelStateMachine<TState, TEvent, TContext>` (208 L) with independent `Region`s, sync points, `combinedState`, `matchesAll`. Genesis `type: 'parallel'` is a typed-only no-op.
|
||||
3. **History states** — `HistoryStateManager` (138 L) with shallow + deep history, snapshot/restore, clear-on-trigger.
|
||||
4. **Deferred event queue** — `DeferredEventQueue<TState, TEvent, TContext>` (152 L), bounded FIFO with replay-on-handle.
|
||||
5. **Per-state lifecycle hooks** — `StateConfig.onEnter` / `onExit` on the flat `MachineConfig` (the upstream `StateMachine` ships this on top of `entry`/`exit` actions; genesis only ships `entry`/`exit` with no separate `onEnter`/`onExit` listener channel and no `enter`/`exit` event emission).
|
||||
6. **Visualization** — `toMermaid()` and `toDot()` exporters (130 L).
|
||||
|
||||
Without these, every consumer that needs a non-trivial machine (workflow engines, drive sync, network connection state, vault unlock, OAuth flows) is forced to either (a) hand-roll a parallel composer / nested resolver, or (b) abandon `@suaveplan/fsm` and import a third-party. The first creates invisible drift; the second wastes the foundation work already in genesis. The genesis-original archive `2026-04-19-enterprise-fsm` was approved on the strength of the hierarchical / parallel / history bullet list and the implementation never delivered them — this change closes that gap.
|
||||
|
||||
## What changes
|
||||
|
||||
### `@suaveplan/fsm` — additions
|
||||
|
||||
- ADDED: `HierarchicalStateMachine<TEvent, TContext>` class — UML-style nested state machine with dot-path state resolution (`statePath`, `stateSegments`), common-ancestor exit/enter chain on cross-boundary transitions, per-node `onEnter` / `onExit` hooks, guard/action pipeline, `matchesPath(path)` helper.
|
||||
- ADDED: `createHierarchicalMachine(config)` factory.
|
||||
- ADDED: `HierarchicalConfig`, `HierarchicalStateNode`, `HierarchicalTransition`, `HierarchicalTransitionResult` interfaces.
|
||||
- ADDED: `ParallelStateMachine<TState, TEvent, TContext>` class — orthogonal-region machine with independent `Region`s, shared context, `getRegionState(id)`, `combinedState`, `matchesAll(conditions)`, `reset()`, `sendToRegionById(id, event)`, `onSync(listener)` for sync-point subscription.
|
||||
- ADDED: `createParallelMachine(config)` factory.
|
||||
- ADDED: `ParallelConfig`, `RegionConfig`, `RegionTransition`, `SyncPoint`, `ParallelState`, `ParallelTransitionResult` interfaces.
|
||||
- ADDED: `HistoryStateManager` class — `register(parentPath, config)`, `record(parentPath, childState)`, `resolve(parentPath)`, `clear`/`clearAll`, `snapshot`/`restore`, `processTransition(from, to)` for clear-on-trigger; injectable `Clock` per CLAUDE.md determinism rule (no module-scope `Date.now()`).
|
||||
- ADDED: `createHistoryManager()` factory.
|
||||
- ADDED: `HistoryStateConfig`, `HistoryEntry`, `HistoryType` (`'shallow' | 'deep'`).
|
||||
- ADDED: `DeferredEventQueue<TState, TEvent, TContext>` class — bounded FIFO of deferred events; `configureState(state, { defer })`, `shouldDefer(state, event)`, `enqueue`, `processQueue(machine)` (replay-on-handle), `clearEvent(event)`; injectable `Clock`.
|
||||
- ADDED: `createDeferredQueue(config?)` factory.
|
||||
- ADDED: `DeferredQueueConfig`, `DeferredEvent`, `StateDeferConfig` interfaces.
|
||||
- ADDED: `FSMInterpreter` lifecycle hook channels — `onEnter(state, listener)` / `onExit(state, listener)` subscription methods that fire after `entry` / before `exit` actions; emit `enter` / `exit` events on the existing internal `EventBus`. Existing `entry` / `exit` action contract unchanged (returning a new context still replaces it).
|
||||
- ADDED: `toMermaid(definition, options?)` — Mermaid `stateDiagram-v2` exporter; honours `currentState`, `showGuards`, `showActions`, `direction: 'LR' | 'TD'`.
|
||||
- ADDED: `toDot(definition, options?)` — Graphviz DOT exporter with same options.
|
||||
- ADDED: `VisualizerOptions` interface.
|
||||
- ADDED: New error codes on `FSM_CODES` — `INVALID_HIERARCHY`, `UNKNOWN_REGION`, `DEFERRED_QUEUE_OVERFLOW`, `INVALID_HISTORY_TYPE`.
|
||||
|
||||
### Subpath exports (additive)
|
||||
|
||||
- ADDED: `./hierarchy` → `HierarchicalStateMachine`, `createHierarchicalMachine`, types.
|
||||
- ADDED: `./parallel` → `ParallelStateMachine`, `createParallelMachine`, types.
|
||||
- ADDED: `./history` → `HistoryStateManager`, `createHistoryManager`, types.
|
||||
- ADDED: `./deferred` → `DeferredEventQueue`, `createDeferredQueue`, types.
|
||||
- ADDED: `./visualization` → `toMermaid`, `toDot`, `VisualizerOptions`.
|
||||
|
||||
### Existing surface — preserved
|
||||
|
||||
- `FSMInterpreter`, `createMachine`, `assign`, `pure`, `validateDefinition`, `FSM_CODES`, `FSMError`, all existing types — unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Actor-model semantics** (spawn/send to child machines, mailbox, supervision) — upstream does not ship this; genesis will not introduce it without a separate proposal and a real consumer asking for it.
|
||||
- **SCXML wire format import/export** — neither upstream nor genesis ships this; the visualization exporters cover the legible-output use case (Mermaid for docs, DOT for tooling).
|
||||
- **Time-based / delayed transitions** (`after: { 5000: 'next' }`-style) — upstream does not ship this; orthogonal feature better served by `@suaveplan/timing`'s scheduler composed with explicit `send()` calls in user code.
|
||||
- **Async transition pipeline** (`AsyncGuardFn`, `AsyncActionFn`) — upstream's `fsm-execution.ts` ships this for the flat machine; genesis defers it to a follow-up because the existing genesis interpreter is sync-only and threading async through history / hierarchy / parallel multiplies surface beyond the audit's scope. Will be tracked separately as `fsm-async-pipeline`.
|
||||
- **Migration of upstream's `Hooks<FsmHookMap>` data-pipeline integration** (`@suaveplan/events/hooks` interceptors) — the genesis `EventBus` already covers the listener case; the data-mutating interceptor pipeline is a separate `@suaveplan/events` capability being tracked in `events-hooks-completion`.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change is a migration from `../suaveplan/packages/universal/fsm/src/`. There is no genesis `enterprise/fsm.md` audit doc, so source mapping cites the upstream package source directly.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `HierarchicalStateMachine` class | `packages/universal/fsm/src/hierarchy/hierarchical-fsm.ts` | L1-L196 | Verbatim port; thread injectable `Clock` instead of `Date.now()` for any timestamp emission |
|
||||
| `createHierarchicalMachine` factory | `packages/universal/fsm/src/hierarchy/hierarchical-fsm.ts` | L198-L203 | Verbatim |
|
||||
| `HierarchicalConfig` / `HierarchicalStateNode` / `HierarchicalTransition` / `HierarchicalTransitionResult` | `packages/universal/fsm/src/hierarchy/hierarchical-fsm.types.ts` | L1-L54 | Verbatim |
|
||||
| `ParallelStateMachine` class | `packages/universal/fsm/src/parallel/parallel-fsm.ts` | L1-L197 | Verbatim port; replace silent `catch` in `checkSyncPoints` (L188-L192) with structured logging via injectable `LoggerLike` |
|
||||
| `createParallelMachine` factory | `packages/universal/fsm/src/parallel/parallel-fsm.ts` | L199-L208 | Verbatim |
|
||||
| `ParallelConfig` / `RegionConfig` / `RegionTransition` / `SyncPoint` / `ParallelState` / `ParallelTransitionResult` | `packages/universal/fsm/src/parallel/parallel-fsm.types.ts` | L1-L74 | Verbatim |
|
||||
| `HistoryStateManager` class with shallow/deep + snapshot/restore | `packages/universal/fsm/src/history/history-state.ts` | L1-L133 | Verbatim port; replace `Date.now()` (L52) with injectable `Clock.now()` |
|
||||
| `createHistoryManager` factory | `packages/universal/fsm/src/history/history-state.ts` | L135-L138 | Verbatim |
|
||||
| `HistoryStateConfig` / `HistoryEntry` / `HistoryType` | `packages/universal/fsm/src/history/history-state.types.ts` | L1-L23 | Verbatim |
|
||||
| `DeferredEventQueue` class with replay-on-handle | `packages/universal/fsm/src/deferred/deferred-queue.ts` | L1-L143 | Verbatim port; replace `Date.now()` (L85) with injectable `Clock.now()` |
|
||||
| `createDeferredQueue` factory | `packages/universal/fsm/src/deferred/deferred-queue.ts` | L145-L152 | Verbatim |
|
||||
| `DeferredQueueConfig` / `DeferredEvent` / `StateDeferConfig` | `packages/universal/fsm/src/deferred/deferred-queue.types.ts` | L1-L24 | Verbatim |
|
||||
| `toMermaid` / `toDot` exporters | `packages/universal/fsm/src/visualization/fsm-visualizer.ts` | L1-L130 | Adapt input from upstream `MachineConfig` to genesis `FSMDefinition` |
|
||||
| `VisualizerOptions` | `packages/universal/fsm/src/visualization/fsm-visualizer.ts` | L13-L23 | Verbatim |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Per-state `onEnter` / `onExit` listener channels on `FSMInterpreter` | Genesis-original gap: upstream `StateMachine` (flat) ships `enter` / `exit` listener events via `FsmListenerMap`, but genesis `FSMInterpreter` only has the internal `transition` event. This change wires `enter` / `exit` events on the existing internal `EventBus`, gated behind `subscribe('enter', ...)` / `subscribe('exit', ...)`. Reasoning: matches upstream's contract without forcing callers to introduce `Hooks` until that capability is migrated separately. |
|
||||
| Injectable `Clock` for `HistoryStateManager` and `DeferredEventQueue` | CLAUDE.md determinism rule (Critical rule 9, openspec/AGENTS.md §0a "Avoid `Math.random()` / `Date.now()` at module scope"). Upstream uses `Date.now()` directly; genesis must thread `Clock` from `@suaveplan/types`. |
|
||||
| New `FSM_CODES` entries — `INVALID_HIERARCHY`, `UNKNOWN_REGION`, `DEFERRED_QUEUE_OVERFLOW`, `INVALID_HISTORY_TYPE` | Upstream throws plain `Error` for these conditions; genesis-wide policy (CLAUDE.md Critical rule 2 "every thrown error route through `@suaveplan/error`") requires registered codes. |
|
||||
| Visualization input adapter (genesis `FSMDefinition` → upstream `MachineConfig` shape) | The two definition shapes differ: upstream is `{ states: TState[], transitions: TransitionDefinition[] }` and genesis is `{ states: Record<StateValue, StateDefinition> }`. The exporter walks the genesis shape directly, so the adapter is a pure-function rewrite of `toMermaid` / `toDot` against the genesis types. Logic-equivalent; output identical for equivalent inputs. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each requirement in the spec delta carries a `**Verified by:**` line pointing at concrete `it(...)` titles. Test files that will exist after this change ships:
|
||||
|
||||
- `packages/flow/fsm/src/hierarchy/hierarchical-fsm.test.ts` — nested entry/exit chain, dot-path resolution, common-ancestor traversal, guard pipeline, cross-boundary transitions
|
||||
- `packages/flow/fsm/src/hierarchy/hierarchical-fsm.spec-conformance.test.ts` — UML-statechart contract: ancestors entered before descendants, descendants exited before ancestors, action runs between exit and enter
|
||||
- `packages/flow/fsm/src/parallel/parallel-fsm.test.ts` — region independence, sync points, `matchesAll`, `combinedState`, `sendToRegionById`, `reset`
|
||||
- `packages/flow/fsm/src/parallel/parallel-fsm.spec-conformance.test.ts` — sync-listener structured-error path, parallel-context-shared invariant
|
||||
- `packages/flow/fsm/src/history/history-state.test.ts` — shallow vs deep, snapshot/restore round-trip, clear-on-trigger, default fallback
|
||||
- `packages/flow/fsm/src/history/history-state.spec-conformance.test.ts` — injected `Clock` stamps timestamps; restore drops keys with no registered config
|
||||
- `packages/flow/fsm/src/deferred/deferred-queue.test.ts` — defer/replay FIFO, max-size enforcement, `clearEvent`, re-entrant guard via `_processing` flag
|
||||
- `packages/flow/fsm/src/deferred/deferred-queue.spec-conformance.test.ts` — `DEFERRED_QUEUE_OVERFLOW` thrown by configurable enqueue mode; injected `Clock` drives `timestamp`
|
||||
- `packages/flow/fsm/src/core/interpreter/fsm-interpreter.lifecycle.test.ts` — `onEnter` / `onExit` listeners fire on transition; ordering vs `entry` / `exit` actions
|
||||
- `packages/flow/fsm/src/visualization/fsm-visualizer.test.ts` — `toMermaid` and `toDot` round-trip a non-trivial definition; current-state highlight; guard / action labels
|
||||
- `packages/flow/fsm/src/visualization/fsm-visualizer.spec-conformance.test.ts` — DOT escaping; Mermaid direction option; produces parseable diagrams (regex-level pin)
|
||||
- `tests/fsm-public-surface.test.ts` (cross-package) — pins the additive subpath-export surface so accidental removal is a deliberate spec change
|
||||
|
||||
## Migration impact
|
||||
|
||||
- **Existing `FSMInterpreter` consumers** — no behavior change. The interpreter gains `onEnter` / `onExit` listener channels but the existing `entry` / `exit` action contract is preserved verbatim.
|
||||
- **`@suaveplan/fsm` direct dependents** — none in the genesis monorepo (`bunx turbo ls --filter=...@suaveplan/fsm` returns the package itself only).
|
||||
- **Subpath imports** — five new subpaths added (`./hierarchy`, `./parallel`, `./history`, `./deferred`, `./visualization`); the root barrel re-exports from each. No removed or renamed exports.
|
||||
- **Error-code consumers** — additive only; existing five codes preserved.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| Flat `StateMachine` with guards / actions / history | (upstream `fsm.ts`) | (genesis `FSMInterpreter`) | Already on genesis; preserved |
|
||||
| Hierarchical FSM (`HierarchicalStateMachine`) | YES | YES | This change |
|
||||
| Parallel regions (`ParallelStateMachine`) | YES | YES | This change |
|
||||
| History states (`HistoryStateManager`) | YES | YES | This change |
|
||||
| Deferred event queue (`DeferredEventQueue`) | YES | YES | This change |
|
||||
| Per-state `onEnter` / `onExit` hooks | YES | YES | This change wires them onto the existing genesis interpreter; `entry` / `exit` actions preserved verbatim |
|
||||
| Visualization (`toMermaid`, `toDot`) | YES | YES | This change |
|
||||
| Async transition pipeline (`AsyncGuardFn`, `AsyncActionFn`, `executeTransitionAsync`) | YES | NO | Scoped out — orthogonal sync-vs-async axis; threading async through history/hierarchy/parallel multiplies surface beyond this proposal. Tracked as separate `fsm-async-pipeline` change. |
|
||||
| `Hooks<FsmHookMap>` data-pipeline integration | YES | NO | Scoped out — depends on `@suaveplan/events/hooks` migration which is its own change. The genesis `EventBus` covers the listener case. |
|
||||
| Snapshot / restore on flat `StateMachine` | YES | NO | Already a known gap on the existing flat genesis interpreter; pre-existing on `fsm-snapshot` follow-up; not introduced by this change. |
|
||||
|
||||
## Affected dependents
|
||||
|
||||
`bunx turbo ls --filter=...@suaveplan/fsm` enumerates only the package itself (no in-repo dependents).
|
||||
|
||||
- `@suaveplan/fsm` (self) — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
|
||||
No external dependents on genesis. The proposal is additive (no removed or modified exports), so no downstream sweep is required.
|
||||
183
openspec/changes/fsm-completion/specs/fsm/spec.md
Normal file
183
openspec/changes/fsm-completion/specs/fsm/spec.md
Normal file
@@ -0,0 +1,183 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: fsm (fsm-completion)
|
||||
|
||||
**Package**: `@suaveplan/fsm`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hierarchical FSM with nested states
|
||||
|
||||
The package SHALL provide a `HierarchicalStateMachine<TEvent, TContext>` class implementing UML-statechart nested-state semantics. State paths are addressed as dot-separated strings (e.g. `"active.editing.text"`); on a transition the machine SHALL exit nested states from leaf upward to the deepest common ancestor of source and target paths, run the transition action, then enter from the common ancestor down to the new leaf, executing each node's `onEnter` / `onExit` hook in that order. Initial child states resolve recursively via each node's `initial` property.
|
||||
|
||||
#### Scenario: nested initial path resolves on construction
|
||||
|
||||
- **WHEN** a config defines `states.active.children.editing.children.text` with `initial` chained at each level
|
||||
- **THEN** `machine.statePath` returns `"active.editing.text"` immediately after construction and `runEnterChain` fires `onEnter` for `active`, `active.editing`, `active.editing.text` in that order
|
||||
|
||||
#### Scenario: cross-boundary transition exits to common ancestor
|
||||
|
||||
- **WHEN** the machine is in `"active.editing.text"` and a transition's target is `"active.viewing"`
|
||||
- **THEN** `onExit` fires for `active.editing.text` then `active.editing`, the transition action runs, then `onEnter` fires for `active.viewing`
|
||||
|
||||
#### Scenario: guard blocks transition without modifying state
|
||||
|
||||
- **WHEN** a transition's `guard` returns `false`
|
||||
- **THEN** `send()` returns `{ success: false, error: 'Guard blocked event "<event>"' }` and `statePath` is unchanged
|
||||
|
||||
**Verified by:** `src/hierarchy/hierarchical-fsm.test.ts::"resolves the nested initial path on construction"` AND `src/hierarchy/hierarchical-fsm.test.ts::"exits to the common ancestor on cross-boundary transitions"` AND `src/hierarchy/hierarchical-fsm.spec-conformance.test.ts::"orders enter/exit hooks per UML statechart contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parallel regions with independent active state per region
|
||||
|
||||
The package SHALL provide a `ParallelStateMachine<TState, TEvent, TContext>` class supporting multiple orthogonal regions. Each region holds its own active state but shares one `TContext`. The `send(event)` method SHALL dispatch the event to every region in declaration order; regions whose current state matches a transition `from` AND whose guard passes SHALL transition independently. The result reports the affected region IDs. Sync points fire a configured event when all listed regions reach their listed states; sync-point listeners that throw SHALL route through an injected `LoggerLike.warn` rather than being silently swallowed.
|
||||
|
||||
#### Scenario: regions transition independently on the same event
|
||||
|
||||
- **WHEN** two regions A and B both have a transition from their initial state on event `"X"`
|
||||
- **THEN** after `send("X")` both region states update, `result.affectedRegions` contains both IDs, and the shared `context` reflects merged action mutations
|
||||
|
||||
#### Scenario: sendToRegionById scopes the event to a single region
|
||||
|
||||
- **WHEN** `sendToRegionById("A", "X")` is called
|
||||
- **THEN** only region A's transitions are evaluated; region B's state is unchanged
|
||||
|
||||
#### Scenario: unknown region raises FSMError
|
||||
|
||||
- **WHEN** `sendToRegionById("does-not-exist", ...)` is called
|
||||
- **THEN** the call throws `FSMError` with code `FSM_CODES.UNKNOWN_REGION`
|
||||
|
||||
#### Scenario: sync point fires when all conditions are met
|
||||
|
||||
- **WHEN** all regions reach the states listed in a `SyncPoint.conditions`
|
||||
- **THEN** every subscribed `onSync` listener is invoked with the sync point's `event`
|
||||
|
||||
**Verified by:** `src/parallel/parallel-fsm.test.ts::"transitions all regions that handle the event"` AND `src/parallel/parallel-fsm.test.ts::"sendToRegionById scopes to one region"` AND `src/parallel/parallel-fsm.spec-conformance.test.ts::"throws FSM_CODES.UNKNOWN_REGION for unknown regionId"` AND `src/parallel/parallel-fsm.test.ts::"fires sync point listeners when matchesAll conditions hold"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: History states (shallow and deep)
|
||||
|
||||
The package SHALL provide a `HistoryStateManager` class that records the last-seen child state for a registered parent path. `register(parentPath, { type, defaultState })` accepts `type: 'shallow'` (remember immediate child) or `type: 'deep'` (remember full nested path). `record(parentPath, childState)` stamps the entry with `Clock.now()` from an injected `@suaveplan/types::Clock`. `resolve(parentPath)` returns the remembered state if present, otherwise the configured `defaultState`. `snapshot()` and `restore(data)` round-trip all entries; `restore` SHALL drop any keys whose `parentPath` is not currently registered.
|
||||
|
||||
#### Scenario: shallow history remembers the last immediate child
|
||||
|
||||
- **WHEN** `register("menu", { type: "shallow", defaultState: "main" })` is called and `record("menu", "settings")` is then called
|
||||
- **THEN** `resolve("menu")` returns `"settings"`
|
||||
|
||||
#### Scenario: default fallback when no history recorded
|
||||
|
||||
- **WHEN** `register("menu", { type: "shallow", defaultState: "main" })` is called and no `record` follows
|
||||
- **THEN** `resolve("menu")` returns `"main"`
|
||||
|
||||
#### Scenario: clear-on-trigger drops history entry
|
||||
|
||||
- **WHEN** `addClearTrigger("menu", "settings", "exit")` is registered, `record("menu", "settings")` is called, then `processTransition("settings", "exit")` is called
|
||||
- **THEN** `has("menu")` returns `false`
|
||||
|
||||
#### Scenario: snapshot/restore preserves only registered keys
|
||||
|
||||
- **WHEN** `restore({ unregistered: { state: "x", timestamp: 0 }, menu: { state: "settings", timestamp: 1 } })` is called and only `"menu"` is registered
|
||||
- **THEN** `resolve("menu")` returns `"settings"` and `has("unregistered")` returns `false`
|
||||
|
||||
#### Scenario: invalid history type raises FSMError
|
||||
|
||||
- **WHEN** `register("menu", { type: "wrong" as HistoryType, defaultState: "main" })` is called
|
||||
- **THEN** the call throws `FSMError` with code `FSM_CODES.INVALID_HISTORY_TYPE`
|
||||
|
||||
**Verified by:** `src/history/history-state.test.ts::"shallow history remembers the last immediate child"` AND `src/history/history-state.test.ts::"falls back to defaultState when no history recorded"` AND `src/history/history-state.test.ts::"clear trigger drops history on matching transition"` AND `src/history/history-state.spec-conformance.test.ts::"restore drops keys not registered in current configuration"` AND `src/history/history-state.spec-conformance.test.ts::"throws FSM_CODES.INVALID_HISTORY_TYPE for unknown HistoryType"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Deferred event queue with replay-on-handle
|
||||
|
||||
The package SHALL provide a `DeferredEventQueue<TState, TEvent, TContext>` class that holds events the current state cannot handle and replays them after the next successful transition. `configureState(state, { defer })` registers events to defer per state. `send(machine, event)` SHALL enqueue (returning `null`) if `shouldDefer(currentState, event)` is true, else call `machine.send(event)` and on success drain the queue in FIFO order. The queue is bounded by `DeferredQueueConfig.maxSize` (default 100). The `overflow` config SHALL accept `'drop-newest' | 'drop-oldest' | 'throw'`; on `'throw'`, exceeding capacity raises `FSMError(FSM_CODES.DEFERRED_QUEUE_OVERFLOW, ...)`. Timestamps SHALL come from an injected `Clock`. The `processQueue` method SHALL guard against re-entrancy via an internal `_processing` flag.
|
||||
|
||||
#### Scenario: event deferred when state is configured to defer it
|
||||
|
||||
- **WHEN** the machine is in state `S` configured to defer event `E`, and `queue.send(machine, E)` is called
|
||||
- **THEN** the call returns `null`, `queue.size` is `1`, and `machine.state` is unchanged
|
||||
|
||||
#### Scenario: replay drains the queue after a successful transition
|
||||
|
||||
- **WHEN** event `E` is deferred in `S`, the machine then transitions out of `S` to `T` (where `T` does not defer `E`), and `queue.processQueue(machine)` is called
|
||||
- **THEN** `machine.send(E)` is invoked once, the queue is empty, and the returned array contains the transition result
|
||||
|
||||
#### Scenario: max-size with `drop-oldest` evicts the head
|
||||
|
||||
- **WHEN** `maxSize: 2`, `overflow: 'drop-oldest'`, and three events are enqueued
|
||||
- **THEN** the oldest event is dropped, `queue.size` is `2`, and the head of the queue is the second-enqueued event
|
||||
|
||||
#### Scenario: overflow `throw` raises FSMError
|
||||
|
||||
- **WHEN** `maxSize: 1`, `overflow: 'throw'`, and a second `enqueue` is attempted
|
||||
- **THEN** `FSMError` with code `FSM_CODES.DEFERRED_QUEUE_OVERFLOW` is thrown
|
||||
|
||||
#### Scenario: re-entrant processQueue is a no-op
|
||||
|
||||
- **WHEN** `processQueue` is called recursively (e.g. from a transition action that calls back into the queue)
|
||||
- **THEN** the inner call returns an empty array and the outer call drains the queue exactly once
|
||||
|
||||
**Verified by:** `src/deferred/deferred-queue.test.ts::"defers events configured for the current state"` AND `src/deferred/deferred-queue.test.ts::"replays queued events in FIFO order on successful transition"` AND `src/deferred/deferred-queue.spec-conformance.test.ts::"drop-oldest overflow evicts the head when capacity is reached"` AND `src/deferred/deferred-queue.spec-conformance.test.ts::"throw overflow raises FSM_CODES.DEFERRED_QUEUE_OVERFLOW"` AND `src/deferred/deferred-queue.test.ts::"re-entrant processQueue returns empty array"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-state lifecycle hooks on FSMInterpreter
|
||||
|
||||
The `FSMInterpreter` SHALL expose `onEnter(state, listener)` and `onExit(state, listener)` subscription methods that fire **after** a state's `entry` action and **before** a state's `exit` action. Each method returns a disposer; calling the disposer SHALL remove the listener idempotently. Both channels emit `enter` / `exit` events on the existing internal `EventBus` so external observers can subscribe directly. The existing `entry` / `exit` action contract (returning a new context to replace it) SHALL be preserved unchanged.
|
||||
|
||||
#### Scenario: onEnter fires after entry action
|
||||
|
||||
- **WHEN** state `S` has both an `entry` action that mutates `context` and an `onEnter` listener registered
|
||||
- **THEN** the `entry` action runs first, then the `onEnter` listener observes the mutated `context`
|
||||
|
||||
#### Scenario: onExit fires before exit action
|
||||
|
||||
- **WHEN** state `S` has both an `exit` action and an `onExit` listener registered, and the machine transitions out of `S`
|
||||
- **THEN** the `onExit` listener is invoked before the `exit` action mutates context
|
||||
|
||||
#### Scenario: disposer removes the listener idempotently
|
||||
|
||||
- **WHEN** the disposer returned by `onEnter('S', listener)` is called twice
|
||||
- **THEN** the listener is removed exactly once and the second call is a no-op (no error thrown)
|
||||
|
||||
**Verified by:** `src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"onEnter listener fires after entry action with mutated context"` AND `src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"onExit listener fires before exit action"` AND `src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"disposer is idempotent on repeated invocation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Visualization exporters — toDot and toMermaid
|
||||
|
||||
The package SHALL provide two pure functions that serialize an `FSMDefinition<TContext>` to human-readable diagram syntax:
|
||||
|
||||
- `toMermaid(definition, options?)` produces a Mermaid `stateDiagram-v2` document.
|
||||
- `toDot(definition, options?)` produces a Graphviz DOT digraph.
|
||||
|
||||
Both SHALL accept `VisualizerOptions` with `currentState?: StateValue`, `showGuards?: boolean`, `showActions?: boolean`, `direction?: 'LR' | 'TD'`. Both SHALL walk the genesis `FSMDefinition.states: Record<StateValue, StateDefinition>` shape recursively, emit one edge per `(state, event, target)` triple, label edges with `event`, optionally annotate with `[guard]` / `/ action` markers, and highlight `currentState` if provided. DOT output SHALL escape `"` and `\` in labels.
|
||||
|
||||
#### Scenario: toMermaid emits a parseable stateDiagram-v2
|
||||
|
||||
- **WHEN** `toMermaid(definition)` is called with a non-empty definition
|
||||
- **THEN** the output starts with `stateDiagram-v2`, contains `[*] --> <initial>`, and contains one `<from> --> <to> : <event>` line per transition
|
||||
|
||||
#### Scenario: toDot escapes special characters in labels
|
||||
|
||||
- **WHEN** an event name contains `"` (e.g. `'click "X"'`)
|
||||
- **THEN** the corresponding DOT edge label is rendered with `\"` escaping
|
||||
|
||||
#### Scenario: showGuards = false suppresses guard annotation
|
||||
|
||||
- **WHEN** a transition has a guard and `showGuards: false` is set
|
||||
- **THEN** the rendered label is exactly the event name with no `[guard]` suffix
|
||||
|
||||
#### Scenario: currentState highlight in toDot
|
||||
|
||||
- **WHEN** `toDot(definition, { currentState: 'active' })` is called
|
||||
- **THEN** the `"active"` node line includes `style="rounded,filled", fillcolor="#ffccff"`
|
||||
|
||||
**Verified by:** `src/visualization/fsm-visualizer.test.ts::"toMermaid emits valid stateDiagram-v2"` AND `src/visualization/fsm-visualizer.spec-conformance.test.ts::"toDot escapes quotes in edge labels"` AND `src/visualization/fsm-visualizer.spec-conformance.test.ts::"showGuards: false suppresses [guard] annotation"` AND `src/visualization/fsm-visualizer.test.ts::"toDot highlights currentState with fillcolor"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
146
openspec/changes/fsm-completion/tasks.md
Normal file
146
openspec/changes/fsm-completion/tasks.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Tasks — FSM completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify. Retroactive ticking is the failure mode this template guards against.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Status: **DEFERRED**. `packages/flow/fsm/src/` contains only `core`, `errors`, `types`, `utils` — none of `hierarchy/`, `parallel/`, `history/`, `deferred/`, or `visualization/` exist. Six advertised features (HierarchicalStateMachine, ParallelStateMachine, HistoryStateManager, DeferredEventQueue, onEnter/onExit hooks, toMermaid/toDot exporters) are entirely missing. 0% of work is done.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/packages/universal/fsm/src/` end-to-end (hierarchy/, parallel/, history/, deferred/, visualization/, fsm-hooks.ts)
|
||||
- [ ] 1.2 Read existing `openspec/specs/fsm/spec.md` (none exists yet — will be created at archive)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13, 14, and §0a (migration parity)
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [ ] 1.6 Draft `specs/fsm/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [ ] 1.8 `## Parity Checklist` table fully populated; every 🚫 has architectural justification ≤1 sentence
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
### 2a. Hierarchical FSM
|
||||
|
||||
- [ ] 2.1 Add `src/hierarchy/hierarchical-fsm.types.ts` with `HierarchicalConfig`, `HierarchicalStateNode`, `HierarchicalTransition`, `HierarchicalTransitionResult`
|
||||
- [ ] 2.2 Add `src/hierarchy/hierarchical-fsm.ts` — port `HierarchicalStateMachine` class verbatim from upstream L1-L196; preserve `statePath`, `stateSegments`, `context`, `id`, `send`, `matchesPath`; preserve common-ancestor exit/enter chain
|
||||
- [ ] 2.3 Add `createHierarchicalMachine` factory at end of file
|
||||
- [ ] 2.4 Add `src/hierarchy/index.ts` barrel re-exporting class + factory + types
|
||||
- [ ] 2.5 Wire transitions through `FSM_CODES.INVALID_HIERARCHY` for malformed nested definitions (instead of upstream's plain `Error` strings)
|
||||
|
||||
### 2b. Parallel regions
|
||||
|
||||
- [ ] 2.6 Add `src/parallel/parallel-fsm.types.ts` with `RegionConfig`, `RegionTransition`, `ParallelConfig`, `SyncPoint`, `ParallelState`, `ParallelTransitionResult`
|
||||
- [ ] 2.7 Add `src/parallel/parallel-fsm.ts` — port `ParallelStateMachine` class verbatim from upstream L1-L197; preserve `states`, `context`, `getRegionState`, `combinedState`, `send`, `sendToRegionById`, `onSync`, `matchesAll`, `reset`
|
||||
- [ ] 2.8 Replace silent `catch` in `checkSyncPoints` (upstream L188-L192) with `LoggerLike.warn` via injected logger; default to no-op logger when none injected
|
||||
- [ ] 2.9 Throw `FSMError(FSM_CODES.UNKNOWN_REGION, ...)` from `sendToRegionById` when `regionId` is not registered
|
||||
- [ ] 2.10 Add `createParallelMachine` factory at end of file
|
||||
- [ ] 2.11 Add `src/parallel/index.ts` barrel
|
||||
|
||||
### 2c. History states
|
||||
|
||||
- [ ] 2.12 Add `src/history/history-state.types.ts` with `HistoryType`, `HistoryStateConfig`, `HistoryEntry`
|
||||
- [ ] 2.13 Add `src/history/history-state.ts` — port `HistoryStateManager` from upstream L1-L133; replace `Date.now()` (L52) with `Clock.now()` from injected `@suaveplan/types::Clock`
|
||||
- [ ] 2.14 Add `createHistoryManager(options?: { clock?: Clock })` factory; default `Clock` uses `Date.now()` at the boundary, not module scope
|
||||
- [ ] 2.15 Throw `FSMError(FSM_CODES.INVALID_HISTORY_TYPE, ...)` if `register` is called with an unrecognized `HistoryType`
|
||||
- [ ] 2.16 Add `src/history/index.ts` barrel
|
||||
|
||||
### 2d. Deferred event queue
|
||||
|
||||
- [ ] 2.17 Add `src/deferred/deferred-queue.types.ts` with `DeferredQueueConfig`, `DeferredEvent`, `StateDeferConfig`
|
||||
- [ ] 2.18 Add `src/deferred/deferred-queue.ts` — port `DeferredEventQueue` from upstream L1-L143; replace `Date.now()` (L85) with injected `Clock.now()`
|
||||
- [ ] 2.19 Add overflow-policy option to `DeferredQueueConfig` — `'drop-newest' | 'drop-oldest' | 'throw'`; on `'throw'`, raise `FSMError(FSM_CODES.DEFERRED_QUEUE_OVERFLOW, ...)`
|
||||
- [ ] 2.20 Add `createDeferredQueue(config?)` factory
|
||||
- [ ] 2.21 Add `src/deferred/index.ts` barrel
|
||||
|
||||
### 2e. FSMInterpreter lifecycle hooks
|
||||
|
||||
- [ ] 2.22 Add `onEnter(state, listener): () => void` and `onExit(state, listener): () => void` methods to `FSMInterpreter` that subscribe through the existing internal `EventBus` to new `enter` / `exit` events
|
||||
- [ ] 2.23 Emit `enter` after a successful transition's `entry` action; emit `exit` before `exit` action runs (matches upstream listener ordering)
|
||||
- [ ] 2.24 Update `FSMEventMap<TContext>` type to register `enter` / `exit` channels with payload `{ state: StateValue, context: TContext, event: FSMEvent }`
|
||||
- [ ] 2.25 Verify existing `transition` event listeners still fire (regression guard)
|
||||
|
||||
### 2f. Visualization
|
||||
|
||||
- [ ] 2.26 Add `src/visualization/fsm-visualizer.ts` — port `toMermaid` / `toDot` / `buildLabel` / `escDot` from upstream L1-L130; rewrite the input walker against the genesis `FSMDefinition.states: Record<...>` shape (upstream's flat `MachineConfig.transitions` array becomes a recursive walk over `states[].on`)
|
||||
- [ ] 2.27 Add `VisualizerOptions` interface with `currentState`, `showGuards`, `showActions`, `direction`
|
||||
- [ ] 2.28 Add `src/visualization/index.ts` barrel
|
||||
|
||||
### 2g. Errors and exports
|
||||
|
||||
- [ ] 2.29 Add `INVALID_HIERARCHY`, `UNKNOWN_REGION`, `DEFERRED_QUEUE_OVERFLOW`, `INVALID_HISTORY_TYPE` to `FSM_CODES` array in `src/errors/fsm-error.ts`
|
||||
- [ ] 2.30 Update `src/index.ts` root barrel to re-export from `./hierarchy`, `./parallel`, `./history`, `./deferred`, `./visualization`
|
||||
- [ ] 2.31 Add subpath entries to `package.json` `exports` map for the five new subpaths (matches `./interpreter` / `./errors` shape already present)
|
||||
- [ ] 2.32 Update `package.json` description if needed (already mentions hierarchical / parallel / deferred — confirm wording matches what shipped)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
### 3a. Hierarchical FSM
|
||||
|
||||
- [ ] 3.1 `src/hierarchy/hierarchical-fsm.test.ts` — initial path resolution, `send` traversal, guard blocking, action context update, `matchesPath`
|
||||
- [ ] 3.2 `src/hierarchy/hierarchical-fsm.spec-conformance.test.ts` — UML enter-from-ancestor, exit-from-leaf order; cross-boundary transition with action between exit and enter
|
||||
|
||||
### 3b. Parallel regions
|
||||
|
||||
- [ ] 3.3 `src/parallel/parallel-fsm.test.ts` — region independence, sync-point firing on `matchesAll`, `sendToRegionById` scoping, `combinedState` formatting, `reset` round-trip
|
||||
- [ ] 3.4 `src/parallel/parallel-fsm.spec-conformance.test.ts` — sync listener throwing routes through injected logger; `UNKNOWN_REGION` error path
|
||||
|
||||
### 3c. History states
|
||||
|
||||
- [ ] 3.5 `src/history/history-state.test.ts` — shallow vs deep recall, default fallback, clear-on-trigger, snapshot/restore round-trip with key filter
|
||||
- [ ] 3.6 `src/history/history-state.spec-conformance.test.ts` — `Clock.now()` is the only timestamp source (test injects a fixed clock); `INVALID_HISTORY_TYPE` thrown for bad config
|
||||
|
||||
### 3d. Deferred event queue
|
||||
|
||||
- [ ] 3.7 `src/deferred/deferred-queue.test.ts` — FIFO defer + replay, max-size truncation, `clearEvent` filtering, re-entrant `_processing` guard
|
||||
- [ ] 3.8 `src/deferred/deferred-queue.spec-conformance.test.ts` — overflow policies (drop-newest / drop-oldest / throw); injected `Clock.now()` populates `DeferredEvent.timestamp`
|
||||
|
||||
### 3e. Lifecycle hooks
|
||||
|
||||
- [ ] 3.9 `src/core/interpreter/fsm-interpreter.lifecycle.test.ts` — `onEnter` fires after `entry` action; `onExit` fires before `exit` action; subscriber unsubscribe path; `enter` / `exit` events visible on internal bus
|
||||
|
||||
### 3f. Visualization
|
||||
|
||||
- [ ] 3.10 `src/visualization/fsm-visualizer.test.ts` — Mermaid output is valid stateDiagram-v2 (regex pin); DOT output is valid digraph; current-state highlight; nested states render with subgraphs / dot-path labels
|
||||
- [ ] 3.11 `src/visualization/fsm-visualizer.spec-conformance.test.ts` — DOT escaping for quotes/backslashes; Mermaid `direction LR` vs `TD` honoured; guard / action labels off by default flag
|
||||
|
||||
### 3g. Cross-package
|
||||
|
||||
- [ ] 3.12 `tests/fsm-public-surface.test.ts` — assert root barrel re-exports include `HierarchicalStateMachine`, `ParallelStateMachine`, `HistoryStateManager`, `DeferredEventQueue`, `toMermaid`, `toDot`, `createHierarchicalMachine`, `createParallelMachine`, `createHistoryManager`, `createDeferredQueue`
|
||||
- [ ] 3.13 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference): `hierarchy/hierarchical-fsm.md`, `parallel/parallel-fsm.md`, `history/history-state.md`, `deferred/deferred-queue.md`, `visualization/fsm-visualizer.md`
|
||||
- [ ] 4.2 README links every new co-located `.md` under `## Modules`
|
||||
- [ ] 4.3 Update root `README.md` quick-start section with a hierarchical machine example and a parallel-regions example
|
||||
- [ ] 4.4 Update `CLAUDE.md` `@suaveplan/fsm` package listing — confirm it already mentions hierarchical / parallel / deferred / visualization; remove "nested state validation" wording if it implies validator-only (we ship full executor now)
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/fsm` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/fsm` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/fsm` — exit 0, no `error` findings
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/fsm` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (`HierarchicalStateMachine<TEvent, TContext>` infers state-path strings; `ParallelStateMachine<TState, TEvent, TContext>` preserves region-state union)
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary (none yet — these classes accept typed objects, not external JSON; if validation is required at construction add `validateHierarchicalConfig` / `validateParallelConfig` helpers)
|
||||
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path (`INVALID_HIERARCHY`, `UNKNOWN_REGION`, `DEFERRED_QUEUE_OVERFLOW`, `INVALID_HISTORY_TYPE`)
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off (queue overflow policy `'throw'` vs `'drop-*'`; sync-listener exception bubbling vs swallowed)
|
||||
- [ ] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider` (existing `fsm.transition.total` / `fsm.transition.duration_ms` preserved; new counters `fsm.hierarchy.transition.total`, `fsm.parallel.transition.total`, `fsm.deferred.queue.size`)
|
||||
- [ ] 6.6 Lifecycle dedup tested where claimed (`onEnter`/`onExit` unsubscribe is idempotent; subscriber removed by returned disposer never re-fires)
|
||||
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/fsm/spec.md` (file does not exist yet — this change creates it)
|
||||
- [ ] 7.2 Move `openspec/changes/fsm-completion/` → `openspec/archive/<yyyy-mm-dd>-fsm-completion/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` if package set or deps changed (no new deps; subpath exports added)
|
||||
Reference in New Issue
Block a user