engine

package
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package engine builds and runs statecharts.

CreateMachine validates a MachineConfig into an immutable Machine, and Setup does the same with guards and actions registered by name. An Actor runs a machine: Actor.Start it, feed it events with Actor.Send, read it with Actor.Snapshot, and store or restore it with Actor.Persist and NewActorFromSnapshot.

The engine performs no I/O and reads no clock. Delayed transitions and invocations are exposed as data (Actor.PendingTimers, Actor.PendingInvocations) for a host to drive (Actor.FireTimer, Actor.ResolveInvocation, Actor.RejectInvocation).

The other packages hold the vocabulary a machine is written in: action for actions, guards and conditions, effect for timers and invocations, persist for the active configuration and snapshots, and describe for the type-erased view that tooling reads.

Example

Example builds a one-state counter machine, drives it with two events, and reads the accumulated context.

package main

import (
	"context"
	"fmt"

	"github.com/arisros/fate/action"
	"github.com/arisros/fate/engine"
)

func main() {
	type Ctx struct{ Count int }

	m, err := engine.CreateMachine(engine.MachineConfig[Ctx, string]{
		ID:      "counter",
		Initial: "active",
		States: map[string]engine.StateNodeConfig[Ctx, string]{
			"active": {On: map[string][]engine.TransitionConfig[Ctx, string]{
				"INC": {{Actions: []action.Action[Ctx, string]{
					action.Assign(func(c Ctx, _ string) Ctx { c.Count++; return c }),
				}}},
			}},
		},
	})
	if err != nil {
		panic(err)
	}

	a := engine.NewActor(m)
	_ = a.Start(context.Background())
	_ = a.Send(context.Background(), "INC")
	_ = a.Send(context.Background(), "INC")

	fmt.Println(a.Snapshot().Context.Count)
}
Output:
2
Example (DelayedTransition)

Example_delayedTransition shows the clock-agnostic timer model: the engine records a pending "after" timer but never fires it. A driver (here, the test itself; in production the fate/temporal adapter) decides the delay elapsed and calls FireTimer.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/arisros/fate/engine"
)

func main() {
	m, _ := engine.CreateMachine(engine.MachineConfig[struct{}, string]{
		ID:      "blink",
		Initial: "off",
		States: map[string]engine.StateNodeConfig[struct{}, string]{
			"off": {After: map[time.Duration][]engine.TransitionConfig[struct{}, string]{
				time.Hour: {{Target: "on"}},
			}},
			"on": {Type: engine.NodeFinal},
		},
	})

	a := engine.NewActor(m)
	_ = a.Start(context.Background())
	fmt.Println(a.Snapshot().Value.Path())

	// A driver pulls the pending timer and fires it once the delay elapses.
	a.FireTimer(a.PendingTimers()[0].ID)
	fmt.Println(a.Snapshot().Value.Path())
}
Output:
off
on
Example (Meta)

Example_meta attaches host data to a state and a transition and reads it back from the descriptor, where a form builder or a viewer would find it.

package main

import (
	"fmt"

	"github.com/arisros/fate/engine"
)

func main() {
	type state = engine.StateNodeConfig[struct{}, string]
	type transition = engine.TransitionConfig[struct{}, string]

	m, err := engine.CreateMachine(engine.MachineConfig[struct{}, string]{
		ID:      "task",
		Initial: "survey",
		States: map[string]state{
			"survey": {
				Meta: map[string]any{"form": "survey_form"},
				On: map[string][]transition{
					"SUBMIT": {{Target: "done", Meta: map[string]any{"title": "Submit", "order": 1}}},
				},
			},
			"done": {Type: engine.NodeFinal},
		},
	})
	if err != nil {
		panic(err)
	}

	survey := m.Describe().States["survey"]
	fmt.Println(string(survey.Meta))
	fmt.Println(string(survey.On["SUBMIT"][0].Meta))
}
Output:
{"form":"survey_form"}
{"order":1,"title":"Submit"}
Example (Persistence)

Example_persistence shows that an actor round-trips through a JSON snapshot: the restored actor continues from exactly where the original left off.

package main

import (
	"context"
	"fmt"

	"github.com/arisros/fate/action"
	"github.com/arisros/fate/engine"
)

func main() {
	type Ctx struct{ Count int }

	build := func() *engine.Machine[Ctx, string] {
		m, _ := engine.CreateMachine(engine.MachineConfig[Ctx, string]{
			ID:      "counter",
			Initial: "active",
			States: map[string]engine.StateNodeConfig[Ctx, string]{
				"active": {On: map[string][]engine.TransitionConfig[Ctx, string]{
					"INC": {{Actions: []action.Action[Ctx, string]{
						action.Assign(func(c Ctx, _ string) Ctx { c.Count++; return c }),
					}}},
				}},
			},
		})
		return m
	}

	a := engine.NewActor(build())
	_ = a.Start(context.Background())
	_ = a.Send(context.Background(), "INC")

	blob, _ := a.Persist()
	restored, _ := engine.NewActorFromSnapshot[Ctx, string](build(), blob)
	_ = restored.Send(context.Background(), "INC")

	fmt.Println(restored.Snapshot().Context.Count)
}
Output:
2
Example (Tooling)

