[READ-ONLY] Mirror of https://github.com/andrioid/statesman. xstate inspired schema compatible statechart library for Go
fsm go statecharts
0

Configure Feed

Select the types of activity you want to include in your feed.

statesman / README.md
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