gstate

package module
v0.3.1 Latest Latest
Warning

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

Go to latest
Published: May 22, 2026 License: MIT Imports: 12 Imported by: 0

README

gstate

A type-safe Statechart library for Go, inspired by XState.

gstate allows you to model complex application logic using finite state machines and statecharts. Unlike traditional logic scattered across if/else blocks and boolean flags, statecharts provide a formal, visual, and structured way to define how your system behaves.

What is a Statechart?

A Statechart is an extension of a Finite State Machine (FSM). While a basic FSM has a set of states and transitions, a Statechart adds:

  • Hierarchy: States can contain other states (Nested States).
  • Orthogonality: Multiple states can be active at once (Parallel States).
  • Broadcast: Events can trigger transitions in multiple regions.
  • History: The ability to "remember" where you were before leaving a state.

Installation

go get github.com/floodfx/gstate

1. The Basics: States, Events, and Transitions

Every statechart starts with three core concepts:

  • State: A specific condition or "mode" of your system (e.g., Idle, Loading, Success).
  • Event: Something that happens (e.g., START, MOUSE_CLICK, TIMEOUT).
  • Transition: A rule that says: "When in state A, if event E happens, move to state B."
Example: A Simple Toggle with Typed Constants
type MyState string
type MyEvent string

const (
    StateOff MyState = "off"
    StateOn  MyState = "on"
)

const (
    EventToggle MyEvent = "TOGGLE"
)

// Define a simple data type that implements the Cloner interface
type MyData struct{}

func (d MyData) Clone() MyData {
    return d
}

machine := gstate.New[MyState, MyEvent, MyData]("toggle").
    Initial(StateOff).
    State(StateOff, func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
        s.On(EventToggle).GoTo(StateOn)
    }).
    State(StateOn, func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
        s.On(EventToggle).GoTo(StateOff)
    }).
    Build()

Try it: basics example — states, events, transitions, data, and entry/exit actions.


2. Type Safety & Generics

One of the core strengths of gstate is its use of Go 1.18+ generics to provide strict type safety.

The library uses three generic parameters: [S ~string, E ~string, D Cloner[D]].

  • S (State ID): By using a custom string type (e.g., type MyState string), you ensure that Initial(), State(), and GoTo() only accept valid state identifiers.
  • E (Event ID): Similarly, On(event) only accepts events of your specific type.
  • D (Data): The data your machine holds is strictly typed, and MUST satisfy the gstate.Cloner[D] constraint. Actions and guards receive this exact type, eliminating the need for dynamic casting and guaranteeing thread-safe reads/writes during actor execution.

Benefits:

  • No Typos: Compilers will catch actor.Send("TYPO") if your event type is strictly defined.
  • IDE Support: Autocomplete works for states, events, and data fields.
  • Safety: Guards and Actions are verified at compile time to work with your specific data structure.

3. Managing State Data (Assign)

Statecharts aren't just about labels; they often need to hold data. In gstate, this is called Data.

Transitions can perform Actions to update this data. In Go, these are pure functions: func(D) D.

type CounterData struct {
    Count int
}

s.On("INCREMENT").
    Assign(func(d CounterData) CounterData {
        d.Count++
        return d
    })
Thread Safety via Cloner Constraint

To guarantee thread-safe read/write isolation when snapshotting or observing a running Actor, the Data type D must satisfy the Cloner[D] constraint.

If your Data consists solely of value types (like struct{ Count int }), implementing Clone() is as simple as returning c:

func (d MyData) Clone() MyData {
    return d
}

If your data type contains reference types (pointers, slices, maps), you must perform a deep copy inside Clone() to ensure true isolation:

type MyData struct {
    Data []int
}

func (d MyData) Clone() MyData {
    newData := make([]int, len(c.Data))
    copy(newData, c.Data)
    return MyData{Data: newData}
}

4. Entry and Exit Actions

States can define actions that run whenever they are entered or exited. This is useful for setup/teardown, logging, or any side effect tied to a state's lifecycle.

s.State(StateActive, func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
    s.Entry(func(d MyData) MyData {
        fmt.Println("[active] Entering state...")
        return d
    })

    s.Exit(func(d MyData) MyData {
        fmt.Println("[active] Leaving state...")
        return d
    })

    s.On(EventStop).GoTo(StateIdle)
})
  • Entry runs when the state is entered, before any child states are resolved.
  • Exit runs when the state is left, as part of the transition.

Optional EntryLabel(name) / ExitLabel(name) attach a human-readable name to the actions. The name doesn't change runtime behavior; it shows up inside the state's node in Mermaid output (see §Mermaid Diagrams) so a reader of the generated diagram can tell what runs on entry/exit without reading the builder code.

s.Entry(loadUserPrefs)
s.EntryLabel("loadUserPrefs")

5. Hierarchical (Nested) States

In a complex system, some states are "sub-modes" of others. For example, a User state might have Guest and LoggedIn sub-states.

Why use this?

  • Bubbling: If a child state doesn't handle an event, it "bubbles up" to the parent.
  • Organization: Group related logic together.
  • Common Actions: Define an Entry action on a parent that runs regardless of which child is entered.
s.State("parent", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
    s.Initial("childA")
    
    // If ANY child receives "RESET", we go to "parent.childA"
    s.On("RESET").GoTo("childA")

    s.State("childA", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) { ... })
    s.State("childB", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) { ... })
})

Try it: hierarchy example — nested states with event bubbling and entry/exit ordering.


6. History States

History allows a compound state to remember which of its children was active before it was exited. When you re-enter the state, it resumes where it left off instead of going to the Initial child.

Two history types are available:

  • gstate.Shallow: Remembers the direct child that was active.
  • gstate.Deep: Remembers all active descendants in the hierarchy.
machine := gstate.New[MyState, MyEvent, MyData]("history_demo").
    Initial("app").
    State("app", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
        s.History(gstate.Shallow)
        s.Initial("screen1")

        s.State("screen1", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
            s.On("SWITCH").GoTo("screen2")
        })
        s.State("screen2", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
            s.On("SWITCH").GoTo("screen1")
        })

        s.On("GO_IDLE").GoTo("idle")
    }).
    State("idle", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
        s.On("WAKE").GoTo("app")
    }).
    Build()

In this example, if the user navigates to screen2 and then goes idle, WAKE will return them to screen2 (not the initial screen1).

Try it: history example — shallow history remembers which child was active.


7. Parallel States

Sometimes a system is in multiple modes at once. A text editor might be Focused while also having Bold enabled.

Parallel states allow you to define regions that operate independently. Use actor.States() to see all active states.

s.State("active", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
    s.Type(gstate.Parallel)

    s.State("keyboard", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
        s.Initial("caps_off")
        s.State("caps_off", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
            s.On("CAPS_LOCK").GoTo("caps_on")
        })
        s.State("caps_on", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
            s.On("CAPS_LOCK").GoTo("caps_off")
        })
    })

    s.State("mouse", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
        s.Initial("not_clicked")
        s.State("not_clicked", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
            s.On("CLICK").GoTo("clicked")
        })
        s.State("clicked", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
            s.On("RELEASE").GoTo("not_clicked")
        })
    })
})

// ...
fmt.Printf("Active States: %v\n", actor.States())
// Output: Active States: [active keyboard caps_off mouse not_clicked]

Try it: parallel example — independent keyboard and mouse regions.


8. Side Effects: Invoke and After

Invoked Services (Invoke)

Used for asynchronous work (like an API call). The service starts when you enter the state and is automatically cancelled (via context.Context) if you leave the state before it finishes.

To prevent data races between concurrent transitions and background goroutines, mutations from inside an Invoke must go through the provided mutate callback:

s.Invoke(func(ctx context.Context, snap MyData, mutate func(func(MyData) MyData)) error {
    // Use `snap` to read the state data at entry.
    // Use the thread-safe `mutate` callback to safely update data.
    mutate(func(d MyData) MyData {
        d.Value = "updated"
        return d
    })
    return doExpensiveWork(ctx)
}, "onSuccessState", "onErrorState")

The mutate callback accepts a function that receives the current data and returns the updated data. This update runs under the actor's internal write lock, ensuring complete race-free synchronization. If the state is exited or the actor stops before the callback runs, the mutation is safely ignored (no-op'd) to prevent stale/obsolete writes.

Optional InvokeLabel(name) names the invocation for Mermaid output. When both success and error targets are set, the labeled invoke renders as a diamond pseudo-state with invoke.done and invoke.error outgoing arrows — see §Mermaid Diagrams.

s.Invoke(callLLM, "checking_response", "failed")
s.InvokeLabel("call_llm")
Delayed Transitions (After)

Transitions that happen automatically after a duration.

s.State("loading", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
    // If we are stuck here for 5 seconds, move to "error"
    s.After(5 * time.Second).GoTo("error")
})

Try it: invoke example — async services with cancellation. delayed example — automatic timeouts.


9. Transient Logic (Always)

Always transitions fire immediately if their Guard (a condition function) is met. They don't wait for an external event. This is useful for "decider" states.

s.State("check_balance", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
    s.Always().
        Guard(func(d MyData) bool { return d.Balance > 100 }).
        GoTo("premium_user")
    
    s.Always().GoTo("regular_user") // Fallback
})

10. Final States

A Final state indicates the completion of its parent's process. Once entered, no further transitions are processed from that state.

s.State("done", func(s *gstate.StateBuilder[MyState, MyEvent, MyData]) {
    s.Type(gstate.Final)
})

Try it: agent example — guards, always transitions, retries, and final states in a real workflow.


11. Build-Time Static Validation

To prevent silent runtime errors (such as transitions to non-existent states due to typos), gstate performs static-analysis validation at build time when you call Build().

If a machine definition violates any structural rules, Build() will fail-fast by panicking with a clear, descriptive message prefixed with gstate:.