Example_tooling annotates a guard with Gates and a state with UIStateOf, then reads both the way a viewer would: the gate from the descriptor, the view model from the live snapshot.

package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/arisros/fate/action"
	"github.com/arisros/fate/describe"
	"github.com/arisros/fate/engine"
)

func main() {
	type Ctx struct {
		Score int `json:"score"`
	}
	type ReviewView struct {
		Score  int  `json:"score"`
		Passes bool `json:"passes"`
	}

	m, err := engine.CreateMachine(engine.MachineConfig[Ctx, string]{
		ID:      "review",
		Initial: "pending",
		Context: Ctx{Score: 72},
		States: map[string]engine.StateNodeConfig[Ctx, string]{
			"pending": {
				UIState: describe.UIStateOf(func(c Ctx) ReviewView {
					return ReviewView{Score: c.Score, Passes: c.Score >= 60}
				}),
				On: map[string][]engine.TransitionConfig[Ctx, string]{
					"DECIDE": {{
						Target:   "approved",
						Guard:    func(c Ctx, _ string) bool { return c.Score >= 60 },
						CondMeta: action.Gates(action.Field("$.score").Gte(60)).Sample(`{"score":60}`),
					}},
				},
			},
			"approved": {Type: engine.NodeFinal},
		},
	})
	if err != nil {
		panic(err)
	}

	gate, _ := json.Marshal(m.Describe().States["pending"].On["DECIDE"][0].CondMeta)
	fmt.Println(string(gate))

	a := engine.NewActor(m)
	_ = a.Start(context.Background())
	s := a.Snapshot()
	views, _ := m.UIState(s.Value, s.Context)
	fmt.Println(string(views["pending"]))
}
Output:
{"fields":[{"path":"$.score","op":"gte","value":60}],"sample":{"score":60}}
{"score":72,"passes":true}

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidConfig is returned by CreateMachine when the supplied config
	// fails validation. The wrapped error gives the specific reason.
	ErrInvalidConfig = errors.New("statechart: invalid machine config")

	// ErrUnknownTarget is returned when a transition's Target string does not
	// resolve to any sibling, descendant, or ancestor state path.
	ErrUnknownTarget = errors.New("statechart: unknown transition target")

	// ErrNoInitial is returned when a compound state node lacks an Initial
	// field naming one of its children.
	ErrNoInitial = errors.New("statechart: compound state has no initial child")

	// ErrUnknownInitial is returned when an Initial field names a state that
	// is not among the node's children.
	ErrUnknownInitial = errors.New("statechart: initial state not found among children")

	// ErrDuplicateState is returned when two sibling states share a name.
	ErrDuplicateState = errors.New("statechart: duplicate sibling state name")

	// ErrInvalidNodeType is returned when a state node has a Type the current
	// skeleton does not yet support (e.g. NodeParallel, NodeHistory, NodeFinal
	// before P5).
	ErrInvalidNodeType = errors.New("statechart: state node type not supported in this build")

	// ErrSnapshotMismatch is returned by NewActorFromSnapshot when the
	// snapshot's state value is not a configuration of the supplied machine,
	// typically because the machine changed after the snapshot was taken.
	ErrSnapshotMismatch = errors.New("statechart: snapshot does not match machine")

	// ErrActorNotStarted is returned by Send when the actor's Start has not
	// been called yet.
	ErrActorNotStarted = errors.New("statechart: actor not started")

	// ErrActorStopped is returned by Send when the actor has been Stopped.
	ErrActorStopped = errors.New("statechart: actor stopped")
)

Functions

This section is empty.

Types

type Actor

