Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/src/content/docs/authoring/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ func kitchenBehavior() state.ActorBehavior {
A state declares an actor invocation with `InvokeActor`, naming the signals fired when the actor completes or fails:

```go
b.SubState(Cooking).InvokeActor("kitchen", PlatedUp, Declined)
b.SubState(EnRoute).InvokeActor("courier", DroppedOff, Declined)
b.SubState(Cooking).InvokeActor("kitchen", state.WithInvokeOnDone(PlatedUp), state.WithInvokeOnError(Declined))
b.SubState(EnRoute).InvokeActor("courier", state.WithInvokeOnDone(DroppedOff), state.WithInvokeOnError(Declined))
```

On entering `Cooking`, the kernel emits a spawn effect; the `ActorSystem` builds the actor and the host drives it. Address a running actor with its derived id and deliver events to it:
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/authoring/assigns.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ func recordHold(in state.AssignCtx[Order]) Order {
Register reducers by name, then reference them on a transition. Multiple `Assign` calls **fold in declared order**, each receiving the output of the previous:

```go
reg.Assign("recordHold", recordHold)
reg.Assign("markBreached", markBreached)
reg.Reducer("recordHold", recordHold)
reg.Reducer("markBreached", markBreached)

// builder form is equivalent:
b.Reducer("settle", settleReducer)
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/authoring/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ sidebar:

A **service** is asynchronous work scoped to a state: authorize a payment, run a cancellation saga, call an external API. Unlike an effect (fire-and-forget data the host dispatches), a service has a lifecycle: it starts when the state is entered, and its completion feeds back into the machine as an event.

Declare an invocation with `Invoke`, naming the service plus its `onDone` and `onError` events. The service is a context-aware function that may block and return a value or an error:
Declare an invocation with `Invoke`, naming the service and routing its outcomes with `WithInvokeOnDone` / `WithInvokeOnError`. The service is a context-aware function that may block and return a value or an error:

```go
type ServiceFn[C any] func(ctx context.Context, in state.ServiceCtx[C]) (any, error)

reg.Service("authorize", authorizeFn)

b.State(Authorizing).
Invoke("authorize", Authorized, Declined).
Invoke("authorize", state.WithInvokeOnDone(Authorized), state.WithInvokeOnError(Declined)).
Transition(Authorizing).On(Authorized).GoTo(Active).Assign("recordHold").
Transition(Authorizing).On(Declined).GoTo(Rejected).Assign("recordDecline")
```
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/concepts/value-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type AssignFn[C any] func(in AssignCtx[C]) C
```

```go
reg.Assign("recordPayment", func(a state.AssignCtx[Order]) Order {
reg.Reducer("recordPayment", func(a state.AssignCtx[Order]) Order {
a.Entity.Paid = true // a.Entity is a copy
a.Entity.PaidAt = clockNow(a) // mutate the copy freely
return a.Entity // the returned value becomes the next context
Expand Down
8 changes: 4 additions & 4 deletions durable/actor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func supervisorMachine() *state.Machine[string, string, actorCtx] {
return c
}).
Actor("spawnChild").
State("supervising").InvokeActor("spawnChild", "childDone", "childFail").
State("supervising").InvokeActor("spawnChild", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childFail")).
State("complete").Final().
State("failed").Final().
Initial("supervising").
Expand Down Expand Up @@ -256,7 +256,7 @@ func messengerMachine() *state.Machine[string, string, actorCtx] {
return c
}).
Actor("spawnPinger").
State("listening").InvokeActor("spawnPinger", "pingerDone", "pingerFail").
State("listening").InvokeActor("spawnPinger", state.WithInvokeOnDone("pingerDone"), state.WithInvokeOnError("pingerFail")).
State("heard").
State("failed").Final().
Initial("listening").
Expand Down Expand Up @@ -334,8 +334,8 @@ func twoActorMachine() *state.Machine[string, string, actorCtx] {
}).
Actor("childA").
Actor("childB").
State("first").InvokeActor("childA", "aDone", "aFail").
State("second").InvokeActor("childB", "bDone", "bFail").
State("first").InvokeActor("childA", state.WithInvokeOnDone("aDone"), state.WithInvokeOnError("aFail")).
State("second").InvokeActor("childB", state.WithInvokeOnDone("bDone"), state.WithInvokeOnError("bFail")).
State("done").Final().
State("failed").Final().
Initial("first").
Expand Down
4 changes: 2 additions & 2 deletions durable/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ func pipelineMachine() *state.Machine[string, string, *pipelineCtx] {
return nil, nil
}).
State("idle").
State("fetching").Invoke("load", "loaded", "loadFail").
State("spawning").InvokeActor("worker", "workerDone", "workerFail").
State("fetching").Invoke("load", state.WithInvokeOnDone("loaded"), state.WithInvokeOnError("loadFail")).
State("spawning").InvokeActor("worker", state.WithInvokeOnDone("workerDone"), state.WithInvokeOnError("workerFail")).
State("ready").
State("notifying").
State("done").Final().
Expand Down
4 changes: 2 additions & 2 deletions durable/runner_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func quoteMachine(fn state.ServiceFn[*quoteCtx]) *state.Machine[string, string,
return c
}).
State("cart").
State("quoting").Invoke("price", "priced", "failed").
State("quoting").Invoke("price", state.WithInvokeOnDone("priced"), state.WithInvokeOnError("failed")).
State("quoted").Final().
State("rejected").Final().
Initial("cart").
Expand Down Expand Up @@ -222,7 +222,7 @@ func fulfillmentMachine() *state.Machine[string, string, *fulfillmentCtx] {
return c
}).
Actor("ship").
State("supervising").InvokeActor("ship", "shipped", "failed").
State("supervising").InvokeActor("ship", state.WithInvokeOnDone("shipped"), state.WithInvokeOnError("failed")).
State("complete").Final().
State("aborted").Final().
Initial("supervising").
Expand Down
2 changes: 1 addition & 1 deletion durable/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func (h *Handle[S, E, C]) RunService(ctx context.Context, id string) (state.Fire
return state.FireResult[S]{}, false, nil
}

res, ok := h.svc.Run(ctx, id)
res, ok := h.svc.Tick(ctx, id)
if !ok {
return state.FireResult[S]{}, false, nil
}
Expand Down
6 changes: 3 additions & 3 deletions durable/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func fetchMachine(fn state.ServiceFn[svcCtx]) *state.Machine[string, string, svc
return c
}).
State("idle").
State("loading").Invoke("fetch", "ok", "fail").
State("loading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")).
State("ready").Final().
State("errored").Final().
Initial("idle").
Expand Down Expand Up @@ -228,8 +228,8 @@ func chainMachine(svcA, svcB state.ServiceFn[svcCtx]) *state.Machine[string, str
return c
}).
State("idle").
State("first").Invoke("svcA", "aDone", "aFail").
State("second").Invoke("svcB", "bDone", "bFail").
State("first").Invoke("svcA", state.WithInvokeOnDone("aDone"), state.WithInvokeOnError("aFail")).
State("second").Invoke("svcB", state.WithInvokeOnDone("bDone"), state.WithInvokeOnError("bFail")).
State("done").Final().
State("failed").Final().
Initial("idle").
Expand Down
22 changes: 11 additions & 11 deletions examples/fooddelivery/fooddelivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,13 +551,13 @@ func registerBindings(reg *state.Registry[Order]) {
reg.Service("refund", refundFn)
reg.Actor("kitchen")
reg.Actor("courier")
reg.Assign("recordHold", recordHold)
reg.Assign("recordDecline", recordDecline)
reg.Assign("recordPrep", recordPrep)
reg.Assign("recordDrop", recordDrop)
reg.Assign("markBreached", markBreached)
reg.Assign("settle", settleReducer)
reg.Assign("recordRefund", recordRefund)
reg.Reducer("recordHold", recordHold)
reg.Reducer("recordDecline", recordDecline)
reg.Reducer("recordPrep", recordPrep)
reg.Reducer("recordDrop", recordDrop)
reg.Reducer("markBreached", markBreached)
reg.Reducer("settle", settleReducer)
reg.Reducer("recordRefund", recordRefund)
}

// buildModel assembles the order lifecycle statechart. generous is the Rich (CEL)
Expand Down Expand Up @@ -596,7 +596,7 @@ func buildModel(schema state.ContextSchema, generous state.GuardNode[Stage]) *st
// test, and the Rich (CEL) guard: a generous order, OR a big basket flagged
// for the fast lane.
State(Authorizing).
Invoke("authorize", Authorized, Declined).
Invoke("authorize", state.WithInvokeOnDone(Authorized), state.WithInvokeOnError(Declined)).
Transition(Authorizing).On(Authorized).
WhenExpr(state.Or(
generous,
Expand All @@ -618,15 +618,15 @@ func buildModel(schema state.ContextSchema, generous state.GuardNode[Stage]) *st
Initial(Cooking).
// Cooking supervises the kitchen actor; its plated output advances the spine.
SubState(Cooking).
InvokeActor("kitchen", PlatedUp, Declined).
InvokeActor("kitchen", state.WithInvokeOnDone(PlatedUp), state.WithInvokeOnError(Declined)).
On(PlatedUp).GoTo(AwaitingCourier).Assign("recordPrep").
// The courier is dispatched once the meal is plated; PickedUp moves to EnRoute.
SubState(AwaitingCourier).
On(PickedUp).GoTo(EnRoute).
// EnRoute supervises the courier actor; its drop confirmation (DroppedOff) is
// handled cross-cuttingly by the Active compound, exiting the parallel state.
SubState(EnRoute).
InvokeActor("courier", DroppedOff, Declined).
InvokeActor("courier", state.WithInvokeOnDone(DroppedOff), state.WithInvokeOnError(Declined)).
EndRegion().
Region("Watchdog").
Initial(OnTime).
Expand All @@ -652,7 +652,7 @@ func buildModel(schema state.ContextSchema, generous state.GuardNode[Stage]) *st
// its onDone, folds the reversed amount into the context via the event before
// reaching the Canceled terminal.
State(Refunding).
Invoke("refund", Refunded, Declined).
Invoke("refund", state.WithInvokeOnDone(Refunded), state.WithInvokeOnError(Declined)).
Transition(Refunding).On(Refunded).GoTo(Canceled).Assign("recordRefund").
State(Canceled).Final().
// Rejected is the declined-authorization terminal.
Expand Down
4 changes: 2 additions & 2 deletions examples/fooddelivery/rig.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func (r *Rig) SettleRefund(ctx context.Context, amount int64) state.FireResult[S
// (executing authorizeFn) and routes its outcome, rather than settling it with a
// fixed token. It returns the resulting FireResult and whether a service ran.
func (r *Rig) RunAuthorization(ctx context.Context) (state.FireResult[Stage], bool) {
fr, ok := r.run.Run(ctx, r.authorizeID())
fr, ok := r.run.Tick(ctx, r.authorizeID())
if ok {
r.absorb(ctx, fr.Effects)
}
Expand All @@ -173,7 +173,7 @@ func (r *Rig) RunAuthorization(ctx context.Context) (state.FireResult[Stage], bo
// refundFn) and routes its outcome. It returns the resulting FireResult and whether a
// service ran.
func (r *Rig) RunRefund(ctx context.Context) (state.FireResult[Stage], bool) {
fr, ok := r.run.Run(ctx, r.refundID())
fr, ok := r.run.Tick(ctx, r.refundID())
if ok {
r.absorb(ctx, fr.Effects)
}
Expand Down
34 changes: 21 additions & 13 deletions state/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ representative hot-path numbers.
Escape guards are read-only by contract.
- Assign reducers, the sole context-mutation site under the value-semantics
context contract. An `AssignFn[C]` is a total pure reducer
(`ctxView, event, params → C`) registered by `Registry.Assign` (alias
`Builder.Reducer`) and wired onto a transition by `Builder.Assign(name)`. The
(`ctxView, event, params → C`) registered by `Registry.Reducer` (alias
`Builder.Reducer`) and wired onto a transition by `Builder.Assign(name)`,
splitting registration (the noun verb `Reducer`) from wiring (the verb `Assign`)
to mirror Guard/When and Action/Do. The
kernel folds the assigns declared on a transition's exit, transition, and entry
phases (in that order, declaration order within each phase, each seeing the
prior result), and the folded value becomes the instance's context at commit.
Expand Down Expand Up @@ -114,7 +116,7 @@ representative hot-path numbers.
`Builder.Palette()` / `Machine.Palette()` surface the same set for a DSL- or
`Provide`-built machine, and `Provide` carries descriptors over from the supplied
registry. A separate `BuiltinPalette()` lists the language-level built-ins
(`spawn`/`stopActor`/`stopChild`/`sendTo`/`sendParent`/`respond`/`forwardTo`/`cancel`
(`spawn`/`stopActor`/`sendTo`/`sendParent`/`respond`/`forwardTo`/`cancel`
actions and the `stateIn` guard), which are intentionally excluded from
`Palette()`. Descriptors are metadata only; they never affect binding, lint, or
`Fire` semantics.
Expand Down Expand Up @@ -220,13 +222,15 @@ representative hot-path numbers.
name through `Registry.Service` / `Builder.Service`, parallel to guards and
actions. An unbound service ref fails `Quench` with the typed `*ErrUnboundRef`
(`Kind: "service"`), consistent with unbound guards/actions. Authored via the
DSL `Invoke(src, onDone, onError, ...InvokeOption)` with `WithInput`,
`WithServiceParams`, and `WithInvokeID`.
DSL `Invoke(src, ...InvokeOption)` whose outcomes are options —
`WithInvokeOnDone` / `WithInvokeOnError` — alongside `WithInput`,
`WithServiceParams`, and `WithInvokeID`, so completion routing is additive
(matching `Spawn`) rather than positional.
- **Host-driver harness.** A reusable, exported `ServiceRunner` driver consumes
the start/stop effects, runs the bound `ServiceFn`, and re-fires each service's
`onDone` (carrying the result) or `onError` (carrying the error) through the
instance; `SettleDone` / `SettleError` settle a service by ID for a
deterministic test driver with no real IO, while `Run` resolves and executes a
deterministic test driver with no real IO, while `Tick` resolves and executes a
bound service for production. `LastResult` / `LastError` let an onDone/onError
action read the routed payload, and `StartEffects` arms the services of the
initial state entered at `Cast`.
Expand All @@ -248,8 +252,9 @@ representative hot-path numbers.
registration, mirroring the `Cancel` built-in.
- **Declarative actor invoke + runtime refs.** An `Invocation` gains a `Kind`
(`ActorKindService` default vs `ActorKindMachine`) and a `SystemID`; the
`InvokeActor(src, onDone, onError, ...)` DSL (with `WithInput`, `WithInvokeID`,
`WithSystemID`) declares a child-machine actor whose `src` binds at the
`InvokeActor(src, ...InvokeOption)` DSL (with `WithInvokeOnDone`,
`WithInvokeOnError`, `WithInput`, `WithInvokeID`, `WithSystemID`) declares a
child-machine actor whose `src` binds at the
`ActorSystem` actor palette, not the service registry. Dynamic `Spawn(src, id,
...)` takes `WithSpawnInput`, `WithSpawnSystemID`, `WithSpawnOnDone`,
`WithSpawnOnError`. An `ActorRef` is an opaque runtime handle a machine stores
Expand All @@ -261,7 +266,8 @@ representative hot-path numbers.
`NewActor`, and re-fires the parent's `onDone` (carrying the child's `output`) or
`onError` when the child completes or fails. `Register` binds child behaviors;
`Absorb` spawns/stops from effects; `Deliver` / `DeliverByID` route an event into
an actor's mailbox and `Step` drains it; `Ref` / `RefBySystemID` resolve refs;
an actor's mailbox and `Tick` drains it (the advance verb shared with
`Scheduler.Tick` and `ServiceRunner.Tick`); `Ref` / `RefBySystemID` resolve refs;
`Stop` / `SettleError` tear down or fail an actor; stopping a parent stops its
children recursively. `LastOutput` / `LastError` let an `onDone` / `onError`
action read the routed payload. The driver is synchronous and deterministic, so
Expand All @@ -281,9 +287,11 @@ representative hot-path numbers.
parent; `Respond(event)` emits a `RespondToSender{Event}` routed back to the
sender of the event the actor is currently handling (a no-op when there is no
identifiable sender); `ForwardTo(targetID, ...)` emits a
`ForwardEvent{TargetID, SystemID}` that forwards the current event verbatim; and
`StopChild(id)` emits a `StopActor{ID}` to stop a spawned actor. Address a target
by registry id or, with `WithSendToSystemID`, by its system-scoped id. Like the
`ForwardEvent{TargetID, SystemID}` that forwards the current event verbatim. A
single `StopActor(id)` verb stops a spawned or invoked-child actor from a
transition (emitting `StopActor{ID}` via the one `crucible.stopActor` built-in).
Address a target by registry id or, with `WithSendToSystemID`, by its
system-scoped id. Like the
spawn/stop/cancel built-ins, these need no host registration and are exempt from
the unbound-ref lint.
- **Sender-tracked routing in the `ActorSystem`.** Mailbox messages carry the
Expand Down Expand Up @@ -410,7 +418,7 @@ representative hot-path numbers.
place context changes is an assign reducer. Actions emit effects; they cannot
write context. A consumer that previously mutated the context through a pointer
inside an action must move those writes into an `AssignFn` registered with
`Registry.Assign`/`Builder.Reducer` and wired with `Builder.Assign(name)`. This
`Registry.Reducer`/`Builder.Reducer` and wired with `Builder.Assign(name)`. This
is the central change for clean serialization, deterministic replay, and
cross-stack evaluation.
- **BREAKING: the reserved `ContextDelta` slot on the action result is removed.**
Expand Down
4 changes: 2 additions & 2 deletions state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ A complete statechart feature surface:
- **Invoked services**: state-scoped `Invoke(src, onDone, onError)` with
result/error routing, auto-stopped on exit, driven by a host `ServiceRunner`.
- **Actor model**: child-machine actors, a host `ActorSystem`, mailboxes, and
dynamic `Spawn`, with **message passing** (`SendTo`, `SendParent`, `Respond`,
`ForwardTo`, and `StopChild`) and sender-tracked routing.
dynamic `Spawn` and `StopActor`, with **message passing** (`SendTo`,
`SendParent`, `Respond`, and `ForwardTo`) and sender-tracked routing.
- **Snapshots**: `Instance.Snapshot()` captures the full runtime state
(configuration, history, context, traces, pending timers/services/actors);
`Machine.Restore` resumes from it without re-running entry actions, and
Expand Down
2 changes: 1 addition & 1 deletion state/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import "context"
//
// Scope: this file ships the actor RUNTIME — child-machine actors, the actor
// system, mailboxes, delivery, and lifecycle. The message-SEND action sugar
// (sendTo / sendParent / respond / forwardTo / stopChild) lives in actor_comms.go
// (sendTo / sendParent / respond / forwardTo) lives in actor_comms.go
// and rides on top of the mailbox and Deliver mechanism defined here.
// An actor ref is a runtime value (created when the actor is spawned), so it
// is never part of the IR; the invoke/spawn declarations that produce actors are
Expand Down
11 changes: 3 additions & 8 deletions state/actor_comms.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package state

// This file adds the actor-communication action sugar on top of the actor
// runtime (actor.go, actor_system.go): the built-in send/stop actions a machine
// uses to message other actors — sendTo, sendParent, respond, forwardTo, and
// stopChild. They follow the same shape as the spawn / stop /
// uses to message other actors — sendTo, sendParent, respond, and forwardTo.
// They follow the same shape as the spawn / stop /
// cancel built-ins: the kernel handles each ref directly at Fire time and emits a
// DATA effect (SendTo / SendParent / RespondToSender / ForwardEvent / StopActor)
// alongside the transition's other effects. The kernel never delivers a message,
Expand Down Expand Up @@ -91,7 +91,6 @@ const (
sendParentBuiltinName = "crucible.sendParent"
respondBuiltinName = "crucible.respond"
forwardToBuiltinName = "crucible.forwardTo"
stopChildBuiltinName = "crucible.stopChild"
)

// Reserved params keys for the actor-communication built-ins. Targets are stable
Expand All @@ -101,7 +100,6 @@ const (
sendToTargetParam = "target"
sendToSystemIDParam = "systemId"
sendEventParam = "event"
stopChildIDParam = "id"
)

// commMicrostep returns the trace microstep label for an actor-communication or
Expand Down Expand Up @@ -144,7 +142,7 @@ func commTarget(targetID, systemID string) string {
func isCommBuiltinAction(name string) bool {
switch name {
case sendToBuiltinName, sendParentBuiltinName, respondBuiltinName,
forwardToBuiltinName, stopChildBuiltinName:
forwardToBuiltinName:
return true
default:
return false
Expand All @@ -168,9 +166,6 @@ func evalCommBuiltinAction(a Ref) (Effect, error) {
target, _ := a.Params[sendToTargetParam].(string)
systemID, _ := a.Params[sendToSystemIDParam].(string)
return ForwardEvent{TargetID: target, SystemID: systemID}, nil
case stopChildBuiltinName:
id, _ := a.Params[stopChildIDParam].(string)
return StopActor{ID: id}, nil
default:
return nil, &ErrUnknownBuiltin{Name: a.Name}
}
Expand Down
Loading
Loading