Validation Rules
  1. Initial States:
    • The top-level machine Initial(state) reference must point to a valid, declared state.
    • For any compound state, its Initial(state) reference must point to a valid child state declared within that compound parent.
  2. Transition Targets:
    • Any target specified in a .GoTo(state) transition (for standard event transitions, Always transitions, and After delayed transitions) must point to a valid, declared state (targetless / internal transitions are allowed).
  3. Invoke Targets:
    • The onDone and onError targets specified in s.Invoke(handler, onDone, onError) must point to valid, declared states.
Example Validation Failures
// Panics: "gstate: initial state 'invalid_state' not found in machine"
machine := gstate.New[MyState, MyEvent, any]("invalid_initial").
    Initial("invalid_state").
    Build()

// Panics: "gstate: transition target 'typo_state' in state 'idle' not found in machine"
machine := gstate.New[MyState, MyEvent, any]("invalid_target").
    Initial("idle").
    State("idle", func(s *gstate.StateBuilder[MyState, MyEvent, any]) {
        s.On("START").GoTo("typo_state")
    }).
    Build()

12. Observing Lifecycle Events

When you want to record transitions, guard outcomes, state entries/exits, transition actions, and invoked services — for telemetry, tracing, audit logs, or just to debug what your machine is doing — you do not need to wrap every Entry, Exit, Guard, and Invoke by hand. Pass one or more observers to Start:

rec := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Start(machine, MyData{}, machine.WithObservers(rec))

The machine.WithObservers(...) form lets Go infer the [MyState, MyEvent, MyData] type parameters from machine, so you don't have to repeat them on every option. WithObservers is variadic — pass any number of observers and they all receive callbacks for the kinds they implement.

Observer[S, E, D] is a sealed marker interface; you opt into specific callback kinds by implementing any of the nine narrow observer interfaces. The engine builds and dispatches each payload only when at least one installed observer subscribes to that kind, so unused hooks cost nothing.

Narrow interface Method Fires when
EventReceivedObserver OnEventReceived An event lands in the mailbox and is about to be processed
GuardObserver OnGuardEvaluated A non-nil Guard was evaluated (carries the boolean result)
EventDroppedObserver OnEventDropped An event was processed but no transition fired (Reason: "no_transition")
StateExitedObserver OnStateExited A state's Exit actions completed and the state was removed from active
ActionObserver OnActionExecuted A transition's Action (Assign) completed
StateEnteredObserver OnStateEntered A state's Entry actions completed and the state is now active
TransitionObserver OnTransition A transition fully resolved (after all exits + entries)
InvokeStartedObserver OnInvokeStarted An invoked service goroutine was launched
InvokeCompletedObserver OnInvokeCompleted An invoked service returned (success, error, or cancellation)
Threading and locking contract
  • All callbacks except OnInvokeCompleted run synchronously on the actor's event-processing goroutine while it holds the actor's internal write lock. This includes OnInvokeStarted, which fires when an invoked service is launched during state entry. Implementations must be non-blocking.
  • Observers must not call methods on the same Actor that would re-enter the actor lock (e.g. Snapshot(), State()).
  • Observers must not call Send / SendCtx synchronously: the channel send can block on a full mailbox, and the loop goroutine that would drain it cannot acquire the actor lock the observer is holding — a hard deadlock. If you need to dispatch an event from an observer, do it from a fresh goroutine:
    func (o *myObs) OnTransition(_ context.Context, e *gstate.TransitionEvent[...]) {
        go func() { actor.Send(EventX) }()
    }
    
  • Payload structs expose data via a lazy Data() method (rather than a field). The first call to e.Data() clones the actor's data via Cloner.Clone() and caches the result with sync.Once; subsequent calls (including from other observers receiving the same payload pointer) return the cached pointer without re-cloning. Observers that don't need the data pay zero clones. Reading is safe; mutating the pointee has no effect on the actor.
  • When multiple observers subscribe to the same callback kind, the engine builds the payload once and passes the same pointer to each. The shared sync.Once on the payload guarantees at most one clone per callback firing across the whole fan-out.
  • OnInvokeCompleted fires from the invoke goroutine and does not hold the actor lock.
Implementing only the methods you care about (BaseObserver)

Embed BaseObserver[S, E, D] to satisfy the marker interface, then implement only the narrow observer interfaces you need. Any callback you don't implement simply isn't dispatched to you — there are no required stubs.

type loggingObs struct {
    gstate.BaseObserver[MyState, MyEvent, MyData]
}

// implements TransitionObserver — every other callback kind is skipped for this observer
func (l *loggingObs) OnTransition(ctx context.Context, e *gstate.TransitionEvent[MyState, MyEvent, MyData]) {
    log.Printf("[%s] %s --%s--> %s", e.ActorID, e.From, e.Event, e.To)
}

A single observer type may implement any subset (or all nine) of the narrow interfaces — the engine independently type-asserts your value against each one at install time and only dispatches the kinds you implement.

Wake on any lifecycle event with SignalObserver
ready := make(chan struct{}, 1)
obs := gstate.SignalObserver[MyState, MyEvent, MyData](func() {
    select { case ready <- struct{}{}: default: }
})
actor := gstate.Start(machine, ctx, machine.WithObservers(obs))
actor.Send(EventGo)
<-ready // deterministically woken by the first lifecycle callback

Every callback on SignalObserver calls the supplied function. The callback's context and typed payload are discarded — SignalObserver is intentionally minimal. If you need them, use ObserverFuncs (below). The signal function must be non-blocking — observer callbacks run synchronously under the actor's write lock.

Avoid boilerplate with ObserverFuncs
obs := gstate.ObserverFuncs[MyState, MyEvent, MyData]{
    AnyFunc: func(ctx context.Context) {
        // fires for every callback
    },
    TransitionFunc: func(ctx context.Context, e *gstate.TransitionEvent[MyState, MyEvent, MyData]) {
        log.Printf("[%s] %s --%s--> %s", e.ActorID, e.From, e.Event, e.To)
    },
}
actor := gstate.Start(machine, ctx, machine.WithObservers(obs))

ObserverFuncs is a struct of optional function fields plus a generic AnyFunc. Each callback dispatches to AnyFunc first (if set), then to the kind-specific field (if set). Nil fields are no-ops. Useful when you want a partial observer without defining a named type, or when one hook should fire for every event in addition to specific typed handlers.

Inspecting behavior with RecordingObserver

RecordingObserver[S, E, D] captures every callback into a thread-safe log. It is useful in tests and for ad-hoc debugging:

rec := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Start(machine, MyData{}, machine.WithObservers(rec))
actor.Send(EventGo)

for _, t := range rec.Transitions() {
    fmt.Printf("%s -> %s on %s\n", t.From, t.To, t.Event)
}

// or by kind:
for _, ev := range rec.Events(gstate.KindGuardEvaluated, gstate.KindTransition) {
    fmt.Printf("%s @ %s: %+v\n", ev.Kind, ev.Timestamp.Format(time.RFC3339Nano), ev.Payload)
}

Try it: observer example — attach a RecordingObserver to a small machine and print the lifecycle log.

Printing and serializing payloads

Every payload type implements fmt.Stringer with a short, stable format, so fmt.Println(e) produces readable output. RecordedEvent.String() delegates to the embedded payload, so a recorder log can be dumped with a single fmt.Println(ev):

for _, ev := range rec.Events() {
    fmt.Println(ev)
    // transition: transition[V1StGXR8_Z5j]: idle --GO--> active
    // guard: guard[V1StGXR8_Z5j]: idle --GO[active]: result=true
    // ...
}

Payload structs also carry json tags and a custom MarshalJSON that materializes Data() into a top-level data field at marshal time, so they can be marshaled directly for shipping to a telemetry pipeline. InvokeEvent.Error is rendered as its Error() string (or omitted when nil) via the same mechanism:

b, _ := json.Marshal(rec.Transitions()[0])
// {"machine_id":"...","actor_id":"V1StGXR8_Z5j","from":"idle","to":"active","event":"GO","data":{...},"timestamp":"..."}

The Actor: Running a Machine

A Machine is a static blueprint. To actually run it, you create an Actor. The Actor holds the live state, processes events, and manages async services.

Creating an Actor
// Start with default options
actor := gstate.Start(machine, MyData{Count: 0})

// Or with one or more functional options — call them as methods on the
// machine to let Go infer the [S, E, D] type parameters.
actor := gstate.Start(machine, MyData{Count: 0},
    machine.WithMailboxSize(500),
    machine.WithObservers(logger, recorder),
    machine.WithActorID("worker-42"),
)

Available options:

  • WithMailboxSize(n) — buffered capacity for the event channel. Default 100.
  • WithObservers(obs...) — install one or more observers. Variadic; pass any number of observers (each implementing whichever narrow callback interfaces it cares about). When omitted, no observer is installed and the engine skips payload construction entirely.
  • WithActorID(id) — override the auto-generated ActorID.
Actor Identity

Every actor is born with a stable ActorID. When you don't supply one via WithActorID, Start generates a short URL-safe nanoid:

actor := gstate.Start(machine, MyData{})
fmt.Println(actor.ID()) // e.g. "V1StGXR8_Z5j"

ActorID is the correlation key surfaced in every Observer payload. It is preserved across Snapshot / Hydrate so the same logical actor keeps its identity across restarts.

Sending Events
actor.Send(EventIncrement)            // fire-and-forget
err := actor.SendCtx(ctx, EventStart) // ctx-honoring, returns error

Events are queued in a channel-based mailbox and processed sequentially, ensuring state transitions are never concurrent.

Send is a thin wrapper around SendCtx(context.Background(), event) that discards the returned error. Use it when you don't have a request-scoped context and don't need to react to delivery failure. After Stop it is a no-op (no panic, no delivery).