type Actor[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Actor is the runtime instance of a statechart Machine. One Actor is instantiated per workflow execution / unit test. It reads no clock and starts no goroutine, so it is safe to drive from a Temporal workflow goroutine.

func NewActor

func NewActor[Ctx any, Evt any](m *Machine[Ctx, Evt], opts ...ActorOption) *Actor[Ctx, Evt]

NewActor constructs a fresh Actor in the Stopped status. Call Start to transition it to Running and observe the initial entry actions.

func NewActorFromSnapshot

func NewActorFromSnapshot[Ctx any, Evt any](m *Machine[Ctx, Evt], persisted []byte) (*Actor[Ctx, Evt], error)

NewActorFromSnapshot constructs an actor seeded from a JSON snapshot.

Restoration sequence:

  • Validates the snapshot version is supported.
  • Validates the state value against the machine, returning ErrSnapshotMismatch when it names a state the machine does not have, gives a compound state more than one active child, or leaves a parallel region out.
  • Rebuilds the history memory by resolving stored path strings to stateNode pointers within the supplied machine.
  • Restores any queued internal events.

The restored actor has the same status as when persisted; if it was running, it is running after restoration (no Start needed).

func (*Actor[Ctx, Evt]) Can

func (a *Actor[Ctx, Evt]) Can(evt Evt) bool

Can reports whether evt would be handled by the current configuration: that is, whether at least one transition selects for it once guards are evaluated against the current context. It does not mutate the actor.

Send deliberately drops an unhandled event, because in a statechart an event no state cares about is not an error. Can is the companion for callers that do treat it as one, and for which "the machine ignored that" must be distinguishable from "the machine acted on it":

if !actor.Can(evt) {
    return fmt.Errorf("%w: %s in %s", ErrUnhandledEvent, name, snap.Value.Path())
}
_ = actor.Send(ctx, evt)

Because guards are pure by contract, the answer is exact rather than an approximation, and asking costs nothing beyond the guard evaluations. Two boundaries are worth knowing. An actor that is not running reports false for every event, since a stopped or completed actor handles none. And Can answers about transition *selection*: a selected transition whose target cannot be resolved reports true here while changing no state, which is the same configuration error Send absorbs.

func (*Actor[Ctx, Evt]) Enabled added in v0.7.0

func (a *Actor[Ctx, Evt]) Enabled(byName func(name string) (Evt, bool)) []string

Enabled returns the names from Actor.NextEvents whose event would fire a transition now, guards evaluated, sorted. byName builds the event for a name and reports false for a name it does not know, which leaves that name out.

A guard that reads the event's payload sees the payload byName supplies, so the answer is exact only for the events byName builds.

func (*Actor[Ctx, Evt]) FireTimer

func (a *Actor[Ctx, Evt]) FireTimer(id effect.TimerID) bool

FireTimer fires the armed delayed transition with the given id. It is the write half of the pull-based timer interface (see Actor.PendingTimers) and is how an adapter delivers an elapsed "after" delay back to the machine. Firing an id that is not currently armed, or firing when the owning state is no longer active, is a safe no-op.

The reported bool is true when the timer was armed and its owning state was still active, so the delay reached the machine. A false result means the id was unknown, already fired, or cancelled by an exit. Adapters that reconcile timers against an external clock use it to tell a delivered delay from a late callback for a state the machine has already left; callers that do not care may discard it.

func (*Actor[Ctx, Evt]) NextEvents added in v0.7.0

func (a *Actor[Ctx, Evt]) NextEvents() []string

NextEvents returns the names of the events the active configuration declares a transition for, sorted. It reads every active state and its ancestors, the same handlers Send would consult, and leaves out the "*" wildcard.

Guards are not evaluated, because a guard needs an event value and a name is not one. Actor.Enabled lists only the events that would fire now.

An actor that is not running reports none.

Example

ExampleActor_NextEvents lists the events a task accepts in its current state and previews one of them without sending it.

package main

import (
	"context"
	"fmt"

	"github.com/arisros/fate/engine"
)

func main() {
	type Ctx struct{ Score int }
	type state = engine.StateNodeConfig[Ctx, string]
	type transition = engine.TransitionConfig[Ctx, string]

	m, err := engine.CreateMachine(engine.MachineConfig[Ctx, string]{
		ID:      "review",
		Initial: "open",
		Context: Ctx{Score: 40},
		States: map[string]state{
			"open": {On: map[string][]transition{
				"APPROVE": {{Target: "approved", Guard: func(c Ctx, _ string) bool { return c.Score >= 60 }}},
				"REJECT":  {{Target: "rejected"}},
			}},
			"approved": {Type: engine.NodeFinal},
			"rejected": {Type: engine.NodeFinal},
		},
	})
	if err != nil {
		panic(err)
	}

	a := engine.NewActor(m)
	_ = a.Start(context.Background())

	fmt.Println(a.NextEvents())
	fmt.Println(a.Enabled(func(name string) (string, bool) { return name, true }))

	next, _ := a.Preview("REJECT")
	fmt.Println(next.Value.Path(), next.Status)
	fmt.Println(a.Snapshot().Value.Path())
}
Output:
[APPROVE REJECT]
[REJECT]
rejected done
open

func (*Actor[Ctx, Evt]) PendingInvocations

func (a *Actor[Ctx, Evt]) PendingInvocations() []effect.PendingInvocation

PendingInvocations returns the actor's currently-armed invocations, in deterministic order (by ID). It is the read half of the invoke effect: an adapter runs each Src and reports the outcome via ResolveInvocation / RejectInvocation. The core never runs an invocation itself. See ADR-0004.

func (*Actor[Ctx, Evt]) PendingTimers

func (a *Actor[Ctx, Evt]) PendingTimers() []effect.PendingTimer

PendingTimers returns the actor's currently-armed delayed transitions, in deterministic order (by TimerID). It is the read half of the timer interface: an adapter arms its own durable or wall-clock timers from this list and calls Actor.FireTimer when a delay elapses. The core never fires a timer itself, so without an adapter pending timers simply remain armed. See ADR-0003.

func (*Actor[Ctx, Evt]) Persist

func (a *Actor[Ctx, Evt]) Persist() ([]byte, error)

Persist returns a JSON snapshot of the actor's state suitable for storage (e.g. ArangoDB) and later restoration via NewActorFromSnapshot.

Round-trip guarantee: NewActorFromSnapshot(m, actor.Persist()) produces an actor that, given the same future events, yields byte-identical Persist output to the original.

func (*Actor[Ctx, Evt]) Preview added in v0.7.0

func (a *Actor[Ctx, Evt]) Preview(evt Evt) (persist.Snapshot[Ctx], error)

Preview returns the snapshot Actor.Send would leave behind for evt, without changing the actor. Compare its Value with the current snapshot's to see where the event leads, or pass both to diff.Snapshots.

The event runs on a copy of the actor. When the machine sets MachineConfig.CloneContext the copy's context comes from it. Otherwise the copy is restored from Actor.Persist, so Preview fails where Persist does and a value held in an any comes back as its JSON form (a time.Time as a string, an int as a float64), which a guard that type-asserts will not match.

An event no transition handles yields the current snapshot unchanged; Actor.Can tells that apart from a transition that keeps the same state. Preview returns the error Send would: ErrActorStopped for an actor that is not running.

func (*Actor[Ctx, Evt]) RejectInvocation

func (a *Actor[Ctx, Evt]) RejectInvocation(id effect.InvokeID, err error) bool

RejectInvocation reports failure of the invocation with the given id. If it is still armed and declares OnError, the mapped event is processed as an internal step. Rejecting an unknown or already-settled id is a safe no-op.

The reported bool carries the same meaning as in Actor.ResolveInvocation: true when the invocation was accepted, false when the id was unknown, already settled, or owned by a state the machine has since left. An accepted invocation with no OnError mapper reports true and delivers no event, which is how a failure with no declared handler is silently absorbed.

func (*Actor[Ctx, Evt]) ResolveInvocation

func (a *Actor[Ctx, Evt]) ResolveInvocation(id effect.InvokeID, output any) bool

ResolveInvocation reports successful completion of the invocation with the given id. If it is still armed (its state still active) and declares OnDone, the mapped event is processed as an internal step. Resolving an unknown or already-settled id is a safe no-op. The reported bool is true when the invocation was still armed and its owning state still active, so the outcome was accepted. It is false when the id was unknown, already settled, or belongs to a state the machine has since left. Note that true means accepted, not that an event was delivered: an accepted invocation with no OnDone mapper settles without producing one. Adapters use this to tell a delivered result from a late one; callers that do not care may discard it.

func (*Actor[Ctx, Evt]) Send

func (a *Actor[Ctx, Evt]) Send(_ context.Context, evt Evt) error

Send dispatches an event to the actor synchronously. Returns after the event (and any events the transition raised internally) have been processed. Events that no transition handles are silently dropped.

If processing the event causes the actor to reach a top-level final state, its status transitions to StatusDone. Subsequent Sends are silently dropped (matching XState v5 semantics).

func (*Actor[Ctx, Evt]) Snapshot

func (a *Actor[Ctx, Evt]) Snapshot() persist.Snapshot[Ctx]

Snapshot returns the actor's current state. Safe to call concurrently.

func (*Actor[Ctx, Evt]) Start

func (a *Actor[Ctx, Evt]) Start(_ context.Context) error

Start moves the actor into Running and executes entry actions for the initial configuration chain (deepest entry's Entry runs last). Idempotent. If the initial configuration already lands in a top-level final state, the actor immediately transitions to StatusDone.

func (*Actor[Ctx, Evt]) Stop

func (a *Actor[Ctx, Evt]) Stop()

Stop terminates the actor and cancels any pending delayed transitions; subsequent Send returns ErrActorStopped.

func (*Actor[Ctx, Evt]) Subscribe

func (a *Actor[Ctx, Evt]) Subscribe(obs func(persist.Snapshot[Ctx])) func()

Subscribe registers an observer that is called with a snapshot after every Send (and once on Start, after entry actions). Returns an unsubscribe func.

func (*Actor[Ctx, Evt]) SubscribeSteps added in v0.8.0

func (a *Actor[Ctx, Evt]) SubscribeSteps(obs func(Step)) func()

SubscribeSteps registers an observer that receives every Step, in order, before the snapshot observers of Actor.Subscribe run. An event that fires no transition produces no step. Returns an unsubscribe func.

The observer runs while the actor is locked, so it must not call the actor; Step.Value carries the configuration it would otherwise read.

Example

ExampleActor_SubscribeSteps records what each step of an actor did: which transition fired and which states were left and entered.

package main

import (
	"context"
	"fmt"

	"github.com/arisros/fate/engine"
)

func main() {
	type state = engine.StateNodeConfig[struct{}, string]
	type transition = engine.TransitionConfig[struct{}, string]

	m, err := engine.CreateMachine(engine.MachineConfig[struct{}, string]{
		ID:      "task",
		Initial: "draft",
		States: map[string]state{
			"draft":  {On: map[string][]transition{"SUBMIT": {{Target: "review"}}}},
			"review": {On: map[string][]transition{"RETURN": {{Target: "review"}}}},
		},
	})
	if err != nil {
		panic(err)
	}

	a := engine.NewActor(m)
	a.SubscribeSteps(func(s engine.Step) {
		fmt.Println(s.Seq, s.Cause, s.Event, s.Exited, s.Entered)
	})
	_ = a.Start(context.Background())
	_ = a.Send(context.Background(), "SUBMIT")
	_ = a.Send(context.Background(), "RETURN")
}
Output:
1 start  [] [draft]
2 event SUBMIT [draft] [review]
3 event RETURN [review] [review]

type ActorOption

type ActorOption func(*actorOpts)

ActorOption configures a new Actor.

func WithInitialValue

func WithInitialValue[Ctx any, Evt any](v persist.StateValue) ActorOption

WithInitialValue overrides the actor's starting state. Used by NewActorFromSnapshot (P6) and by tests that need to seed mid-flight. The value must be a valid configuration of the machine; this is not re-validated in the skeleton.

func WithLogger

func WithLogger(fn func(string)) ActorOption

WithLogger sets the function called by Log actions and internal warnings. Default: a no-op (logs are discarded).

type Finding added in v0.10.0

type Finding struct {
	Kind FindingKind `json:"kind"`
	// State is the dot path of the state the finding is about.
	State   string `json:"state"`
	Message string `json:"message"`
}

Finding is one problem Lint found in a machine.

type FindingKind added in v0.10.0

type FindingKind string

FindingKind names what Lint found.

const (
	// FindingUnreachable is a state no initial chain or transition enters.
	FindingUnreachable FindingKind = "unreachable"
	// FindingDeadEnd is a state that is not final and that no transition, on
	// it or on an ancestor, leaves.
	FindingDeadEnd FindingKind = "dead_end"
	// FindingOnDoneNeverFires is a state that declares OnDone and can never
	// complete: a compound state with no final child, or a parallel state with
	// a region that cannot complete.
	FindingOnDoneNeverFires FindingKind = "on_done_never_fires"
)

The kinds of Finding that Lint reports.

type History

type History uint8

History selects the depth of memory for a NodeHistory pseudo-state.

  • HistoryShallow remembers only the immediate child of the parent compound. On re-entry, the parent restarts that child via the child's initial chain.
  • HistoryDeep remembers the full descendant configuration. On re-entry, the entire active sub-tree at exit time is restored.
const (
	HistoryShallow History = iota
	HistoryDeep
)

type Machine

type Machine[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Machine is an immutable, validated statechart. Safe to share across goroutines and across multiple Actor instances. Construct via CreateMachine; never mutate.

func CreateMachine

func CreateMachine[Ctx any, Evt any](cfg MachineConfig[Ctx, Evt]) (*Machine[Ctx, Evt], error)

CreateMachine validates a MachineConfig and returns an immutable *Machine. Returns ErrInvalidConfig (with a descriptive wrapped error) for malformed configurations.

func (*Machine[Ctx, Evt]) Describe

func (m *Machine[Ctx, Evt]) Describe() describe.MachineDescriptor

Describe returns a MachineDescriptor for the machine. The context is JSON-marshaled if possible; on marshal failure (e.g. a Ctx containing a channel) the Context field is left nil and the rest of the descriptor still renders correctly.

Action names come from each value's ImplName() method: the built-in actions report their kind ("assign", "raise:CANCEL", "log"), and action.Named attaches a caller-chosen label. Guard names come from TransitionConfig.GuardName, since a func value carries no name a descriptor could recover. Anything unnamed falls back to "".

func (*Machine[Ctx, Evt]) ID

func (m *Machine[Ctx, Evt]) ID() string

ID returns the machine's configured identifier.

func (*Machine[Ctx, Evt]) IsKnownState

func (m *Machine[Ctx, Evt]) IsKnownState(name string) bool

IsKnownState reports whether `name` is a valid state name anywhere in the machine. The check is recursive — it matches both top-level states and nested children. This mirrors the legacy fp.StateMachine.AsStateValidator behavior used by LPW.

func (*Machine[Ctx, Evt]) IsLegalTransition

func (m *Machine[Ctx, Evt]) IsLegalTransition(from string, eventName string) bool

IsLegalTransition reports whether `eventName` declared on state `from` (or any of its ancestors, mirroring transition bubbling at runtime) has at least one candidate transition. It does NOT evaluate guards — guards require an event payload and context, neither of which are available here.

Use this when you want stricter-than-set-membership validation. The LPW port keeps the legacy set-membership default (via IsKnownState) for backward compat per migration-playbook P12 decision; opt into IsLegalTransition where stricter checks are wanted.

func (*Machine[Ctx, Evt]) IsTerminal

func (m *Machine[Ctx, Evt]) IsTerminal(name string) bool

IsTerminal reports whether `name` is a state with Type == NodeFinal. Replaces the legacy IsTerminalStatus consumer in termination.go.

func (*Machine[Ctx, Evt]) Lint added in v0.10.0

func (m *Machine[Ctx, Evt]) Lint() []Finding

Lint reports states that are legal but probably mistakes: unreachable states, dead ends, and OnDone transitions that can never fire. CreateMachine accepts all of them, so call Lint from a test to keep a machine clean.

The analysis reads structure only. Guards are assumed able to pass, so a state behind a guard that is never true is still counted as reachable. Findings are sorted by state path, then kind; a clean machine returns nil.

Example

ExampleMachine_Lint finds a state nothing enters and a state nothing leaves.

package main

import (
	"fmt"

	"github.com/arisros/fate/engine"
)

func main() {
	type state = engine.StateNodeConfig[struct{}, string]
	type transition = engine.TransitionConfig[struct{}, string]

	m, err := engine.CreateMachine(engine.MachineConfig[struct{}, string]{
		ID:      "task",
		Initial: "draft",
		States: map[string]state{
			"draft":    {On: map[string][]transition{"SUBMIT": {{Target: "review"}}}},
			"review":   {},
			"returned": {On: map[string][]transition{"SUBMIT": {{Target: "review"}}}},
		},
	})
	if err != nil {
		panic(err)
	}

	for _, f := range m.Lint() {
		fmt.Println(f.State, f.Kind)
	}
}
Output:
returned unreachable
review dead_end

func (*Machine[Ctx, Evt]) States

func (m *Machine[Ctx, Evt]) States() []string

States returns the names of every state in the machine (top-level + nested) in deterministic order: top-down, alphabetical within siblings. Used by schema-vs-FSM enum sync checks (LPW expects status enum to match machine states exactly).

func (*Machine[Ctx, Evt]) UIState

func (m *Machine[Ctx, Evt]) UIState(v persist.StateValue, ctx Ctx) (map[string]json.RawMessage, error)

UIState evaluates the view models of the active configuration v against ctx, keyed by the dot path of the state that declares each one.

For each active leaf, the nearest state on its path (the leaf itself or an ancestor) that declares a UIState contributes, once even when several leaves share it. The result is nil when no active state declares one. A view model that fails to marshal, or whose function panics, returns an error naming the state.

type MachineConfig

type MachineConfig[Ctx any, Evt any] struct {
	// ID is a human-readable identifier used in inspection output and as the
	// stable prefix for spawn IDs (per ADR-002).
	ID string

	// Initial is the starting child state name. Required.
	Initial string

	// Context is the seed value for the actor's running context.
	Context Ctx

	// CloneContext, if set, returns a copy of a context that shares no mutable
	// state with the original. Set it when Ctx holds a map, slice or pointer.
	// NewActor uses it so actors of one machine do not share the seed, and
	// Actor.Preview uses it in place of a JSON round trip, which keeps the Go
	// types of values held in an any (a time.Time stays a time.Time).
	CloneContext func(Ctx) Ctx

	// States is the map of immediate child state nodes. Keys are local state
	// names (e.g. "idle"); values describe each node.
	States map[string]StateNodeConfig[Ctx, Evt]
}

MachineConfig declares an immutable statechart. Pass to CreateMachine to validate and obtain a *Machine.

Generics: Ctx is the user's context (data accumulated as the machine runs); Evt is the user's event type (typically a sealed interface).

type NodeType

type NodeType uint8

NodeType discriminates state node kinds. As of P5, Atomic, Compound, Final, and History are supported; Parallel is the remaining P5 piece.

const (
	NodeAtomic NodeType = iota
	NodeCompound
	NodeParallel // P5 follow-up
	NodeFinal
	NodeHistory
)

func (NodeType) String

func (t NodeType) String() string

String returns the textual name of the node type. Used in error messages and snapshot debugging output.

type SelectedTransition

type SelectedTransition[Ctx any, Evt any] struct {
	Source *stateNode[Ctx, Evt]
	Config TransitionConfig[Ctx, Evt]
}

SelectedTransition records the outcome of selectTransitions per active leaf: the resolved source node and the matching transition config.

type Setup

type Setup[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Setup is a type-safe registry of named guards and actions, mirroring XState v5's setup({ guards, actions }) ergonomic. Register implementations once, then reference them by name while declaring a MachineConfig via the Setup.Guard and Setup.Action accessors. This keeps large machine configs readable and lets several transitions share one implementation.

Setup is sugar over CreateMachine; it adds no semantics the declarative config cannot express. A typical use:

s := engine.NewSetup[Ctx, Evt]().
	WithGuard("isHighRisk", func(c Ctx, _ Evt) bool { return c.Risk == "HIGH" }).
	WithAction("clearForm", action.Assign(func(c Ctx, _ Evt) Ctx { c.Form = nil; return c }))

m, err := s.CreateMachine(engine.MachineConfig[Ctx, Evt]{
	ID: "review", Initial: "open",
	States: map[string]engine.StateNodeConfig[Ctx, Evt]{
		"open": {On: map[string][]engine.TransitionConfig[Ctx, Evt]{
			"NEXT": {{Target: "closed", Guard: s.Guard("isHighRisk"),
				Actions: []action.Action[Ctx, Evt]{s.Action("clearForm")}}},
		}},
		"closed": {Type: engine.NodeFinal},
	},
})

Referencing a name that was never registered is reported as an error from Setup.CreateMachine, so typos surface at construction time rather than silently doing nothing.

func NewSetup

func NewSetup[Ctx any, Evt any]() *Setup[Ctx, Evt]

NewSetup returns an empty registry. Register entries with Setup.WithGuard and Setup.WithAction (both chainable).

func (*Setup[Ctx, Evt]) Action

func (s *Setup[Ctx, Evt]) Action(name string) action.Action[Ctx, Evt]

Action returns the action registered under name for use in a TransitionConfig or a state's Entry/Exit. If no action is registered under name, Action records the missing reference (so Setup.CreateMachine returns an error) and returns a no-op action.

The returned action carries name, so it appears under that name in a describe.MachineDescriptor and in every rendered diagram, without the caller repeating it through action.Named. Wrapping does not change how the action runs.

func (*Setup[Ctx, Evt]) CreateMachine

func (s *Setup[Ctx, Evt]) CreateMachine(cfg MachineConfig[Ctx, Evt]) (*Machine[Ctx, Evt], error)

CreateMachine validates and builds the machine, first reporting any guard or action names referenced via Setup.Guard / Setup.Action that were never registered. On success it is identical to calling CreateMachine directly.

func (*Setup[Ctx, Evt]) Guard

func (s *Setup[Ctx, Evt]) Guard(name string) action.Guard[Ctx, Evt]

Guard returns the guard registered under name for use in a TransitionConfig. If no guard is registered under name, Guard records the missing reference (so Setup.CreateMachine returns an error) and returns a guard that never passes, keeping config construction safe to continue.

func (*Setup[Ctx, Evt]) WithAction

func (s *Setup[Ctx, Evt]) WithAction(name string, a action.Action[Ctx, Evt]) *Setup[Ctx, Evt]

WithAction registers an action under name and returns the Setup for chaining. Registering the same name twice replaces the earlier action.

func (*Setup[Ctx, Evt]) WithGuard

func (s *Setup[Ctx, Evt]) WithGuard(name string, g action.Guard[Ctx, Evt]) *Setup[Ctx, Evt]

WithGuard registers a guard under name and returns the Setup for chaining. Registering the same name twice replaces the earlier guard.

type StateNodeConfig

type StateNodeConfig[Ctx any, Evt any] struct {
	// Type is the node kind. If zero, it is inferred: NodeAtomic when States
	// is empty; NodeCompound otherwise.
	Type NodeType

	// Initial is the starting child state name. Required when Type is
	// NodeCompound and States is non-empty.
	Initial string

	// States declares immediate child state nodes (compound nesting).
	States map[string]StateNodeConfig[Ctx, Evt]

	// On maps event names to ordered transition candidates. The first
	// candidate whose guard passes (or has no guard) is selected.
	On map[string][]TransitionConfig[Ctx, Evt]

	// After declares delayed transitions, keyed by delay. When this state is
	// entered the actor records one pending timer per delay; exiting the state
	// disarms them. The core never fires a timer itself (it is clock-agnostic):
	// an adapter discovers armed timers via Actor.PendingTimers and delivers an
	// elapsed delay via Actor.FireTimer, at which point the first transition in
	// that delay's slice whose Guard and Cond pass fires (as an internal step
	// with the zero Evt). Mirrors XState's `after`. See ADR-0003.
	After map[time.Duration][]TransitionConfig[Ctx, Evt]

	// Invoke declares external work run while this state is active (XState's
	// invoke). On entry each invocation is recorded as pending; on exit it is
	// disarmed. The core never executes an invocation — an adapter pulls them
	// via Actor.PendingInvocations and reports outcomes via
	// Actor.ResolveInvocation / Actor.RejectInvocation. See ADR-0004.
	Invoke []effect.Invocation[Ctx, Evt]

	// Entry actions run, in declaration order, when this state is entered.
	// For a compound node, Entry runs before the child's Entry.
	Entry []action.Action[Ctx, Evt]

	// Exit actions run, in declaration order, when this state is exited.
	// For a compound node, Exit runs after the child's Exit (deepest first).
	Exit []action.Action[Ctx, Evt]

	// OnDone declares transitions to fire when this node completes: a compound
	// node when its active child reaches a final state, a parallel node when
	// every region has. Empty for atomic / final nodes.
	OnDone []TransitionConfig[Ctx, Evt]

	// History selects HistoryShallow or HistoryDeep when Type is NodeHistory.
	// Ignored for other node types.
	History History

	// Default is the fallback target for a NodeHistory pseudo-state when
	// no prior memory exists. Optional; if empty, the parent compound's
	// initial child is used.
	Default string

	// Output, set only on a NodeFinal state, builds the machine's output value
	// from the final context when a top-level final state is reached. The
	// result is JSON-marshaled into the snapshot's Output field. Mirrors
	// XState's final-state output.
	Output func(ctx Ctx) any

	// Meta is data for tooling and hosts: a form name, a task type, a display
	// order. The engine never reads it. CreateMachine encodes it as JSON, so
	// values must be JSON-marshalable, and Describe publishes it as "meta".
	Meta map[string]any

	// UIState projects the context into a view model while this state is
	// active. Build it with UIStateOf. See Machine.UIState.
	UIState *describe.UIState[Ctx]
}

StateNodeConfig declares one state node within a machine. State nodes nest via the States field to form compound hierarchies.

type Step added in v0.8.0

type Step struct {
	// Seq numbers the steps of an actor from 1. It is stored in the snapshot,
	// so it keeps counting after a restore.
	Seq   uint64    `json:"seq"`
	Cause StepCause `json:"cause"`
	// Event is the event's name for StepEvent, StepRaise and StepInvoke.
	Event string `json:"event,omitempty"`
	// Effect is the timer or invocation id for StepTimer and StepInvoke.
	Effect      string           `json:"effect,omitempty"`
	Transitions []StepTransition `json:"transitions,omitempty"`
	Exited      []string         `json:"exited,omitempty"`
	Entered     []string         `json:"entered,omitempty"`
	// Value is the active configuration after the step.
	Value persist.StateValue `json:"value"`
}

Step records what one step of an actor did. A single Send produces one step for the event and one more for each event its actions raised and each OnDone that followed, in the order they ran.

Exited and Entered list state paths in the order their exit and entry actions ran. A state that a step leaves and enters again appears in both, which is how a host tells a restarted state from one that stayed active: its timers and invocations keep the same ids across the re-entry.

type StepCause added in v0.8.0

type StepCause string

StepCause says what set a step off.

const (
	// StepStart is Actor.Start entering the initial configuration.
	StepStart StepCause = "start"
	// StepEvent is an event passed to Actor.Send.
	StepEvent StepCause = "event"
	// StepRaise is an event an action raised, taken from the internal queue.
	StepRaise StepCause = "raise"
	// StepDone is an OnDone transition fired by a completed state.
	StepDone StepCause = "done"
	// StepTimer is a delayed transition delivered by Actor.FireTimer.
	StepTimer StepCause = "timer"
	// StepInvoke is the event an invocation outcome mapped to.
	StepInvoke StepCause = "invoke"
)

The causes a Step can have.

type StepTransition added in v0.8.0

type StepTransition struct {
	Source   string `json:"source"`
	Target   string `json:"target,omitempty"`
	Internal bool   `json:"internal,omitempty"`
}

StepTransition is one transition that fired in a step. Target is empty for a transition that only runs actions, and names the state actually entered when the declared target was a history state.

type TransitionConfig

type TransitionConfig[Ctx any, Evt any] struct {
	// Target is the destination state, named by its local name (sibling)
	// or by a dot-separated descendant path (e.g. "parent.child").
	// An empty Target means the transition is internal (no state change).
	Target string

	// Internal, when true, suppresses exit/re-entry of the source state for
	// targets that are descendants of the source (matches XState's
	// `internal: true`). Default false (external transition).
	Internal bool

	// Guard, if non-nil, must return true for the transition to be selected.
	// Otherwise the next candidate in the slice is tried, then ancestors are
	// consulted. A Guard is a pure predicate over context and event.
	Guard action.Guard[Ctx, Evt]

	// GuardName labels Guard in a [describe.MachineDescriptor], and through it in every
	// rendered diagram. Guard is a func value with no identity a descriptor can
	// recover, so a guard is unnamed unless it is named here. Optional; an
	// unnamed guard renders as "".
	GuardName string

	// Cond, if non-nil, is a structural condition over the active state
	// configuration (see Cond / StateIn / InState). When both Guard and Cond
	// are set, the transition is selected only if both pass. Use Cond for
	// "in state X" checks that a context/event Guard cannot express.
	Cond action.Cond

	// Actions run after exit actions and before entry actions when the
	// transition fires. Order: declaration order.
	Actions []action.Action[Ctx, Evt]

	// Meta is data for tooling and hosts about this transition: a button
	// title, an order, a hidden flag. The engine never reads it. It follows the
	// rules of StateNodeConfig.Meta and is accepted on On and OnDone
	// transitions.
	Meta map[string]any

	// CondMeta documents the context fields Guard checks, for tooling only.
	// It does not change whether the transition fires. Build it with Gates.
	CondMeta *action.CondMeta
	// contains filtered or unexported fields
}

TransitionConfig declares one possible transition for an event.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL