diff --git a/docs/src/content/docs/authoring/actors.md b/docs/src/content/docs/authoring/actors.md index b946c8e..08917a7 100644 --- a/docs/src/content/docs/authoring/actors.md +++ b/docs/src/content/docs/authoring/actors.md @@ -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: diff --git a/docs/src/content/docs/authoring/assigns.md b/docs/src/content/docs/authoring/assigns.md index 3237074..0a0a103 100644 --- a/docs/src/content/docs/authoring/assigns.md +++ b/docs/src/content/docs/authoring/assigns.md @@ -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) diff --git a/docs/src/content/docs/authoring/services.md b/docs/src/content/docs/authoring/services.md index 7173039..16abb55 100644 --- a/docs/src/content/docs/authoring/services.md +++ b/docs/src/content/docs/authoring/services.md @@ -10,7 +10,7 @@ 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) @@ -18,7 +18,7 @@ type ServiceFn[C any] func(ctx context.Context, in state.ServiceCtx[C]) (any, er 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") ``` diff --git a/docs/src/content/docs/concepts/value-semantics.md b/docs/src/content/docs/concepts/value-semantics.md index 10c66eb..5d36403 100644 --- a/docs/src/content/docs/concepts/value-semantics.md +++ b/docs/src/content/docs/concepts/value-semantics.md @@ -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 diff --git a/durable/actor_test.go b/durable/actor_test.go index 7cda33d..c02ecf9 100644 --- a/durable/actor_test.go +++ b/durable/actor_test.go @@ -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"). @@ -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"). @@ -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"). diff --git a/durable/integration_test.go b/durable/integration_test.go index 7f1152c..f31f86e 100644 --- a/durable/integration_test.go +++ b/durable/integration_test.go @@ -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(). diff --git a/durable/runner_example_test.go b/durable/runner_example_test.go index 2c4b110..7c79458 100644 --- a/durable/runner_example_test.go +++ b/durable/runner_example_test.go @@ -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"). @@ -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"). diff --git a/durable/service.go b/durable/service.go index 4ba60c1..5f5ed2d 100644 --- a/durable/service.go +++ b/durable/service.go @@ -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 } diff --git a/durable/service_test.go b/durable/service_test.go index 9922507..71ce36a 100644 --- a/durable/service_test.go +++ b/durable/service_test.go @@ -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"). @@ -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"). diff --git a/examples/fooddelivery/fooddelivery.go b/examples/fooddelivery/fooddelivery.go index c659e1d..f7b50b8 100644 --- a/examples/fooddelivery/fooddelivery.go +++ b/examples/fooddelivery/fooddelivery.go @@ -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) @@ -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, @@ -618,7 +618,7 @@ 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). @@ -626,7 +626,7 @@ func buildModel(schema state.ContextSchema, generous state.GuardNode[Stage]) *st // 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). @@ -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. diff --git a/examples/fooddelivery/rig.go b/examples/fooddelivery/rig.go index 91f27d5..aa79c52 100644 --- a/examples/fooddelivery/rig.go +++ b/examples/fooddelivery/rig.go @@ -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) } @@ -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) } diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index d62245f..eece708 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -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. @@ -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. @@ -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`. @@ -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 @@ -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 @@ -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 @@ -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.** diff --git a/state/README.md b/state/README.md index 2f7ab7b..994d9db 100644 --- a/state/README.md +++ b/state/README.md @@ -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 diff --git a/state/actor.go b/state/actor.go index 0db9a8c..ce14792 100644 --- a/state/actor.go +++ b/state/actor.go @@ -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 diff --git a/state/actor_comms.go b/state/actor_comms.go index ed49332..d923639 100644 --- a/state/actor_comms.go +++ b/state/actor_comms.go @@ -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, @@ -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 @@ -101,7 +100,6 @@ const ( sendToTargetParam = "target" sendToSystemIDParam = "systemId" sendEventParam = "event" - stopChildIDParam = "id" ) // commMicrostep returns the trace microstep label for an actor-communication or @@ -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 @@ -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} } diff --git a/state/actor_comms_test.go b/state/actor_comms_test.go index 0cf1e0e..2261d0a 100644 --- a/state/actor_comms_test.go +++ b/state/actor_comms_test.go @@ -68,7 +68,7 @@ func commParentMachine(childID string) *state.Machine[string, string, *trec] { return nil, nil }). State("idle"). - State("running").InvokeActor("relay", "relayDone", "relayErr"). + State("running").InvokeActor("relay", state.WithInvokeOnDone("relayDone"), state.WithInvokeOnError("relayErr")). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). Transition("idle").On("begin").GoTo("running"). @@ -218,14 +218,15 @@ func TestComm_ForwardDeliversTypedEvent(t *testing.T) { } } -// TestComm_StopChildStopsActor asserts the stopChild action stops a spawned actor. -func TestComm_StopChildStopsActor(t *testing.T) { +// TestComm_StopActorStopsActor asserts the StopActor action stops a spawned actor +// from a transition. +func TestComm_StopActorStopsActor(t *testing.T) { ctx := context.Background() m := state.Forge[string, string, *trec]("parent"). State("running"). Initial("running"). CurrentStateFn(func(*trec) string { return "running" }). - Transition("running").On("kill").GoTo("running").StopChild("victim"). + Transition("running").On("kill").GoTo("running").StopActor("victim"). Quench() entity := &trec{} @@ -241,7 +242,7 @@ func TestComm_StopChildStopsActor(t *testing.T) { res := parent.Fire(ctx, "kill") sys.Absorb(ctx, res.Effects) if sys.IsRunning("victim") { - t.Fatal("victim should be stopped after stopChild") + t.Fatal("victim should be stopped after StopActor") } if sys.Running() != 0 { t.Fatalf("running = %d, want 0", sys.Running()) @@ -263,7 +264,7 @@ func TestComm_IRRoundTrip(t *testing.T) { SendParent("up"). Respond("ack"). ForwardTo("child-2"). - StopChild("child-1"). + StopActor("child-1"). Transition("b").On("sys").GoTo("a"). SendTo("", "hello", state.WithSendToSystemID("named")). ForwardTo("", state.WithSendToSystemID("named")). diff --git a/state/actor_escalation_test.go b/state/actor_escalation_test.go index 8699cf0..ebfb43f 100644 --- a/state/actor_escalation_test.go +++ b/state/actor_escalation_test.go @@ -186,7 +186,7 @@ func TestActorEscalation_ChildPanic_NoOnError_Escalates(t *testing.T) { func TestActorEscalation_WithOnError_HandledLocally(t *testing.T) { m := state.Forge[string, string, *trec]("parent"). State("idle"). - State("supervising").InvokeActor("child", "childDone", "childErr"). + State("supervising").InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). State("errored"). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). @@ -340,7 +340,7 @@ func TestActorRef_Opacity_ResolvedThroughSystem(t *testing.T) { m := state.Forge[string, string, *trec]("parent"). State("idle"). State("supervising"). - InvokeActor("child", "childDone", "childErr", state.WithSystemID("supervisor")). + InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr"), state.WithSystemID("supervisor")). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). Transition("idle").On("start").GoTo("supervising"). diff --git a/state/actor_system.go b/state/actor_system.go index 1c39233..4ec72f7 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -466,7 +466,7 @@ func (s *ActorSystem[S, E, C]) deliver(ctx context.Context, id string, event any } ra.mailbox = append(ra.mailbox, envelope{event: event, sender: senderID}) s.mu.Unlock() - s.Step(ctx, id) + s.Tick(ctx, id) return true } @@ -476,15 +476,17 @@ func (s *ActorSystem[S, E, C]) DeliverByID(ctx context.Context, id string, event return s.Deliver(ctx, ActorRef{ID: id}, event) } -// Step drains the mailbox of the actor under id, firing each queued event through +// Tick drains the mailbox of the actor under id, firing each queued event through // the actor in order. When the actor reaches its final state it is settled: the // parent's onDone event (carrying the child's output) is fired through the parent // and the resulting effects absorbed; nested-child effects the actor emits are // absorbed too. It returns the parent FireResults produced by completion routing, -// in order (empty when the actor did not complete). Step is safe to call with an -// empty mailbox (a no-op) and is how the deterministic driver advances an actor; -// a production driver runs it from the actor's own goroutine. -func (s *ActorSystem[S, E, C]) Step(ctx context.Context, id string) []FireResult[S] { +// in order (empty when the actor did not complete). Tick is the ActorSystem's +// advance verb — the host-driver counterpart of Scheduler.Tick and +// ServiceRunner.Tick — safe to call with an empty mailbox (a no-op) and is how the +// deterministic driver advances an actor; a production driver runs it from the +// actor's own goroutine. +func (s *ActorSystem[S, E, C]) Tick(ctx context.Context, id string) []FireResult[S] { var out []FireResult[S] for { s.mu.Lock() diff --git a/state/actor_test.go b/state/actor_test.go index 60120b9..44bbc71 100644 --- a/state/actor_test.go +++ b/state/actor_test.go @@ -57,7 +57,7 @@ func parentInvokeMachine(sys **state.ActorSystem[string, string, *trec]) *state. return nil, nil }). State("idle"). - State("supervising").InvokeActor("child", "childDone", "childErr"). + State("supervising").InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). State("complete"). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). @@ -208,7 +208,7 @@ func TestActor_DeliverStepsActor(t *testing.T) { func parentInvokeMachineWith(_ *state.Machine[string, string, *childEntity]) *state.Machine[string, string, *trec] { return state.Forge[string, string, *trec]("parent"). State("idle"). - State("supervising").InvokeActor("child", "childDone", "childErr"). + State("supervising").InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). State("complete"). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). @@ -258,7 +258,7 @@ func TestActor_StopParentStopsNestedChildren(t *testing.T) { } // Middle actor: on entering its initial state it invokes the grandchild. middle := state.Forge[string, string, *childEntity]("middle"). - State("run").InvokeActor("grand", "gDone", "gErr"). + State("run").InvokeActor("grand", state.WithInvokeOnDone("gDone"), state.WithInvokeOnError("gErr")). State("end").Final(). Initial("run"). Transition("run").On("stop").GoTo("end"). @@ -295,7 +295,7 @@ func TestActor_StopParentStopsNestedChildren(t *testing.T) { func TestActor_UnboundSrcRoutesOnError(t *testing.T) { m := state.Forge[string, string, *trec]("parent"). State("idle"). - State("supervising").InvokeActor("missing", "childDone", "childErr"). + State("supervising").InvokeActor("missing", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). State("errored"). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). @@ -322,7 +322,7 @@ func TestActor_IRRoundTrip(t *testing.T) { m := state.Forge[string, string, *trec]("parent"). State("idle"). State("supervising"). - InvokeActor("child", "childDone", "childErr", + InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr"), state.WithInvokeID("sup-actor"), state.WithSystemID("supervisor")). State("complete"). Initial("idle"). @@ -392,7 +392,7 @@ func TestActor_RefBySystemID(t *testing.T) { m := state.Forge[string, string, *trec]("parent"). State("idle"). State("supervising"). - InvokeActor("child", "childDone", "childErr", state.WithSystemID("supervisor")). + InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr"), state.WithSystemID("supervisor")). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). Transition("idle").On("start").GoTo("supervising"). @@ -419,7 +419,7 @@ func TestActor_RefBySystemID(t *testing.T) { func TestActor_SettleErrorRoutesOnError(t *testing.T) { m := state.Forge[string, string, *trec]("parent"). State("idle"). - State("supervising").InvokeActor("child", "childDone", "childErr"). + State("supervising").InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). State("errored"). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). diff --git a/state/assign_ondone_test.go b/state/assign_ondone_test.go index d55b29c..a61c6d0 100644 --- a/state/assign_ondone_test.go +++ b/state/assign_ondone_test.go @@ -28,7 +28,7 @@ func TestAssign_ServiceOnDoneReadsResult(t *testing.T) { return c }). State("idle"). - State("loading").Invoke("fetch", "ok", "fail"). + State("loading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")). State("ready"). State("errored"). Initial("idle"). @@ -83,7 +83,7 @@ func TestAssign_ActorOnDoneReadsResult(t *testing.T) { return c }). Actor("spawnChild"). - State("supervising").InvokeActor("spawnChild", "childDone", "childFail"). + State("supervising").InvokeActor("spawnChild", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childFail")). State("complete"). State("failed"). Initial("supervising"). diff --git a/state/assign_test.go b/state/assign_test.go index ba55afe..015d742 100644 --- a/state/assign_test.go +++ b/state/assign_test.go @@ -330,9 +330,9 @@ func TestAssign_ParallelRegionPanicStopsCommit(t *testing.T) { func TestAssign_IRRoundTrip(t *testing.T) { reg := func() *state.Registry[acct] { return state.NewRegistry[acct](). - Assign("exitA", func(in state.AssignCtx[acct]) acct { c := in.Entity; c.Notes = append(c.Notes, "exit"); return c }). - Assign("tr", func(in state.AssignCtx[acct]) acct { c := in.Entity; c.Balance += 7; return c }). - Assign("entryB", func(in state.AssignCtx[acct]) acct { c := in.Entity; c.Notes = append(c.Notes, "entry"); return c }) + Reducer("exitA", func(in state.AssignCtx[acct]) acct { c := in.Entity; c.Notes = append(c.Notes, "exit"); return c }). + Reducer("tr", func(in state.AssignCtx[acct]) acct { c := in.Entity; c.Balance += 7; return c }). + Reducer("entryB", func(in state.AssignCtx[acct]) acct { c := in.Entity; c.Notes = append(c.Notes, "entry"); return c }) } m := state.Forge[string, string, acct]("irrt"). Reducer("exitA", func(in state.AssignCtx[acct]) acct { return in.Entity }). @@ -432,7 +432,7 @@ func TestAssign_UnboundRefFailsQuench(t *testing.T) { // under KindAssign. func TestAssign_Palette(t *testing.T) { reg := state.NewRegistry[acct](). - Assign("credit", func(in state.AssignCtx[acct]) acct { return in.Entity }) + Reducer("credit", func(in state.AssignCtx[acct]) acct { return in.Entity }) found := false for _, d := range reg.Palette() { if d.Kind == state.KindAssign && d.Name == "credit" { diff --git a/state/binding_test.go b/state/binding_test.go index d0e586f..8ba5570 100644 --- a/state/binding_test.go +++ b/state/binding_test.go @@ -163,7 +163,7 @@ func TestAssignBinding_Shadow_MatchesDirectReducer(t *testing.T) { // never collides with a same-named action. func TestRegistry_AssignBindingRecorded(t *testing.T) { reg := NewRegistry[bindOrder](). - Assign("credit", func(in AssignCtx[bindOrder]) bindOrder { return in.Entity }) + Reducer("credit", func(in AssignCtx[bindOrder]) bindOrder { return in.Entity }) if reg.assignBinding("credit") == nil { t.Fatal("Assign did not record an in-process AssignBinding") } diff --git a/state/doc.go b/state/doc.go index f07f00f..e1203d3 100644 --- a/state/doc.go +++ b/state/doc.go @@ -82,7 +82,7 @@ // prior result — committing the folded value to the instance at the end of the // step. Wire an assign with the Assign transition verb or the OnEntryAssign / // OnExitAssign state verbs; register the reducer with Builder.Reducer (or -// Registry.Assign). A service result or actor done-data reaches its onDone +// Registry.Reducer). A service result or actor done-data reaches its onDone // transition's assign through the re-fired done event's payload (AssignCtx.Event), // delivered with the WithEventData fire option — no host side channel. // diff --git a/state/exemplar_test.go b/state/exemplar_test.go index a3f19a5..3754448 100644 --- a/state/exemplar_test.go +++ b/state/exemplar_test.go @@ -281,7 +281,7 @@ func buildConnMachine() *state.Machine[Conn, ConnEvent, Link] { // Connecting invokes the dial service. On dial success it fires Dialed; on // failure it falls back to Backoff to wait out a retry delay. State(Connecting). - Invoke("dial", Dialed, DialFailed). + Invoke("dial", state.WithInvokeOnDone(Dialed), state.WithInvokeOnError(DialFailed)). Transition(Connecting).On(DialFailed).GoTo(Backoff). // Backoff waits out a connect-timeout delay, then the delayed Retry edge // re-enters Connecting (re-arming the dial). This is the retry/backoff loop. diff --git a/state/expr/assign.go b/state/expr/assign.go index 6bd9295..f406442 100644 --- a/state/expr/assign.go +++ b/state/expr/assign.go @@ -60,7 +60,7 @@ func Assign[C any](reg *state.Registry[C], name, source string, schema state.Con return fmt.Errorf("assign %q: build program: %w", name, err) } - reg.Assign(name, celAssign[C](program, schema)) + reg.Reducer(name, celAssign[C](program, schema)) if cfg.catalog != nil { astBytes, err := checkedASTBytes(ast) diff --git a/state/fire.go b/state/fire.go index 8cfa9b6..5f09814 100644 --- a/state/fire.go +++ b/state/fire.go @@ -18,7 +18,9 @@ func as(err error, target any) bool { return errors.As(err, target) } // The kernel keeps Fire pure: it never reads the clock, never performs IO, and // the only state it advances is the Instance.current field. -// Fire runs the full transition pipeline for a single event. +// Fire runs the full transition pipeline for a single event. To drive a sequence +// of events into one instance use FireSeq; to fan one event across many instances +// use the top-level FireEach. func (i *Instance[S, E, C]) Fire(ctx context.Context, event E, opts ...FireOption) FireResult[S] { cfg := fireConfig{} for _, o := range opts { @@ -863,7 +865,9 @@ func projectTransition[S comparable, E comparable, C any](t *Transition[S, E, C] } // FireSeq drives a sequence of events into one instance, threading intermediate -// state and merging the per-step traces into one ordered Trace. +// state and merging the per-step traces into one ordered Trace. It is the +// many-events form of Fire; to fan a single event across many instances use the +// top-level FireEach. func (i *Instance[S, E, C]) FireSeq(ctx context.Context, events []E, opts ...FireOption) BatchResult[S] { cfg := fireConfig{} for _, o := range opts { @@ -908,7 +912,9 @@ func mergeTrace(dst *Trace, step Trace) { } // FireEach fans one event across an explicit set of instances, preserving -// per-instance attribution. +// per-instance attribution. It is the many-instances counterpart to +// Instance.Fire (one event, one instance) and Instance.FireSeq (many events, one +// instance). func FireEach[S comparable, E comparable, C any]( ctx context.Context, instances []*Instance[S, E, C], event E, opts ...FireOption, ) []FireResult[S] { diff --git a/state/invoke_test.go b/state/invoke_test.go index 29c4f81..a907cd3 100644 --- a/state/invoke_test.go +++ b/state/invoke_test.go @@ -33,7 +33,7 @@ func invokeMachine(run **state.ServiceRunner[string, string, *trec]) *state.Mach return nil, nil }). State("idle"). - State("loading").Invoke("fetch", "ok", "fail"). + State("loading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")). State("ready"). State("errored"). Initial("idle"). @@ -180,7 +180,7 @@ func TestInvoke_UnboundServiceQuench(t *testing.T) { state.Forge[string, string, *trec]("unbound"). State("idle"). - State("loading").Invoke("missing", "ok", "fail"). + State("loading").Invoke("missing", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")). State("ready"). Initial("idle"). CurrentStateFn(func(*trec) string { return "idle" }). @@ -196,7 +196,7 @@ func TestInvoke_RoundTrip(t *testing.T) { m := state.Forge[string, string, *trec]("rt"). Service("fetch", func(context.Context, state.ServiceCtx[*trec]) (any, error) { return nil, nil }). State("idle"). - State("loading").Invoke("fetch", "ok", "fail", + State("loading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail"), state.WithInvokeID("svc-1"), state.WithServiceParams(map[string]any{"url": "/x"}), state.WithInput(map[string]any{"page": float64(2)})). @@ -282,7 +282,7 @@ func TestInvoke_RunResolvesService(t *testing.T) { run.Absorb(ctx, inst.Fire(ctx, "start").Effects) id := state.InvokeID("loader", "loading", 0) - fr, ok := run.Run(ctx, id) + fr, ok := run.Tick(ctx, id) if !ok { t.Fatalf("Run reported no in-flight service %q", id) } @@ -300,7 +300,7 @@ func TestInvoke_StartEffectsInitialState(t *testing.T) { var run *state.ServiceRunner[string, string, *trec] m := state.Forge[string, string, *trec]("boot"). Service("fetch", func(context.Context, state.ServiceCtx[*trec]) (any, error) { return "p", nil }). - State("loading").Invoke("fetch", "ok", "fail"). + State("loading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")). State("ready"). State("errored"). Initial("loading"). diff --git a/state/kernel.go b/state/kernel.go index b19d4b0..035ee0e 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -586,18 +586,20 @@ func (r *Registry[C]) Action(name string, fn ActionFn[C], opts ...DescribeOption return r } -// Assign registers a named assign reducer — the sole context writer. The reducer +// Reducer registers a named assign reducer — the sole context writer. The reducer // takes the prior context by value, the triggering event, and the ref's static // params, and returns the next context; the kernel folds the assigns declared on // a transition's exit/transition/entry phases to produce the instance's context. // An optional Describe option adds palette metadata; registering without one still // works and yields a minimal palette descriptor. // -// Naming: the assign verb appears three times with distinct roles. Registry.Assign -// (here) and its builder alias Builder.Reducer both REGISTER a reducer impl under a -// name; Builder.Assign WIRES a registered reducer (by name) onto a transition. So -// you register once (Reducer / Registry.Assign) and wire each use (.Assign(name)). -func (r *Registry[C]) Assign(name string, fn AssignFn[C], opts ...DescribeOption) *Registry[C] { +// Naming: registration and wiring are split cleanly, mirroring Guard/When and +// Action/Do. Registry.Reducer (here) and its builder alias Builder.Reducer both +// REGISTER a reducer impl under a name; Builder.Assign WIRES a registered reducer +// (by name) onto a transition. So you register once (Reducer) and wire each use +// (Assign(name)). The implementation type stays AssignFn — you register it via +// Reducer and wire it via Assign. +func (r *Registry[C]) Reducer(name string, fn AssignFn[C], opts ...DescribeOption) *Registry[C] { r.assigns[name] = fn r.bindAssign(name, inProcessAssign(fn)) r.describe(KindAssign, name, opts) @@ -779,10 +781,10 @@ func (b *Builder[S, E, C]) Action(name string, fn ActionFn[C], opts ...DescribeO // context writer, wired onto a transition with the Assign DSL verb or onto a state // with OnEntryAssign / OnExitAssign. It is the builder-side registration of an // assign (the Do verb wires an Action that Action registers; the Assign verb wires -// a reducer that Reducer registers), forwarding to Registry.Assign. An optional +// a reducer that Reducer registers), forwarding to Registry.Reducer. An optional // Describe option attaches palette metadata. func (b *Builder[S, E, C]) Reducer(name string, fn AssignFn[C], opts ...DescribeOption) *Builder[S, E, C] { - b.reg.Assign(name, fn, opts...) + b.reg.Reducer(name, fn, opts...) return b } @@ -875,16 +877,19 @@ func (b *Builder[S, E, C]) Requires(req Requirement[C]) *Builder[S, E, C] { } // Invoke declares an invoked service on the most-recent state (an -// `invoke`). src names the service in the registry (bind it with Service); onDone -// and onError name the events the host re-fires through Fire when the service -// completes or fails, routed by ordinary transitions from this state. Configure -// the input passed to the service and an explicit id with the variadic -// InvokeOptions (WithInput, WithInvokeID); omitting WithInvokeID derives a stable -// id via InvokeID. On entering this state the kernel emits a StartService effect; -// on exiting it before the service completes, a StopService effect +// `invoke`). src names the service in the registry (bind it with Service). The +// completion outcomes are configured with the variadic InvokeOptions, mirroring +// Spawn: WithInvokeOnDone / WithInvokeOnError name the events the host re-fires +// through Fire when the service completes or fails, routed by ordinary +// transitions from this state; WithInput sets the service input and WithInvokeID +// pins an explicit id (omitting it derives a stable id via InvokeID). Keeping the +// outcomes as options rather than positional parameters means new routing knobs +// (fire-and-forget, onCancel) can arrive later as further options without a +// signature change. On entering this state the kernel emits a StartService +// effect; on exiting it before the service completes, a StopService effect // (auto-stop-on-exit). The kernel never runs the service — a host ServiceRunner // does, keeping Fire pure. -func (b *Builder[S, E, C]) Invoke(src string, onDone, onError E, opts ...InvokeOption) *Builder[S, E, C] { +func (b *Builder[S, E, C]) Invoke(src string, opts ...InvokeOption) *Builder[S, E, C] { if b.curState == nil { return b } @@ -896,25 +901,39 @@ func (b *Builder[S, E, C]) Invoke(src string, onDone, onError E, opts ...InvokeO ID: cfg.id, Src: Ref{Name: src, Params: cfg.params}, Input: cfg.input, - OnDone: onDone, - OnError: onError, + OnDone: invokeOutcome[E](cfg.onDone, cfg.hasOnDone), + OnError: invokeOutcome[E](cfg.onError, cfg.hasOnError), }) return b } +// invokeOutcome resolves a configured invoke outcome event to its typed E: the +// asserted value when the option was set, else the zero event. The InvokeOption +// type stays non-generic (it carries the event as any), so the assertion lands +// here at the typed Invoke / InvokeActor call. +func invokeOutcome[E comparable](v any, present bool) E { + if !present { + var zero E + return zero + } + return v.(E) +} + // InvokeActor declares a child-MACHINE actor invoked while the most-recent state // is active (invoke of a child machine). src names the child-machine -// factory registered in the host's ActorSystem actor palette; onDone and onError -// name the events the host re-fires through the PARENT's Fire when the child -// reaches its final state (carrying its output) or fails (carrying the error), -// routed by ordinary transitions from this state. Configure the input passed to -// the child, an explicit id, and a system-scoped id with WithInput / WithInvokeID -// / WithSystemID. On entering this state the kernel emits a SpawnActor effect; on -// exiting it before the child completes, a StopActor effect (auto-stop-on-exit). -// The kernel never runs the actor — a host ActorSystem does, keeping Fire pure. -// Unlike Invoke (a host-run service), the src here is bound at the ActorSystem, -// not the registry, so it is not subject to the registry's unbound-ref lint. -func (b *Builder[S, E, C]) InvokeActor(src string, onDone, onError E, opts ...InvokeOption) *Builder[S, E, C] { +// factory registered in the host's ActorSystem actor palette. The completion +// outcomes are configured with the variadic InvokeOptions, mirroring Spawn: +// WithInvokeOnDone / WithInvokeOnError name the events the host re-fires through +// the PARENT's Fire when the child reaches its final state (carrying its output) +// or fails (carrying the error), routed by ordinary transitions from this state. +// Configure the input passed to the child, an explicit id, and a system-scoped id +// with WithInput / WithInvokeID / WithSystemID. On entering this state the kernel +// emits a SpawnActor effect; on exiting it before the child completes, a StopActor +// effect (auto-stop-on-exit). The kernel never runs the actor — a host ActorSystem +// does, keeping Fire pure. Unlike Invoke (a host-run service), the src here is +// bound at the ActorSystem, not the registry, so it is not subject to the +// registry's unbound-ref lint. +func (b *Builder[S, E, C]) InvokeActor(src string, opts ...InvokeOption) *Builder[S, E, C] { if b.curState == nil { return b } @@ -926,8 +945,8 @@ func (b *Builder[S, E, C]) InvokeActor(src string, onDone, onError E, opts ...In ID: cfg.id, Src: Ref{Name: src, Params: cfg.params}, Input: cfg.input, - OnDone: onDone, - OnError: onError, + OnDone: invokeOutcome[E](cfg.onDone, cfg.hasOnDone), + OnError: invokeOutcome[E](cfg.onError, cfg.hasOnError), Kind: ActorKindMachine, SystemID: cfg.systemID, }) @@ -971,9 +990,9 @@ func (b *Builder[S, E, C]) Spawn(src, id string, opts ...SpawnOption) *Builder[S // StopActor attaches the kernel stop-actor built-in to the most-recent // transition: when the transition fires, the kernel emits a StopActor effect for -// the given actor id, so a machine can explicitly stop a spawned actor before its -// natural completion (stopping an actor). Stopping an unknown id is a -// host-side no-op. The built-in needs no host registration, mirroring Cancel. +// the given actor id, so a machine can explicitly stop a spawned or invoked-child +// actor before its natural completion. Stopping an unknown id is a host-side +// no-op. The built-in needs no host registration, mirroring Cancel. func (b *Builder[S, E, C]) StopActor(id string) *Builder[S, E, C] { if b.curTransition != nil { b.curTransition.Effects = append(b.curTransition.Effects, @@ -1060,20 +1079,6 @@ func (b *Builder[S, E, C]) ForwardTo(targetID string, opts ...SendOption) *Build return b } -// StopChild attaches the kernel stopChild built-in to the most-recent transition: -// when the transition fires, the kernel emits a StopActor effect for the given -// actor id, so a machine can explicitly stop a spawned child actor (the -// `stopChild`). It is the action-level twin of StopActor and shares its effect; -// stopping an unknown id is a host-side no-op. The built-in needs no host -// registration. -func (b *Builder[S, E, C]) StopChild(id string) *Builder[S, E, C] { - if b.curTransition != nil { - b.curTransition.Effects = append(b.curTransition.Effects, - Ref{Name: stopChildBuiltinName, Params: map[string]any{stopChildIDParam: id}}) - } - return b -} - // Initial sets the entry state. At the top level it sets the machine's initial // state; inside a SuperState or Region block it sets that block's initial child. func (b *Builder[S, E, C]) Initial(name S) *Builder[S, E, C] { @@ -1325,7 +1330,8 @@ func (b *Builder[S, E, C]) Do(actionName string, params ...map[string]any) *Buil // fires — the sole context-mutation site under the value-semantics contract. It is // distinct from Do: Do emits an effect, Assign computes the next context. The // referenced reducer is registered separately by Builder.Reducer (alias of -// Registry.Assign); this WIRES a registered reducer by name onto the transition. +// Registry.Reducer); this WIRES a registered reducer by name onto the transition, +// mirroring how When wires a Guard and Do wires an Action. func (b *Builder[S, E, C]) Assign(assignName string, params ...map[string]any) *Builder[S, E, C] { if b.curTransition != nil { b.curTransition.Assigns = append(b.curTransition.Assigns, Ref{Name: assignName, Params: firstParams(params)}) diff --git a/state/options.go b/state/options.go index 2c34e1a..5ecfed4 100644 --- a/state/options.go +++ b/state/options.go @@ -46,10 +46,14 @@ func Strict() QuenchOption { return func(c *quenchConfig) { c.strict = true } } type InvokeOption func(*invokeConfig) type invokeConfig struct { - id string - params map[string]any - input map[string]any - systemID string + id string + params map[string]any + input map[string]any + systemID string + onDone any + onError any + hasOnDone bool + hasOnError bool } // WithInput sets the serializable input passed to an invoked service when it @@ -80,6 +84,32 @@ func WithSystemID(id string) InvokeOption { return func(c *invokeConfig) { c.systemID = id } } +// WithInvokeOnDone sets the event the host re-fires through Fire when an invoked +// service completes successfully (or, for InvokeActor, when the child machine +// reaches its final state), routing the result through an ordinary transition +// from the owning state. Omitting it leaves the invocation's OnDone at the zero +// event. It mirrors WithSpawnOnDone for the declarative Invoke / InvokeActor +// surface, so completion routing arrives as an additive option rather than a +// positional parameter; the same shape lets fire-and-forget or onCancel routing +// arrive later as further options without a signature change. +func WithInvokeOnDone[E comparable](onDone E) InvokeOption { + return func(c *invokeConfig) { + c.onDone = onDone + c.hasOnDone = true + } +} + +// WithInvokeOnError sets the event the host re-fires through Fire when an invoked +// service fails (or, for InvokeActor, when the child machine fails), routing the +// error through an ordinary transition from the owning state. Omitting it leaves +// the invocation's OnError at the zero event. It mirrors WithSpawnOnError. +func WithInvokeOnError[E comparable](onError E) InvokeOption { + return func(c *invokeConfig) { + c.onError = onError + c.hasOnError = true + } +} + // SpawnOption configures a Builder.Spawn declaration (the dynamic spawn built-in). type SpawnOption func(*spawnConfig) diff --git a/state/palette.go b/state/palette.go index dd8af2d..63b21d0 100644 --- a/state/palette.go +++ b/state/palette.go @@ -373,15 +373,9 @@ func BuiltinPalette() []Descriptor { { Kind: KindAction, Name: stopActorBuiltinName, - Description: "Stops a running spawned actor by id.", + Description: "Stops a running spawned or invoked-child actor by id.", Params: []ParamSpec{{Name: stopActorIDParam, Type: StringParam, Required: true}}, }, - { - Kind: KindAction, - Name: stopChildBuiltinName, - Description: "Stops a spawned child actor by id.", - Params: []ParamSpec{{Name: stopChildIDParam, Type: StringParam, Required: true}}, - }, { Kind: KindAction, Name: sendToBuiltinName, diff --git a/state/region_lifecycle_test.go b/state/region_lifecycle_test.go index 89bcfa4..f11c800 100644 --- a/state/region_lifecycle_test.go +++ b/state/region_lifecycle_test.go @@ -143,7 +143,7 @@ func regionInvokeMachine() *state.Machine[string, string, *trec] { Region("work"). Initial("wIdle"). SubState("wIdle"). - SubState("wLoading").Invoke("fetch", "ok", "fail"). + SubState("wLoading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")). SubState("wReady"). SubState("wErrored"). EndRegion(). @@ -255,7 +255,7 @@ func regionActorMachine() *state.Machine[string, string, *trec] { Region("work"). Initial("wIdle"). SubState("wIdle"). - SubState("wSuper").InvokeActor("child", "childDone", "childErr"). + SubState("wSuper").InvokeActor("child", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). SubState("wDone"). EndRegion(). Region("side"). diff --git a/state/runner.go b/state/runner.go index b401257..e8c57e0 100644 --- a/state/runner.go +++ b/state/runner.go @@ -217,15 +217,16 @@ func (r *ServiceRunner[S, E, C]) settle(ctx context.Context, id string, result a return res, true } -// Run resolves and runs the in-flight service id against the bound registry, -// settling it with the ServiceFn's result or error. It is the production -// convenience that couples resolve + run + settle: a host that arms services from -// Absorb and wants the runner to execute them calls Run(ctx, id) (typically from -// its own goroutine). It returns the routed FireResult and true, or false when id -// is not in flight or no registry / ServiceFn resolves it (in which case the -// service is settled as an error so the machine still routes onError rather than -// hanging). -func (r *ServiceRunner[S, E, C]) Run(ctx context.Context, id string) (FireResult[S], bool) { +// Tick resolves and runs the in-flight service id against the bound registry, +// settling it with the ServiceFn's result or error. It is the ServiceRunner's +// advance verb — the host-driver counterpart of Scheduler.Tick and +// ActorSystem.Tick — coupling resolve + run + settle: a host that arms services +// from Absorb and wants the runner to execute them calls Tick(ctx, id) (typically +// from its own goroutine). It returns the routed FireResult and true, or false +// when id is not in flight or no registry / ServiceFn resolves it (in which case +// the service is settled as an error so the machine still routes onError rather +// than hanging). +func (r *ServiceRunner[S, E, C]) Tick(ctx context.Context, id string) (FireResult[S], bool) { r.mu.Lock() rs, ok := r.running[id] r.mu.Unlock() diff --git a/state/snapshot_test.go b/state/snapshot_test.go index 4b27e28..201d8b2 100644 --- a/state/snapshot_test.go +++ b/state/snapshot_test.go @@ -334,7 +334,7 @@ func TestResumeEffects_ReArmsPendingService(t *testing.T) { m := state.Forge[string, string, *snapCtx]("svc"). Service("fetch", func(context.Context, state.ServiceCtx[*snapCtx]) (any, error) { return nil, nil }). State("idle"). - State("loading").Invoke("fetch", "ok", "fail"). + State("loading").Invoke("fetch", state.WithInvokeOnDone("ok"), state.WithInvokeOnError("fail")). State("ready"). Initial("idle"). Transition("idle").On("start").GoTo("loading"). @@ -419,7 +419,7 @@ func snapChildMachine() *state.Machine[string, string, *snapChildCtx] { func snapParentMachine() *state.Machine[string, string, *snapCtx] { return state.Forge[string, string, *snapCtx]("snapparent"). State("idle"). - State("supervising").InvokeActor("snapchild", "childDone", "childErr"). + State("supervising").InvokeActor("snapchild", state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")). State("complete"). Initial("idle"). Transition("idle").On("start").GoTo("supervising").