Request-Scoped Context

To attach a context.Context to an event — for tracing IDs, request deadlines, or any value you want delivered to observer callbacks — use SendCtx:

ctx := tracing.ContextWithSpan(req.Context(), span)
if err := actor.SendCtx(ctx, EventDoTheThing); err != nil {
    // event was not delivered; err tells you why
}

The provided ctx is threaded into every Observer callback fired in response to this event (including Always transitions chained after it), and it gates the enqueue itself. SendCtx returns:

  • nil when the event was enqueued.
  • ctx.Err() (context.Canceled or context.DeadlineExceeded) when the supplied context was cancelled or its deadline elapsed before enqueue. The event is not delivered.
  • gstate.ErrActorStopped when Stop was called before enqueue. The event is not delivered.

When the mailbox is full, SendCtx blocks until a slot opens, the context is done, or the actor is stopped. It never blocks forever.

Reading State
// Get the deepest active leaf state
state := actor.State()

// Get ALL active states (useful for parallel states)
states := actor.States()

// Get a thread-safe copy of the actor's data
data := actor.Data()

// Get a full snapshot (active states, history, and data)
snap := actor.Snapshot()

All read methods are thread-safe (protected by RWMutex).

Stopping an Actor
actor.Stop()

Stop() cancels all running invocations and timers, then waits for in-flight work to drain before returning. It is safe to call multiple times — only the first call performs the shutdown.

Guaranteed finished before Stop returns:

Work item Why it's guaranteed
Entry, exit, and transition actions, and guard evaluations, for any event the actor had already begun processing Synchronous on the loop goroutine. Stop acquires the actor's write lock before signalling shutdown; handleEvent holds that lock while running actions, so Stop waits for the in-flight transition to fully complete before proceeding.
Invoke Func goroutines Cancelled via their context.CancelFunc. A sync.WaitGroup tracks every spawned invoke goroutine and Stop waits on it before returning.
OnInvokeCompleted observer callbacks for each in-flight or cancelled invoke Fired from inside the invoke goroutine immediately before its defer wg.Done(), so they're covered by the wait.
OnStateExited / OnStateEntered callbacks fired during the in-flight transition Run synchronously inside handleEvent under the write lock — same guarantee as actions.

Not awaited by Stop:

Work item Why, and what to do instead
Events buffered in the mailbox that the actor had not yet pulled Abandoned. Once Stop has signalled shutdown, the loop exits without consuming further events. Model must-run-before-shutdown work as an Invoke (see Tip below).
Goroutines you spawned yourself from inside an action, guard, or invoke (e.g. go publishMetric(c) inside an Assign) The actor has no handle on them. If the work must finish before Stop returns, do it inside an Invoke Func whose return is awaited, not in a fire-and-forget goroutine.
time.AfterFunc callbacks for delayed transitions that were already firing when Stop ran They no-op via the actor's inactive-state check in executeInternalTransition, but they run on Go's internal timer pool and aren't tracked. The side effect is harmless.

Send after Stop is a no-op. Send swallows any error and returns; SendCtx returns gstate.ErrActorStopped so callers can detect that the event was not delivered. Neither will panic.

Tip — guaranteeing cleanup work runs: if you need work to complete before shutdown (flush a buffer, commit a transaction), model it as an Invoke. Your Func function should observe ctx.Done(), do its cleanup, and return. Stop will wait for it.

Automatic stop on reaching a "done" state

An actor whose machine transitions into a "done" top-level state stops itself automatically. The shutdown follows the same contract as calling Stop() explicitly (see above): in-flight invokes are cancelled and awaited, observers see the terminal transition before the actor goes away, and Snapshot() / State() / States() / Data() remain readable on the stopped actor.

Auto-stop fires when the actor's top-level active state has reached "done" in the SCXML sense:

  • An atomic top-level state with Type == Final.
  • A compound top-level state whose active child chain reaches a Final (recursively).
  • A parallel top-level state whose every region has reached a Final (recursively).

The check is purely a property of the active set — nothing is propagated up the hierarchy. (Compound-parent onDone transitions, where reaching a child Final fires an onDone event on the parent, are a separate feature not yet implemented.)

Machines without any Final state never auto-stop. Their actors run until the caller invokes Stop() explicitly. Auto-stop is opt-in by virtue of placing a Final state in your machine, not opt-out via configuration.


Persistence: Snapshot and Hydrate

Snapshots allow you to serialize the full state of an Actor and restore it later. This is critical for long-running workflows that must survive process restarts.

// 1. Capture the current state
snapshot := actor.Snapshot()

// 2. Serialize to JSON (for storage in a database, file, etc.)
data, _ := json.MarshalIndent(snapshot, "", "  ")

// 3. Later, deserialize and restore
var loaded gstate.Snapshot[MyState, MyData]
json.Unmarshal(data, &loaded)

actor2 := gstate.Hydrate(machine, loaded)
// actor2 is now in exactly the same state as the original

A Snapshot contains:

  • Active []S — all currently active states
  • History map[S]S — the history map (parent → remembered child)
  • Data D — the user data
  • ActorID ActorID — the producing actor's stable identifier

Hydrate restores the actor state and restarts any background services (invocations and timers) for active states, without re-executing entry actions. The hydrated actor keeps the original ActorID from the snapshot.

Hydrate does not fire OnStateEntered or OnTransition for the states being restored — those events represent the original state changes that were already observed before the snapshot was captured. Hooks resume firing on the next event, Always evaluation, or invoke completion processed by the hydrated actor.

Hydrate accepts the same functional options as Start, so you can attach observers or tune the mailbox on a restored actor:

rec := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Hydrate(machine, loaded,
    machine.WithObservers(rec),
    machine.WithMailboxSize(500),
)

WithActorID on Hydrate overrides the snapshot's ActorID (useful when forking or anonymizing); when omitted, the snapshot's ID wins.

Try it: persistence example — snapshot to JSON and hydrate a new actor.


Visualization & Export

gstate can export machine definitions to standard diagram formats for documentation, debugging, and visualization.

Mermaid Diagrams

ToMermaid converts a machine to a Mermaid flowchart string. The output renders natively on GitHub, GitLab, and any Mermaid-compatible viewer.

fmt.Println(gstate.ToMermaid(machine))

Optional configuration via functional options:

gstate.ToMermaid(machine,
    gstate.MermaidTheme(gstate.MermaidThemeDark),
    gstate.MermaidTitle("My Workflow"),
    gstate.MermaidFontSize(20),
)

Available themes: MermaidThemeDefault, MermaidThemeNeutral, MermaidThemeDark, MermaidThemeForest, MermaidThemeBase

Embed directly in a README with a fenced code block:

```mermaid
<output of ToMermaid(machine)>
```

Diagram-friendly labels. EntryLabel / ExitLabel / InvokeLabel make the rendered diagram self-explanatory. Entry/exit names appear inside the state node; an invoke with both success and error targets becomes a diamond pseudo-state with invoke.done and invoke.error arrows:

---
config:
  theme: default
  themeVariables:
    fontSize: 16px
---
flowchart TB
    __start(("●"))
    __start --> idle
    calling_llm(["calling_llm"])
    checking_response(["checking_response"])
    done((("done")))
    failed((("failed")))
    idle(["idle<br/>entry / startEngine<br/>exit / stopEngine"])
    call_llm{"call_llm"}
    calling_llm --> call_llm
    call_llm -->|"invoke.done"| checking_response
    call_llm -->|"invoke.error"| failed
    checking_response -->|"OK"| done
    idle -->|"BEGIN"| calling_llm

Labels are Mermaid-only — SCXML export keeps the spec-prescribed done.invoke.<id> / error.platform event names.

SCXML Export

ToSCXMLString converts a machine to a W3C SCXML document. This enables interop with SCXML-compatible tools and runtimes.

xml, err := gstate.ToSCXMLString(machine)

Also available:

  • ToSCXML(m) — returns a structured *SCXMLDocument
  • ToSCXMLBytes(m) — returns []byte with XML header
What is exported
Feature Mermaid SCXML
States & transitions
Nested (compound) states
Parallel states ✓ (fork/join) ✓ (<parallel>)
Initial states
Final states
Guards (with labels) ✓ (cond)
Entry/exit actions ✓ (notes)
Delayed transitions ✓ (<send> + after.*)
History (shallow/deep) ✓ (notes) ✓ (<history>)
Invoke (onDone/onError)
Action labels ✓ (<assign>)

Note: Export captures the static structure of a machine definition. Runtime behavior (Go functions for guards, actions, invocations) cannot be serialized — labels are used as descriptive placeholders.


System Architecture & Concurrency

gstate uses a hybrid concurrency model to ensure safety and performance:

  • Sequential Mailbox (Channels): All events sent via actor.Send(event) are queued. A background goroutine processes them one by one, ensuring that state transitions and context updates are strictly sequential.
  • Thread-Safe Access (RWMutex): Methods like actor.State(), actor.States(), actor.Data(), and actor.Snapshot() are safe to call concurrently. They use a read-lock to provide a consistent view of the actor.
  • Asynchronous Integrity: Invoke and After run in separate goroutines but their results are funneled back through the sequential logic to prevent data races on your data.

Background & Resources

gstate is based on the formalisms of Statecharts, which provide a rigorous way to model complex, event-driven systems.

Examples

Each example has its own README with a Mermaid state diagram, a walkthrough of what happens, real-world use cases, and expected output.

Example Feature Use Case
basics States, transitions, data, entry/exit Form validation, connection managers
hierarchy Nested states, event bubbling Wizards, multi-step flows
parallel Orthogonal regions Media players, independent monitors
history Shallow/deep history Pause/resume, settings panels
invoke Async services, auto-cancellation API calls, file uploads
delayed Time-based transitions Session timeouts, debouncing
agent Guards, always, invoke, retries CI/CD pipelines, automated remediation
observer BaseObserver, narrow interfaces, RecordingObserver Structured logging, metrics, test assertions
persistence Snapshot & hydrate Durable workflows, serverless

