[READ-ONLY] Mirror of https://github.com/andrioid/statesman. xstate inspired schema compatible statechart library for Go
fsm
go
statecharts
9.4 kB
163 lines
1# statesman
2
3Statemanager for Go statechart for long-running backend workflows. Author a machine as
4Stately-compatible `machine.json`, generate a typed Go facade from it, and run it
5as an actor with a lock-free snapshot and deterministic, virtual-time tests.
6
7- **Typed, not stringly-typed.** Generated `States` constants, event types, and an
8 `Implementations` interface whose action/guard methods carry the concrete event
9 and context types — no `any` on the surfaces you touch.
10- **Stdlib-only runtime.** No third-party dependencies on a running machine's path.
11- **Deterministic tests.** Virtual clock + `Sync` harness; transitions settle
12 synchronously, time only moves on `Advance`.
13- **`-race`-clean.** The whole runtime and test suite pass `go test -race`.
14
15> **Status: v1.** Hierarchy, parallel regions, history, guards, eventless
16> (`always`) transitions, delayed (`after`) transitions, invoked actors
17> (promise/callback/observable/machine), supervision, and codegen are implemented.
18> Durable persistence is designed (`docs/persistence-contract.md`) and exposed via
19> the `TransitionObserver` seam, but the on-disk store/recovery (Phase 6) is on the
20> roadmap — v1 is in-memory.
21
22## Install
23
24```sh
25go get github.com/andrioid/statesman # runtime library
26go get -tool github.com/andrioid/statesman/cmd/statesman # CLI, tracked as a go tool
27```
28
29The CLI is a [Go tool dependency](https://go.dev/doc/modules/managing-dependencies#tools):
30`go get -tool` pins it in your `go.mod` and you run it with `go tool statesman <verb>` —
31no separate `go install` or `$PATH` binary to keep in sync. Requires Go 1.26+.
32
33## Quickstart
34
35```sh
36go tool statesman init checkout # scaffold a runnable checkout/ package (idle -> done)
37```
38
39`init` writes `checkout/`: `gen.go` (with a `//go:generate go tool statesman generate`
40directive), `checkout.machine.json`, `checkout.events.go` (event/context stubs),
41and `checkout.machine.gen.go` (generated facade) — and it compiles immediately. Then iterate:
42
431. Edit `checkout/checkout.machine.json` (or paste the export from Stately Studio).
442. Re-scaffold the new symbols and regenerate:
45
46 ```sh
47 go tool statesman stub ./checkout # append stubs for new events/actors/fields + an Impl skeleton (idempotent)
48 go generate ./checkout/ # re-runs `go tool statesman generate` -> checkout.machine.gen.go
49 ```
50
513. Fill in the `Implementations` methods on the `Impl` skeleton `statesman stub`
52 wrote to `checkout/checkout.behavior.go` (one panicking method per action,
53 guard, or invoke-input callsite).
54
55Drive it from a test with the deterministic harness:
56
57```go
58impl := checkout.Impl{ /* ... */ }
59s := statesmantest.NewSync(func(o ...statesman.Option) *statesman.Machine[checkout.Context, checkout.Event] {
60 return checkout.NewCheckoutMachine(impl, o...)
61})
62ctx := context.Background()
63_ = s.Start(ctx, "checkout-1")
64
65_ = s.SendAndSettle(ctx, checkout.Submit{ /* ... */ })
66s.Advance(ctx, checkout.RetryDelay) // fire `after` timers on virtual time
67
68snap := s.Snapshot() // typed: snap.ActiveStates, snap.Context
69```
70
71Visualize it — Mermaid for docs, or a live tree in the terminal:
72
73```sh
74go tool statesman diagram ./checkout # Mermaid stateDiagram-v2 (e.g. pipe to checkout.mmd)
75go tool statesman diagram ./checkout --format term # Unicode outline tree in the terminal
76go tool statesman diagram ./checkout --watch # re-render as you edit the machine.json
77```
78
79`diagram.Live(ctx, machine, os.Stdout)` overlays a running machine's active
80states, status, and armed timers onto that tree.
81
82## How it works
83
84```
85<id>.machine.json --schema.Load--> *Definition --NewXxxMachine--> Machine[TCtx,TEvt]
86 | |
87 +-- statesman generate --> <id>.machine.gen.go (States, events, +-- Snapshot[TCtx] (atomic, lock-free)
88 Implementations interface, constructor, dispatch tables)
89```
90
91- **One goroutine owns mutable state.** Every transition runs on the actor loop;
92 readers call `Snapshot()` (an `atomic.Pointer` load) and never block the actor.
93- **Engine is pure.** Selection, LCCA exit/entry, and the microstep/macrostep loop
94 are an SCXML-subset reduction (see `docs/transition-algorithm.md`); actions and
95 guards are opaque closures keyed by callsite index, so the engine carries no
96 user types.
97- **Effects via an outbox.** Actions return an `ActionResult`; assigns produce a
98 new context value, sends/spawns are drained after the snapshot publishes.
99
100## Packages
101
102| Path | Role |
103| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
104| `.` (`statesman`) | Runtime core: `Machine`, `Snapshot`, `NewMachine`, observers, `StallObserver`, clock/timer interfaces |
105| `schema` | `Load([]byte) (*Definition, error)` — the only `machine.json` parser; stricter-than-schema validation |
106| `cmd/statesman` | CLI: `init`, `stub`, `generate` |
107| `statesmantest` | `Sync` harness, `ManualClock`/`ManualTimerService`, `FakeActor`, `RunScenarios` corpus runner |
108| `internal/codegen` | `go/types`-driven resolution + emitters (not a public API) |
109| `docs/` | Normative specs: transition algorithm, schema subset, persistence contract |
110| `examples/simple` | Worked slot-machine example (RTP-controlled via giving/taking mode states): machine + `cmd/simple` terminal demo (`go run ./examples/simple/cmd/simple`) |
111| `examples/issues` | Worked GitHub issue-triage coordinator. Its own nested module so the genqlient GraphQL client (`github/`) keeps its deps out of the core; `gh` only as a token fallback. LLM steps run through `AGENT='pi -p {{prompt}}'` (per-verb `AGENT_<VERB>` overrides; prompts are templates overridable via `AGENT_PROMPT_DIR`). `cmd/issuesctl <n>` drives the whole machine against a live issue in dry-run to iterate on prompts. Test with `cd examples/issues && go test ./...` |
112
113## Testing
114
115- **`Sync`** — `SendAndSettle` / `Advance` / `Settle` block until the actor
116 quiesces, so assertions are race-free without sleeps.
117- **`ManualClock` + `ManualTimerService`** — virtual time; `after` timers fire on
118 `Advance`.
119- **`FakeActor` / `CommandRecorder` / `FakeCallback`** — actor doubles for invoke
120 contract tests without real side effects.
121- **`RunScenarios(t, dir)`** — the curated behavioral corpus: each
122 `testdata/scenarios/*.txtar` holds a `machine.json` and a `send`/`advance`/
123 `expect` script, run on virtual time. Add machines as data, not Go code.
124
125## Production notes
126
127- **Strong subscriber delivery.** Subscribers get a bounded, blocking queue: a
128 slow subscriber stalls the actor by design (audit semantics). Wrap load-bearing
129 transition observers in a `StallObserver` to surface "held the actor for >Nms"
130 as a metric; use a latest-wins proxy for lossy feeds.
131- **Observers must not `Send`.** A synchronous self-`Send` from an observer
132 deadlocks the loop — emit effects via `ActionResult`.
133- **Panics are fatal.** Action/guard methods must not panic; the actor loop has no
134 `recover()` (panics are programming errors).
135- **Context is read-only** in action/guard methods; mutate by returning a new
136 value via `ActionResult`.
137- **Retries and timeouts are chart edges, not hidden loops.** A timeout is an
138 `after` on the invoking state; a retry is a guarded `error.invoke.<id>` (or
139 `after`) edge re-entering it. For exponential/jittered backoff (which static
140 `after` delays can't express), `statesman.BackoffActor(clock, timers, delay,
141 onDone)` runs the wait as an invoke on the same `TimerService`; classify
142 failures with `statesman.IsTransient(err)` in the retry guard. `Snapshot.InvokeRestarts`
143 reports per-invoke re-spawns so an observer can alarm on a runaway retry loop.
144- **`statesman generate` warns** when a promise invoke has no `onError` and no
145 `after`: a failed or hung call then has no exit and stalls the actor.
146
147## Performance
148
149Apple M3, `go test -bench`:
150
151| Benchmark | ns/op | allocs/op |
152| ---------------------------------------------------- | ----- | --------- |
153| `SendSettle` (one synchronous transition round trip) | ~3100 | 63 |
154| `Snapshot` (lock-free reader) | ~0.3 | 0 |
155
156## Documentation
157
158- `statesman-architecture.md` — design, patterns, full decision table
159- `docs/transition-algorithm.md` — the SCXML-subset transition reduction
160- `docs/schema-subset.md` — the accepted `machine.json` subset and validation gates
161- `docs/persistence-contract.md` — the durability window and recovery contract
162- `DECISIONS.md` — decision log
163- `TODO.md` — phased build sequence and roadmap