License

This project is licensed under the MIT License - see the LICENSE file for details.

Documentation

Index

Examples

Constants

View Source
const (
	KindTransition      = "transition"
	KindGuardEvaluated  = "guard"
	KindInvokeStarted   = "invoke_started"
	KindInvokeCompleted = "invoke_completed"
	KindStateEntered    = "state_entered"
	KindStateExited     = "state_exited"
	KindActionExecuted  = "action"
	KindEventReceived   = "event_received"
	KindEventDropped    = "event_dropped"
)

Kind constants identify the entry type in [RecordedEvent.Kind] and are used to filter RecordingObserver.Events.

Variables

View Source
var ErrActorStopped = errors.New("gstate: actor stopped")

ErrActorStopped is returned by Actor.SendCtx when the actor has already been stopped and the event cannot be delivered. It is distinct from a context-cancelled error so callers can branch on the reason an event was not delivered.

Functions

func ToMermaid

func ToMermaid[S ~string, E ~string, D Cloner[D]](m *Machine[S, E, D], opts ...MermaidOption) string

ToMermaid converts a Machine to a Mermaid flowchart source string. The output is plain Mermaid syntax; downstream consumers (GitHub README, mermaid.live, mermaid-cli, IDE plugins) handle the actual rendering.

func ToSCXMLBytes

func ToSCXMLBytes[S ~string, E ~string, D Cloner[D]](m *Machine[S, E, D]) ([]byte, error)

ToSCXMLBytes converts a Machine to SCXML bytes with XML header.

func ToSCXMLString

func ToSCXMLString[S ~string, E ~string, D Cloner[D]](m *Machine[S, E, D]) (string, error)

ToSCXMLString converts a Machine to a pretty-printed SCXML string.

Types

type ActionEvent

type ActionEvent[S ~string, E ~string, D Cloner[D]] struct {
	MachineID string  `json:"machine_id"`
	ActorID   ActorID `json:"actor_id"`
	// State is the source state of the firing transition.
	State S `json:"state"`
	// Event is the triggering event. Zero value for Always / internal triggers.
	Event E `json:"event,omitempty"`
	// Target is the destination state ID, or zero for internal transitions.
	Target    S         `json:"target,omitempty"`
	Timestamp time.Time `json:"timestamp"`
	// contains filtered or unexported fields
}

ActionEvent is the payload for [Observer.OnActionExecuted]. It is emitted only when a transition has a non-nil Action.

func (*ActionEvent[S, E, D]) Data added in v0.3.0

func (e *ActionEvent[S, E, D]) Data() *D

func (*ActionEvent[S, E, D]) MarshalJSON added in v0.3.0

func (e *ActionEvent[S, E, D]) MarshalJSON() ([]byte, error)

func (*ActionEvent[S, E, D]) String

func (e *ActionEvent[S, E, D]) String() string

String renders the action as "action[ActorID]: State --Event--> Target" (Target is "<internal>" when empty).

type ActionObserver added in v0.3.0

type ActionObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnActionExecuted(context.Context, *ActionEvent[S, E, D])
}

type Actor

type Actor[S ~string, E ~string, D Cloner[D]] struct {
	// contains filtered or unexported fields
}

Actor is the runtime interpreter for a statechart machine. It maintains the current state, processes events sequentially, and manages asynchronous services (invocations and timers).

func Hydrate

func Hydrate[S ~string, E ~string, D Cloner[D]](m *Machine[S, E, D], snapshot Snapshot[S, D], opts ...Option[S, E, D]) *Actor[S, E, D]

Hydrate restores an Actor from a previously captured Snapshot. It restarts any services (invocations/timers) associated with the active states without re-executing state entry actions. The same Option set as Start is accepted so callers can attach an observer or tune the mailbox on a hydrated actor.

Hydrate does not fire [Observer.OnStateEntered] or [Observer.OnTransition] for the states restored from the snapshot — those events represent the original state changes that were already observed before the snapshot was captured. Hooks resume firing on the next event, Always evaluation, or invoke completion handled by the hydrated actor.

The ActorID is resolved in priority order: [WithActorID] if supplied, otherwise the ActorID stored in the snapshot.

func Start

func Start[S ~string, E ~string, D Cloner[D]](m *Machine[S, E, D], initialData D, opts ...Option[S, E, D]) *Actor[S, E, D]

Start creates and launches a new Actor for the given machine. Options are applied in order; later values for the same option win. The returned Actor is already running and ready to receive events via Actor.Send or Actor.SendCtx.

func (*Actor[S, E, D]) Data added in v0.3.0

func (a *Actor[S, E, D]) Data() D

Data returns a thread-safe copy of the current data. Thread safety is guaranteed by calling [Cloner.Clone] on the underlying data.

func (*Actor[S, E, D]) ID

func (a *Actor[S, E, D]) ID() ActorID

ID returns the actor's stable identifier. The ID is generated on Start (unless overridden with [WithActorID]) and is preserved across Actor.Snapshot and Hydrate so telemetry can correlate the same logical actor across persistence boundaries.

func (*Actor[S, E, D]) Send

func (a *Actor[S, E, D]) Send(event E)

Send enqueues an event in the actor's mailbox using context.Background as the request-scoped context. It is a thin wrapper over Actor.SendCtx that discards the returned error; with context.Background the only possible non-nil return is ErrActorStopped, in which case the send is a no-op per Stop's contract (no panic, no delivery).

Callers that need to react to delivery failure should use Actor.SendCtx directly.

func (*Actor[S, E, D]) SendCtx

func (a *Actor[S, E, D]) SendCtx(ctx context.Context, event E) error

SendCtx enqueues an event in the actor's mailbox carrying the supplied request-scoped context. The context is threaded into every Observer callback fired in response to this event (including Always transitions chained after it) AND it gates the enqueue itself.

Returns:

  • nil when the event was enqueued.
  • ctx.Err() (context.Canceled or context.DeadlineExceeded) when the supplied context was cancelled or its deadline elapsed before the event could be enqueued. The event is NOT delivered.
  • ErrActorStopped when the actor was stopped before the event could be enqueued. The event is NOT delivered.

Behaviour when the mailbox is full: SendCtx blocks until one of three things happens — a slot opens, ctx is done, or the actor is stopped. It never blocks forever.

func (*Actor[S, E, D]) Snapshot

func (a *Actor[S, E, D]) Snapshot() Snapshot[S, D]

Snapshot captures the current status of the Actor, including its active states, history data, and data. The returned struct is suitable for JSON serialization.

func (*Actor[S, E, D]) State

func (a *Actor[S, E, D]) State() S

State returns the ID of the current deepest active leaf state. In the case of parallel states, it returns one of the active leaf states.

func (*Actor[S, E, D]) States

func (a *Actor[S, E, D]) States() []S

States returns the current list of all active states, ordered from root to leaf.

func (*Actor[S, E, D]) Stop

func (a *Actor[S, E, D]) Stop()

Stop shuts the actor down and waits for all goroutines it owns to exit before returning. It is safe to call from multiple goroutines and safe to call more than once — only the first call performs the shutdown work.

Stop completion contract:

Guaranteed finished before Stop returns:

  • Entry, exit, and transition actions, and guard evaluations, for any event the actor had already begun processing. These run synchronously under the actor's write lock; Stop acquires that lock before signalling shutdown, so the in-flight transition completes first.
  • InvokeDef Func goroutines. Stop cancels their context.Context and waits for each Func function to return.
  • [Observer.OnInvokeCompleted] callbacks for each in-flight or cancelled invoke. They fire from inside the invoke goroutine immediately before the WaitGroup decrement.
  • [Observer.OnStateExited] and [Observer.OnStateEntered] callbacks fired during the in-flight transition.

Not awaited by Stop:

  • Events buffered in the mailbox that the actor had not yet pulled. They are abandoned: once Stop has signalled shutdown, the loop exits without consuming further events. Use the invoke pattern below if you have work that must run before shutdown.
  • Goroutines spawned by user code from inside an action, guard, or invoke (for example `go publishMetric(c)` inside an Assign action). The actor has no handle on them. If the work must finish before Stop returns, model it as an InvokeDef Func instead — Stop awaits invoke goroutines.
  • time.AfterFunc callbacks for delayed transitions that were already firing when Stop ran. They safely no-op via the inactive-state check in executeInternalTransition but Stop does not block for them.

Send after Stop is a no-op. Actor.Send and Actor.SendCtx called after Stop (or concurrently with Stop past the shutdown-signal point) return without delivering the event and without panicking.

type ActorID

type ActorID string

ActorID is the stable identifier for a running Actor. It is generated on Start (unless overridden via [WithActorID]) and survives Actor.Snapshot and Hydrate so telemetry can correlate across persistence boundaries.

type BaseObserver added in v0.3.0

type BaseObserver[S ~string, E ~string, D Cloner[D]] struct{}

BaseObserver is the marker-implementing zero struct. Embed it in your observer type to satisfy Observer. Provides no callback methods — implement whichever narrow interfaces you care about directly.

type Cloner

type Cloner[T any] interface {
	Clone() T
}

Cloner is required of every data type used with a Machine. Clone() must return an independent deep copy: mutations to the returned value must not be observable through any reference to the original. For struct types containing no references (no pointers, slices, maps, channels, or funcs), func (c T) Clone() T { return c } is sufficient. For pointer types, ensure referenced data (slices, maps, nested structs) is also deep-copied.

type EventDroppedObserver added in v0.3.0

type EventDroppedObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnEventDropped(context.Context, *EventNotice[S, E, D])
}

type EventNotice

type EventNotice[S ~string, E ~string, D Cloner[D]] struct {
	MachineID string  `json:"machine_id"`
	ActorID   ActorID `json:"actor_id"`
	Event     E       `json:"event"`
	// Reason describes why the event was dropped (e.g. "no_transition"). It is
	// empty on OnEventReceived.
	Reason    string    `json:"reason,omitempty"`
	Timestamp time.Time `json:"timestamp"`
}

EventNotice is the payload for [Observer.OnEventReceived] and [Observer.OnEventDropped].

func (EventNotice[S, E, D]) String

func (e EventNotice[S, E, D]) String() string

String renders the event notice as "event[ActorID]: Event" or, when a reason is set (i.e. drop notices), "event[ActorID]: Event reason=...".

type EventReceivedObserver added in v0.3.0

type EventReceivedObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnEventReceived(context.Context, *EventNotice[S, E, D])
}

type GuardEvent

type GuardEvent[S ~string, E ~string, D Cloner[D]] struct {
	MachineID string  `json:"machine_id"`
	ActorID   ActorID `json:"actor_id"`
	State     S       `json:"state"`
	// Event is the triggering event. Zero value for Always guards.
	Event     E         `json:"event,omitempty"`
	Target    S         `json:"target"`
	Result    bool      `json:"result"`
	Timestamp time.Time `json:"timestamp"`
	// contains filtered or unexported fields
}

GuardEvent is the payload for [Observer.OnGuardEvaluated]. It is emitted only when the transition defines a non-nil Guard, so the absence of an event does not imply the absence of guard evaluation.

func (*GuardEvent[S, E, D]) Data added in v0.3.0

func (e *GuardEvent[S, E, D]) Data() *D

func (*GuardEvent[S, E, D]) MarshalJSON added in v0.3.0

func (e *GuardEvent[S, E, D]) MarshalJSON() ([]byte, error)

func (*GuardEvent[S, E, D]) String

func (e *GuardEvent[S, E, D]) String() string

String renders the guard as "guard[ActorID]: State --Event[Target]: result=true|false".

type GuardObserver added in v0.3.0

type GuardObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnGuardEvaluated(context.Context, *GuardEvent[S, E, D])
}

type HistoryType

type HistoryType int

HistoryType represents the type of history tracking for a compound state. When history is enabled, entering a compound state will restore the last active child instead of defaulting to the initial state.

const (
	// None means no history is tracked for the state.
	None HistoryType = iota
	// Shallow remembers the direct child state that was active.
	Shallow
	// Deep remembers all active descendants in the state hierarchy.
	Deep
)

type InvokeCompletedObserver added in v0.3.0

type InvokeCompletedObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnInvokeCompleted(context.Context, *InvokeEvent[S, E, D])
}

type InvokeDef

type InvokeDef[S ~string, E ~string, D Cloner[D]] struct {
	// Func is the function to run. It receives a context cancelled on state exit,
	// a defensive snapshot of the data taken at state entry, and a thread-safe
	// mutate callback for applying writes to the live data.
	//
	// Parameters:
	// - ctx: cancelled when the state exits or the actor stops. Standard context.Context semantics.
	// - snap: a deep copy of the actor's data taken at the moment of state entry, via D.Clone().
	//   Reads are lock-free and never race because the invoke goroutine owns this value.
	// - mutate: applies updates to the live actor data under the actor's write lock.
	//   The result of the mutation function replaces the live data. mutate is synchronous
	//   and returns after the write commits. It no-ops if the state has exited or the actor stopped.
	//
	// This field was previously named Src.
	Func func(ctx context.Context, snap D, mutate func(func(D) D)) error
	// OnDone is the target state when Func returns nil.
	OnDone S
	// OnError is the target state when Func returns a non-nil error.
	OnError S
	// contains filtered or unexported fields
}

InvokeDef defines an asynchronous service managed during a state's lifecycle. The service is started in a goroutine on state entry and cancelled on exit.

type InvokeEvent

type InvokeEvent[S ~string, E ~string, D Cloner[D]] struct {
	MachineID string  `json:"machine_id"`
	ActorID   ActorID `json:"actor_id"`
	State     S       `json:"state"`
	// Error is nil on OnInvokeStarted and on successful completion.
	// On cancellation it is typically [context.Canceled]. It serializes to
	// JSON as its string form (or null when nil) — see [InvokeEvent.MarshalJSON].
	Error error `json:"-"`
	// Duration is zero on OnInvokeStarted and the elapsed wall time on completion.
	Duration  time.Duration `json:"duration"`
	Timestamp time.Time     `json:"timestamp"`
}

InvokeEvent is the payload for [Observer.OnInvokeStarted] and [Observer.OnInvokeCompleted].

func (InvokeEvent[S, E, D]) MarshalJSON

func (e InvokeEvent[S, E, D]) MarshalJSON() ([]byte, error)

MarshalJSON renders Error as its Error() string (or null when nil) so the payload round-trips through standard JSON tooling. All other fields use their declared json tags.

func (InvokeEvent[S, E, D]) String

func (e InvokeEvent[S, E, D]) String() string

String renders the invoke event as "invoke[ActorID]: state=... duration=... error=...". Fields with zero values are omitted.

type InvokeStartedObserver added in v0.3.0

type InvokeStartedObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnInvokeStarted(context.Context, *InvokeEvent[S, E, D])
}

type Machine

type Machine[S ~string, E ~string, D Cloner[D]] struct {
	// ID identifies this machine definition.
	ID string
	// Initial is the state to enter when the machine starts.
	Initial S
	// States is a flat map of every state (including nested) for O(1) lookup.
	States map[S]*StateDef[S, E, D]
}

Machine is the static, immutable definition of a statechart. It serves as a blueprint for creating Actor instances.

func (*Machine[S, E, D]) WithActorID

func (m *Machine[S, E, D]) WithActorID(id ActorID) Option[S, E, D]

WithActorID returns an Option that overrides the auto-generated ActorID for the actor. When omitted, a fresh ID is generated with nanoid. Supplying an empty string is treated as "no override".

func (*Machine[S, E, D]) WithMailboxSize

func (m *Machine[S, E, D]) WithMailboxSize(n int) Option[S, E, D]

WithMailboxSize returns an Option that sets the buffered capacity of the actor's event channel. When omitted, the default is 100. Values <= 0 fall back to the default.

func (*Machine[S, E, D]) WithObservers added in v0.3.0

func (m *Machine[S, E, D]) WithObservers(obs ...Observer[S, E, D]) Option[S, E, D]

WithObservers returns an Option that installs Observer values receiving lifecycle callbacks for the actor. The observers are invoked synchronously on the actor's event-processing goroutine; see Observer for the full threading contract.

type MachineBuilder

type MachineBuilder[S ~string, E ~string, D Cloner[D]] struct {
	// contains filtered or unexported fields
}

MachineBuilder provides a fluent API for declaring statechart definitions.

func New

func New[S ~string, E ~string, D Cloner[D]](id string) *MachineBuilder[S, E, D]

New initiates the creation of a new statechart machine definition.

func (*MachineBuilder[S, E, D]) Build

func (m *MachineBuilder[S, E, D]) Build() *Machine[S, E, D]

Build finalizes the machine definition and returns an immutable Machine instance. It performs a static-analysis validation pass and panics if any invalid state, transition target, or invoke target is detected.

func (*MachineBuilder[S, E, D]) Initial

func (m *MachineBuilder[S, E, D]) Initial(id S) *MachineBuilder[S, E, D]

Initial sets the starting state for the machine.

func (*MachineBuilder[S, E, D]) State

func (m *MachineBuilder[S, E, D]) State(id S, fn func(*StateBuilder[S, E, D])) *MachineBuilder[S, E, D]

State defines a top-level state in the machine. The provided function is used to configure the state's behavior via a StateBuilder.

type MermaidOption

type MermaidOption func(*mermaidConfig)

MermaidOption configures Mermaid diagram output.

func MermaidFontSize

func MermaidFontSize(size int) MermaidOption

MermaidFontSize sets the base font size in pixels (default 16).

func MermaidTheme

func MermaidTheme(theme MermaidThemeName) MermaidOption

MermaidTheme sets the Mermaid color theme.

func MermaidTitle

func MermaidTitle(title string) MermaidOption

MermaidTitle sets the diagram title shown in the frontmatter.

type MermaidThemeName

type MermaidThemeName string

MermaidThemeName identifies a Mermaid color theme.

const (
	MermaidThemeDefault MermaidThemeName = "default"
	MermaidThemeNeutral MermaidThemeName = "neutral"
	MermaidThemeDark    MermaidThemeName = "dark"
	MermaidThemeForest  MermaidThemeName = "forest"
	MermaidThemeBase    MermaidThemeName = "base"
)

Mermaid theme constants matching the five built-in Mermaid themes.

type NodeKind

type NodeKind string

NodeKind identifies the XML element name of an SCXML node.

const (
	NodeState    NodeKind = "state"
	NodeFinal    NodeKind = "final"
	NodeParallel NodeKind = "parallel"
	NodeHistory  NodeKind = "history"
)

type Observer

type Observer[S ~string, E ~string, D Cloner[D]] interface {
	// contains filtered or unexported methods
}

Observer is the marker interface for all statechart lifecycle hook listeners. To observe actor lifecycle events:

  1. Embed BaseObserver in your type.
  2. Implement any of the nine narrow observer interfaces (e.g. TransitionObserver, GuardObserver, etc.) to receive specific hooks.
  3. Register your observer by passing the output of Machine.WithObservers as an Option to Start.

Hook methods receive event structures containing metadata and an unexported copy of the actor's context data at the time of the event. Call `e.Data()` on any event payload to retrieve a pointer to this data. The copy is evaluated lazily on the first `Data()` call using sync.Once (via [Cloner.Clone()] when implemented, or value copying otherwise), so observers only pay for deep-copying if they read the data.

Threading and locking contract:

  • All callbacks except OnInvokeCompleted run synchronously on the actor's event-processing goroutine while it holds the actor's internal write lock. This includes OnInvokeStarted, which fires from enterSingleState's service-restart path. Implementations must be non-blocking.
  • Implementations must not call methods on the same Actor that would require re-entering the actor lock (e.g. Actor.Snapshot, Actor.State).
  • Implementations must not call Actor.Send or Actor.SendCtx synchronously: the channel send can block on a full mailbox, and the loop goroutine that would drain it cannot acquire the actor lock the observer is holding — a hard deadlock. If an observer needs to dispatch into the actor, do it from a fresh goroutine (e.g. `go func() { actor.Send(EventX) }()`).
  • OnInvokeCompleted fires from the invoke goroutine and does not hold the actor lock.

To implement a subset of the methods, embed BaseObserver and implement only the narrow interfaces needed.

Example

ExampleObserver demonstrates the BaseObserver embedding pattern for implementing only a subset of Observer methods.

package main

import (
	"context"
	"fmt"

	"github.com/floodfx/gstate"
)

type myState string
type myEvent string
type myCtx struct{ Count int }

func (c myCtx) Clone() myCtx {
	return c
}

func buildExampleMachine() *gstate.Machine[myState, myEvent, myCtx] {
	return gstate.New[myState, myEvent, myCtx]("example").
		Initial("idle").
		State("idle", func(s *gstate.StateBuilder[myState, myEvent, myCtx]) {
			s.On("GO").
				Guard(func(_ myCtx) bool { return true }).
				Assign(func(c myCtx) myCtx { c.Count++; return c }).
				GoTo("active")
		}).
		State("active", func(_ *gstate.StateBuilder[myState, myEvent, myCtx]) {}).
		Build()
}

// loggingObserver embeds BaseObserver and overrides only OnTransition. It
// publishes formatted lines through a buffered channel so the example can
// observe them safely without a sleep+racy read.
type loggingObserver struct {
	gstate.BaseObserver[myState, myEvent, myCtx]
	lines chan string
}

func (l *loggingObserver) OnTransition(_ context.Context, e *gstate.TransitionEvent[myState, myEvent, myCtx]) {
	l.lines <- fmt.Sprintf("%s --%s--> %s", e.From, e.Event, e.To)
}

func main() {
	obs := &loggingObserver{lines: make(chan string, 4)}
	m := buildExampleMachine()
	a := gstate.Start(m, myCtx{}, m.WithObservers(obs))
	defer a.Stop()

	a.Send("GO")
	fmt.Println(<-obs.lines)
}
Output:
idle --GO--> active

func SignalObserver added in v0.2.0

func SignalObserver[S ~string, E ~string, D Cloner[D]](signal func()) Observer[S, E, D]

SignalObserver returns an Observer whose every callback calls signal. The context and typed payload arguments are discarded — use ObserverFuncs if you need them, or embed BaseObserver for a full custom implementation.

signal must be non-blocking; observer callbacks run under the actor's write lock (see Observer's threading contract). A nil signal makes the returned observer a no-op.

Typical use: waking a channel when any lifecycle activity occurs.

ready := make(chan struct{}, 1)
obs := gstate.SignalObserver[MyState, MyEvent, MyData](func() {
    select { case ready <- struct{}{}: default: }
})
actor := gstate.Start(machine, ctx, machine.WithObservers(obs))
actor.Send(EventGo)
<-ready

type ObserverFuncs added in v0.2.0

type ObserverFuncs[S ~string, E ~string, D Cloner[D]] struct {
	BaseObserver[S, E, D]

	// AnyFunc fires for every lifecycle callback before the
	// kind-specific field (if any). Useful as a single "something
	// happened" hook for waiters and counters that still want the
	// originating context.
	AnyFunc func(context.Context)

	TransitionFunc      func(context.Context, *TransitionEvent[S, E, D])
	GuardEvaluatedFunc  func(context.Context, *GuardEvent[S, E, D])
	InvokeStartedFunc   func(context.Context, *InvokeEvent[S, E, D])
	InvokeCompletedFunc func(context.Context, *InvokeEvent[S, E, D])
	StateEnteredFunc    func(context.Context, *StateEvent[S, E, D])
	StateExitedFunc     func(context.Context, *StateEvent[S, E, D])
	ActionExecutedFunc  func(context.Context, *ActionEvent[S, E, D])
	EventReceivedFunc   func(context.Context, *EventNotice[S, E, D])
	EventDroppedFunc    func(context.Context, *EventNotice[S, E, D])
}

ObserverFuncs is a function-field adapter that implements Observer without forcing callers to embed BaseObserver and override the callbacks they care about. Each lifecycle method dispatches first to AnyFunc (if non-nil), then to the kind-specific field (if non-nil); nil fields are no-ops.

obs := gstate.ObserverFuncs[MyState, MyEvent, MyData]{
    AnyFunc:        func(ctx context.Context) { /* ... */ },
    TransitionFunc: func(ctx context.Context, e *gstate.TransitionEvent[MyState, MyEvent, MyData]) { /* ... */ },
}
actor := gstate.Start(machine, ctx, machine.WithObservers(obs))

ObserverFuncs values are passed by value; the implementation uses value receivers. Do not mutate fields after installing on an actor. Callback bodies must be non-blocking — see Observer's threading contract.

func (ObserverFuncs[S, E, D]) OnActionExecuted added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnActionExecuted(ctx context.Context, e *ActionEvent[S, E, D])

func (ObserverFuncs[S, E, D]) OnEventDropped added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnEventDropped(ctx context.Context, e *EventNotice[S, E, D])

func (ObserverFuncs[S, E, D]) OnEventReceived added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnEventReceived(ctx context.Context, e *EventNotice[S, E, D])

func (ObserverFuncs[S, E, D]) OnGuardEvaluated added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnGuardEvaluated(ctx context.Context, e *GuardEvent[S, E, D])

func (ObserverFuncs[S, E, D]) OnInvokeCompleted added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnInvokeCompleted(ctx context.Context, e *InvokeEvent[S, E, D])

func (ObserverFuncs[S, E, D]) OnInvokeStarted added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnInvokeStarted(ctx context.Context, e *InvokeEvent[S, E, D])

func (ObserverFuncs[S, E, D]) OnStateEntered added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnStateEntered(ctx context.Context, e *StateEvent[S, E, D])

func (ObserverFuncs[S, E, D]) OnStateExited added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnStateExited(ctx context.Context, e *StateEvent[S, E, D])

func (ObserverFuncs[S, E, D]) OnTransition added in v0.2.0

func (o ObserverFuncs[S, E, D]) OnTransition(ctx context.Context, e *TransitionEvent[S, E, D])

type Option

type Option[S ~string, E ~string, D Cloner[D]] func(*config[S, E, D])

Option configures an Actor at Start or Hydrate time. Options are constructed via methods on a typed MachineMachine.WithMailboxSize, Machine.WithObservers, Machine.WithActorID — which lets Go infer the [S, E, C] type parameters from the machine so the call site needs no annotations:

actor := gstate.Start(m, ctx,
    m.WithMailboxSize(500),
    m.WithObservers(obs),
    m.WithActorID("worker-42"),
)

type RecordedEvent

type RecordedEvent struct {
	Kind      string    `json:"kind"`
	Payload   any       `json:"payload"`
	Timestamp time.Time `json:"timestamp"`
}

RecordedEvent is one entry in a RecordingObserver's log. Payload holds the matching typed payload pointer (e.g. *TransitionEvent); callers can either type-assert via Payload or use the typed accessors on RecordingObserver.

func (RecordedEvent) String

func (r RecordedEvent) String() string

String renders the entry as "Kind: Payload" using the payload's own String() method when it implements fmt.Stringer, falling back to %+v otherwise.

type RecordingObserver

type RecordingObserver[S ~string, E ~string, D Cloner[D]] struct {
	BaseObserver[S, E, D]
	// contains filtered or unexported fields
}

RecordingObserver captures every callback into an in-memory log. It is safe for concurrent use and is intended both for tests and for ad-hoc live introspection of an actor's behavior.

The recorder satisfies Observer by embedding BaseObserver and implementing all narrow interfaces; callers receive ordering identical to the engine's call sequence.

Example

ExampleRecordingObserver attaches a RecordingObserver to a tiny machine, sends one event, and prints the kinds of lifecycle events recorded. A transitionBarrier composed via WithObservers synchronises the example so it doesn't need a sleep.

package main

import (
	"context"
	"fmt"

	"github.com/floodfx/gstate"
)

type myState string
type myEvent string
type myCtx struct{ Count int }

func (c myCtx) Clone() myCtx {
	return c
}

func buildExampleMachine() *gstate.Machine[myState, myEvent, myCtx] {
	return gstate.New[myState, myEvent, myCtx]("example").
		Initial("idle").
		State("idle", func(s *gstate.StateBuilder[myState, myEvent, myCtx]) {
			s.On("GO").
				Guard(func(_ myCtx) bool { return true }).
				Assign(func(c myCtx) myCtx { c.Count++; return c }).
				GoTo("active")
		}).
		State("active", func(_ *gstate.StateBuilder[myState, myEvent, myCtx]) {}).
		Build()
}

// transitionBarrier is a channel-backed observer used by examples to wait
// deterministically for OnTransition without time.Sleep / polling.
type transitionBarrier struct {
	gstate.BaseObserver[myState, myEvent, myCtx]
	done chan struct{}
}

func (b *transitionBarrier) OnTransition(_ context.Context, _ *gstate.TransitionEvent[myState, myEvent, myCtx]) {
	select {
	case b.done <- struct{}{}:
	default:
	}
}

func main() {
	rec := &gstate.RecordingObserver[myState, myEvent, myCtx]{}
	bar := &transitionBarrier{done: make(chan struct{}, 1)}
	m := buildExampleMachine()
	a := gstate.Start(m, myCtx{},
		m.WithObservers(rec, bar),
	)
	defer a.Stop()

	a.Send("GO")
	<-bar.done // deterministic: blocks until OnTransition fires

	for _, ev := range rec.Events() {
		fmt.Println(ev.Kind)
	}
}
Output:
state_entered
event_received
guard
state_exited
action
state_entered
transition

func (*RecordingObserver[S, E, D]) Actions

func (r *RecordingObserver[S, E, D]) Actions() []*ActionEvent[S, E, D]

Actions returns every recorded *ActionEvent.

func (*RecordingObserver[S, E, D]) Events

func (r *RecordingObserver[S, E, D]) Events(kinds ...string) []RecordedEvent

Events returns a copy of recorded events. If kinds are supplied, only entries whose Kind matches one of them are returned, in original order.

func (*RecordingObserver[S, E, D]) EventsDropped

func (r *RecordingObserver[S, E, D]) EventsDropped() []*EventNotice[S, E, D]

EventsDropped returns every recorded *EventNotice for dropped events.

func (*RecordingObserver[S, E, D]) EventsReceived

func (r *RecordingObserver[S, E, D]) EventsReceived() []*EventNotice[S, E, D]

EventsReceived returns every recorded *EventNotice for received events.

func (*RecordingObserver[S, E, D]) Guards

func (r *RecordingObserver[S, E, D]) Guards() []*GuardEvent[S, E, D]

Guards returns every recorded *GuardEvent.

func (*RecordingObserver[S, E, D]) InvokeCompleted

func (r *RecordingObserver[S, E, D]) InvokeCompleted() []*InvokeEvent[S, E, D]

InvokeCompleted returns every recorded *InvokeEvent for invoke completions.

func (*RecordingObserver[S, E, D]) InvokeStarted

func (r *RecordingObserver[S, E, D]) InvokeStarted() []*InvokeEvent[S, E, D]

InvokeStarted returns every recorded *InvokeEvent for invoke starts.

func (*RecordingObserver[S, E, D]) OnActionExecuted

func (r *RecordingObserver[S, E, D]) OnActionExecuted(_ context.Context, e *ActionEvent[S, E, D])

func (*RecordingObserver[S, E, D]) OnEventDropped

func (r *RecordingObserver[S, E, D]) OnEventDropped(_ context.Context, e *EventNotice[S, E, D])

func (*RecordingObserver[S, E, D]) OnEventReceived

func (r *RecordingObserver[S, E, D]) OnEventReceived(_ context.Context, e *EventNotice[S, E, D])

func (*RecordingObserver[S, E, D]) OnGuardEvaluated

func (r *RecordingObserver[S, E, D]) OnGuardEvaluated(_ context.Context, e *GuardEvent[S, E, D])

func (*RecordingObserver[S, E, D]) OnInvokeCompleted

func (r *RecordingObserver[S, E, D]) OnInvokeCompleted(_ context.Context, e *InvokeEvent[S, E, D])

func (*RecordingObserver[S, E, D]) OnInvokeStarted

func (r *RecordingObserver[S, E, D]) OnInvokeStarted(_ context.Context, e *InvokeEvent[S, E, D])

func (*RecordingObserver[S, E, D]) OnStateEntered

func (r *RecordingObserver[S, E, D]) OnStateEntered(_ context.Context, e *StateEvent[S, E, D])

func (*RecordingObserver[S, E, D]) OnStateExited

func (r *RecordingObserver[S, E, D]) OnStateExited(_ context.Context, e *StateEvent[S, E, D])

func (*RecordingObserver[S, E, D]) OnTransition

func (r *RecordingObserver[S, E, D]) OnTransition(_ context.Context, e *TransitionEvent[S, E, D])

func (*RecordingObserver[S, E, D]) Reset

func (r *RecordingObserver[S, E, D]) Reset()

Reset clears the recorded log.

func (*RecordingObserver[S, E, D]) StateEntered

func (r *RecordingObserver[S, E, D]) StateEntered() []*StateEvent[S, E, D]

StateEntered returns every recorded *StateEvent for state entries.

func (*RecordingObserver[S, E, D]) StateExited

func (r *RecordingObserver[S, E, D]) StateExited() []*StateEvent[S, E, D]

StateExited returns every recorded *StateEvent for state exits.

func (*RecordingObserver[S, E, D]) Transitions

func (r *RecordingObserver[S, E, D]) Transitions() []*TransitionEvent[S, E, D]

Transitions returns every recorded *TransitionEvent.

type SCXMLAssign

type SCXMLAssign struct {
	Location string `xml:"location,attr,omitempty"`
}

SCXMLAssign represents an <assign> element.

type SCXMLDocument

type SCXMLDocument struct {
	XMLName  xml.Name    `xml:"scxml"`
	XMLNS    string      `xml:"xmlns,attr"`
	Version  string      `xml:"version,attr"`
	Name     string      `xml:"name,attr"`
	Initial  string      `xml:"initial,attr,omitempty"`
	Children []SCXMLNode `xml:",any"`
}

SCXMLDocument represents the root <scxml> element.

func ToSCXML

func ToSCXML[S ~string, E ~string, D Cloner[D]](m *Machine[S, E, D]) (*SCXMLDocument, error)

ToSCXML converts a gstate Machine definition into an SCXML document.

type SCXMLInvoke

type SCXMLInvoke struct {
	XMLName xml.Name `xml:"invoke"`
	ID      string   `xml:"id,attr"`
}

SCXMLInvoke represents an <invoke> element.

func (*SCXMLInvoke) UnmarshalXML

func (e *SCXMLInvoke) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error

UnmarshalXML handles <invoke> with unknown children.

type SCXMLNode

type SCXMLNode struct {
	Kind        NodeKind           `xml:"-"`
	ID          string             `xml:"id,attr,omitempty"`
	Initial     string             `xml:"initial,attr,omitempty"`
	HistoryMode string             `xml:"type,attr,omitempty"`
	OnEntry     *SCXMLOnEntry      `xml:"onentry,omitempty"`
	OnExit      *SCXMLOnExit       `xml:"onexit,omitempty"`
	Transitions []*SCXMLTransition `xml:"transition,omitempty"`
	Invoke      *SCXMLInvoke       `xml:"invoke,omitempty"`
	Children    []SCXMLNode        `xml:",any"`
}

SCXMLNode represents a child element: <state>, <final>, <parallel>, or <history>. The Kind field determines the element name during marshaling.

func (SCXMLNode) IsFinal

func (n SCXMLNode) IsFinal() bool

IsFinal reports whether the node is a <final> state.

func (SCXMLNode) IsHistory

func (n SCXMLNode) IsHistory() bool

IsHistory reports whether the node is a history pseudo-state.

func (SCXMLNode) IsParallel

func (n SCXMLNode) IsParallel() bool

IsParallel reports whether the node is a <parallel> wrapper.

func (SCXMLNode) IsState

func (n SCXMLNode) IsState() bool

IsState reports whether the node is a regular <state>.

func (SCXMLNode) MarshalXML

func (n SCXMLNode) MarshalXML(enc *xml.Encoder, start xml.StartElement) error

MarshalXML implements xml.Marshaler using Kind as the element name.

func (*SCXMLNode) UnmarshalXML

func (n *SCXMLNode) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error

UnmarshalXML implements xml.Unmarshaler, setting Kind from the element name.

type SCXMLOnEntry

type SCXMLOnEntry struct {
	XMLName      xml.Name     `xml:"onentry"`
	SendElements []*SCXMLSend `xml:"send,omitempty"`
}

SCXMLOnEntry wraps executable content on state entry.

func (*SCXMLOnEntry) UnmarshalXML

func (e *SCXMLOnEntry) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error

UnmarshalXML handles <onentry> with unknown children (raise, datamodel, etc.).

type SCXMLOnExit

type SCXMLOnExit struct {
	XMLName xml.Name `xml:"onexit"`
}

SCXMLOnExit wraps executable content on state exit.

func (*SCXMLOnExit) UnmarshalXML

func (e *SCXMLOnExit) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error

UnmarshalXML handles <onexit> with unknown children.

type SCXMLSend

type SCXMLSend struct {
	Event string `xml:"event,attr,omitempty"`
	Delay string `xml:"delay,attr,omitempty"`
}

SCXMLSend represents a <send> element.

type SCXMLTransition

type SCXMLTransition struct {
	XMLName xml.Name       `xml:"transition"`
	Event   string         `xml:"event,attr,omitempty"`
	Cond    string         `xml:"cond,attr,omitempty"`
	Target  string         `xml:"target,attr,omitempty"`
	Assign  []*SCXMLAssign `xml:"assign,omitempty"`
}

SCXMLTransition represents a <transition> element.

type Snapshot

type Snapshot[S ~string, D Cloner[D]] struct {
	// Active lists the currently active state IDs.
	Active []S `json:"active"`
	// History maps compound state IDs to their last active child.
	History map[S]S `json:"history"`
	// Data is the deep-copied data, captured safely using [Cloner.Clone].
	Data D `json:"data"`
	// ActorID is the stable identifier of the actor that produced this snapshot.
	// [Hydrate] restores it so telemetry correlation survives serialization.
	ActorID ActorID `json:"actor_id,omitempty"`
}

Snapshot is a serializable point-in-time capture of an Actor's state.

type StateBuilder

type StateBuilder[S ~string, E ~string, D Cloner[D]] struct {
	// contains filtered or unexported fields
}

StateBuilder provides methods for configuring a specific state's properties and children.

func (*StateBuilder[S, E, D]) After

func (s *StateBuilder[S, E, D]) After(d time.Duration) *TransitionBuilder[S, E, D]

After defines a transition that fires automatically after a duration.

func (*StateBuilder[S, E, D]) Always

func (s *StateBuilder[S, E, D]) Always() *TransitionBuilder[S, E, D]

Always defines a transient transition that fires immediately if its guard passes.

func (*StateBuilder[S, E, D]) Entry

func (s *StateBuilder[S, E, D]) Entry(fn func(D) D)

Entry adds a function to be executed when this state is entered.

func (*StateBuilder[S, E, D]) EntryLabel added in v0.2.2

func (s *StateBuilder[S, E, D]) EntryLabel(name string)

EntryLabel sets a human-readable label for the state's Entry actions. Used in Mermaid output to identify what runs on entry.

func (*StateBuilder[S, E, D]) Exit

func (s *StateBuilder[S, E, D]) Exit(fn func(D) D)

Exit adds a function to be executed when this state is left.

func (*StateBuilder[S, E, D]) ExitLabel added in v0.2.2

func (s *StateBuilder[S, E, D]) ExitLabel(name string)

ExitLabel sets a human-readable label for the state's Exit actions. Used in Mermaid output to identify what runs on exit.

func (*StateBuilder[S, E, D]) History

func (s *StateBuilder[S, E, D]) History(t HistoryType)

History enables history tracking for this state.

func (*StateBuilder[S, E, D]) Initial

func (s *StateBuilder[S, E, D]) Initial(id S)

Initial sets the default child state to enter for this compound state.

func (*StateBuilder[S, E, D]) Invoke

func (s *StateBuilder[S, E, D]) Invoke(fn func(ctx context.Context, snap D, mutate func(func(D) D)) error, onDone S, onError S)

Invoke configures an asynchronous service to run during the state's lifecycle.

Parameters:

  • fn: service function receiving ctx, entry snapshot, and mutate callback. For details on the parameter contracts, see the documentation for InvokeDef.Func.
  • onDone: state to transition to on success (when fn returns nil).
  • onError: state to transition to on failure (when fn returns a non-nil error).

func (*StateBuilder[S, E, D]) InvokeLabel added in v0.2.2

func (s *StateBuilder[S, E, D]) InvokeLabel(name string)

InvokeLabel sets a human-readable label for the state's invoked service. Used in Mermaid output (e.g. as the diamond pseudo-state label). No-op if Invoke has not been called yet.

func (*StateBuilder[S, E, D]) On

func (s *StateBuilder[S, E, D]) On(event E) *TransitionBuilder[S, E, D]

On defines a transition triggered by a specific event.

func (*StateBuilder[S, E, D]) State

func (s *StateBuilder[S, E, D]) State(id S, fn func(*StateBuilder[S, E, D]))

State defines a nested child state.

func (*StateBuilder[S, E, D]) Type

func (s *StateBuilder[S, E, D]) Type(t StateType)

Type explicitly sets the StateType (e.g., Parallel or Final).

type StateDef

type StateDef[S ~string, E ~string, D Cloner[D]] struct {
	// ID uniquely identifies this state within the machine.
	ID S
	// Type is the kind of state: Atomic, Compound, Parallel, or Final.
	Type StateType
	// Initial is the default child state to enter for Compound states.
	Initial S
	// States maps child state IDs to their definitions.
	States map[S]*StateDef[S, E, D]
	// Transitions maps event IDs to ordered transition definitions.
	// The first transition whose guard passes (or has no guard) fires.
	Transitions map[E][]*TransitionDef[S, E, D]
	// Always holds eventless transitions evaluated on state entry in declaration order.
	Always []*TransitionDef[S, E, D]
	// Delayed holds time-based transitions that fire after their After duration.
	Delayed []*TransitionDef[S, E, D]
	// Entry holds functions called in order when entering this state.
	Entry []func(D) D
	// Exit holds functions called in order when leaving this state.
	Exit []func(D) D
	// Invoke defines an async service started on entry and cancelled on exit.
	Invoke *InvokeDef[S, E, D]
	// History controls history tracking: None, Shallow, or Deep.
	History HistoryType
	// contains filtered or unexported fields
}

StateDef defines the properties and behavior of a single state in the machine.

type StateEnteredObserver added in v0.3.0

type StateEnteredObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnStateEntered(context.Context, *StateEvent[S, E, D])
}

type StateEvent

type StateEvent[S ~string, E ~string, D Cloner[D]] struct {
	MachineID string    `json:"machine_id"`
	ActorID   ActorID   `json:"actor_id"`
	State     S         `json:"state"`
	Timestamp time.Time `json:"timestamp"`
	// contains filtered or unexported fields
}

StateEvent is the payload for [Observer.OnStateEntered] and [Observer.OnStateExited].

func (*StateEvent[S, E, D]) Data added in v0.3.0

func (e *StateEvent[S, E, D]) Data() *D

func (*StateEvent[S, E, D]) MarshalJSON added in v0.3.0

func (e *StateEvent[S, E, D]) MarshalJSON() ([]byte, error)

func (*StateEvent[S, E, D]) String

func (e *StateEvent[S, E, D]) String() string

String renders the state event as "state[ActorID]: State".

type StateExitedObserver added in v0.3.0

type StateExitedObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnStateExited(context.Context, *StateEvent[S, E, D])
}

type StateType

type StateType int

StateType represents the type of a state in the statechart.

const (
	// Atomic represents a leaf state with no children.
	Atomic StateType = iota
	// Compound represents a state that contains one or more child states.
	// It has exactly one active child at any given time.
	Compound
	// Parallel represents a state where all of its child regions are active simultaneously.
	Parallel
	// Final represents a state that indicates the completion of its parent's
	// process. When the actor's top-level active state has reached "done"
	// (an atomic Final, a compound whose active child is done, or a parallel
	// whose every region is done), the actor stops itself automatically.
	// See [Actor.Stop] for the shutdown contract.
	//
	// Machines that contain no Final state never auto-stop; their actors run
	// until [Actor.Stop] is called explicitly.
	Final
)

type TransitionBuilder

type TransitionBuilder[S ~string, E ~string, D Cloner[D]] struct {
	// contains filtered or unexported fields
}

TransitionBuilder provides a fluent API for configuring a state transition.

func (*TransitionBuilder[S, E, D]) ActionLabel

func (t *TransitionBuilder[S, E, D]) ActionLabel(name string) *TransitionBuilder[S, E, D]

ActionLabel sets an optional label for the action.

func (*TransitionBuilder[S, E, D]) Assign

func (t *TransitionBuilder[S, E, D]) Assign(fn func(D) D) *TransitionBuilder[S, E, D]

Assign adds a data update action to the transition.

func (*TransitionBuilder[S, E, D]) GoTo

func (t *TransitionBuilder[S, E, D]) GoTo(target S)

GoTo sets the target state for the transition.

func (*TransitionBuilder[S, E, D]) Guard

func (t *TransitionBuilder[S, E, D]) Guard(fn func(D) bool) *TransitionBuilder[S, E, D]

Guard adds a conditional check to the transition.

func (*TransitionBuilder[S, E, D]) GuardLabel

func (t *TransitionBuilder[S, E, D]) GuardLabel(name string) *TransitionBuilder[S, E, D]

GuardLabel sets an optional label for the guard condition.

type TransitionDef

type TransitionDef[S ~string, E ~string, D Cloner[D]] struct {
	// Target is the state to transition to.
	Target S
	// Guard is an optional predicate that must return true for the transition to fire.
	Guard func(D) bool
	// Action is a pure function that updates the data during the transition.
	Action func(D) D
	// After is the delay before a timed transition fires.
	After time.Duration
	// contains filtered or unexported fields
}

TransitionDef defines the rules for moving from one state to another.

type TransitionEvent

type TransitionEvent[S ~string, E ~string, D Cloner[D]] struct {
	MachineID string  `json:"machine_id"`
	ActorID   ActorID `json:"actor_id"`
	From      S       `json:"from"`
	To        S       `json:"to"`
	// Event is the triggering event. Zero value when the transition fires from
	// an Always, Delayed, or invoke-completion path.
	Event     E         `json:"event,omitempty"`
	Timestamp time.Time `json:"timestamp"`
	// contains filtered or unexported fields
}

TransitionEvent is the payload for [Observer.OnTransition].

func (*TransitionEvent[S, E, D]) Data added in v0.3.0

func (e *TransitionEvent[S, E, D]) Data() *D

func (*TransitionEvent[S, E, D]) MarshalJSON added in v0.3.0

func (e *TransitionEvent[S, E, D]) MarshalJSON() ([]byte, error)

func (*TransitionEvent[S, E, D]) String

func (e *TransitionEvent[S, E, D]) String() string

String renders the transition as "transition[ActorID]: From --Event--> To". To is "<internal>" for transitions without a target state.

type TransitionObserver added in v0.3.0

type TransitionObserver[S ~string, E ~string, D Cloner[D]] interface {
	Observer[S, E, D]
	OnTransition(context.Context, *TransitionEvent[S, E, D])
}

Directories

Path Synopsis
examples
agent command
basics command
delayed command
hierarchy command
history command
invoke command
observer command
Package main demonstrates attaching an Observer to a gstate machine to inspect lifecycle events end-to-end.
Package main demonstrates attaching an Observer to a gstate machine to inspect lifecycle events end-to-end.
parallel command
persistence command
internal
mermaid
Package mermaid emits Mermaid flowchart diagram source strings.
Package mermaid emits Mermaid flowchart diagram source strings.

Jump to

Keyboard shortcuts

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