orca-runtime-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,875 @@
1
+ """
2
+ Orca state machine runtime.
3
+
4
+ Async state machine that executes Orca machine definitions,
5
+ publishing state changes to an event bus and executing effects
6
+ via registered handlers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+ from typing import Any, Awaitable, Callable
14
+
15
+ from .types import (
16
+ MachineDef,
17
+ StateDef,
18
+ StateValue,
19
+ Transition,
20
+ Effect,
21
+ EffectResult,
22
+ EffectStatus,
23
+ ActionSignature,
24
+ GuardExpression,
25
+ GuardTrue,
26
+ GuardFalse,
27
+ GuardCompare,
28
+ GuardAnd,
29
+ GuardOr,
30
+ GuardNot,
31
+ GuardNullcheck,
32
+ VariableRef,
33
+ ValueRef,
34
+ InvokeDef,
35
+ )
36
+ from .bus import EventBus, Event, EventType, get_event_bus
37
+
38
+
39
+ # Type alias for transition callback
40
+ TransitionCallback = Callable[[StateValue, StateValue], Awaitable[None]]
41
+
42
+ # Action handler: (context, event_payload?) -> context_updates or None
43
+ ActionHandler = Callable[..., Any] # async (dict, dict|None) -> dict|None
44
+
45
+
46
+ @dataclass
47
+ class TransitionResult:
48
+ """Result of a transition attempt."""
49
+ taken: bool
50
+ from_state: str
51
+ to_state: str | None = None
52
+ guard_failed: bool = False
53
+ error: str | None = None
54
+
55
+
56
+ class OrcaMachine:
57
+ """
58
+ Async Orca state machine runtime.
59
+
60
+ Executes Orca machine definitions with:
61
+ - Event-driven transitions
62
+ - Hierarchical (nested) state support
63
+ - Effect execution via event bus
64
+ - Guard condition evaluation
65
+
66
+ All methods are async. The machine must be started before
67
+ sending events.
68
+
69
+ Example:
70
+ machine = OrcaMachine(definition, event_bus=get_event_bus())
71
+ await machine.start()
72
+ await machine.send("ORDER_PLACED", {"order_id": "123"})
73
+ print(machine.state)
74
+ await machine.stop()
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ definition: MachineDef,
80
+ event_bus: EventBus | None = None,
81
+ context: dict[str, Any] | None = None,
82
+ on_transition: TransitionCallback | None = None,
83
+ ):
84
+ self.definition = definition
85
+ self.event_bus = event_bus or get_event_bus()
86
+ self.context = context or dict(definition.context)
87
+ self.on_transition = on_transition
88
+
89
+ # Internal state
90
+ self._state: StateValue = StateValue(self._get_initial_state())
91
+ self._active: bool = False
92
+ self._action_handlers: dict[str, ActionHandler] = {}
93
+ self._timeout_task: asyncio.Task | None = None
94
+
95
+ # Child machine management
96
+ self._child_machines: dict[str, OrcaMachine] = {}
97
+ self._sibling_machines: dict[str, MachineDef] | None = None
98
+ self._active_invoke: str | None = None
99
+
100
+ def _get_initial_state(self) -> str:
101
+ """Find the initial state name."""
102
+ for state in self.definition.states:
103
+ if state.is_initial:
104
+ return state.name
105
+ # Fallback to first state
106
+ if self.definition.states:
107
+ return self.definition.states[0].name
108
+ return "unknown"
109
+
110
+ @property
111
+ def state(self) -> StateValue:
112
+ """Current state value."""
113
+ return self._state
114
+
115
+ @property
116
+ def is_active(self) -> bool:
117
+ """Whether the machine is running."""
118
+ return self._active
119
+
120
+ def snapshot(self) -> dict[str, Any]:
121
+ """
122
+ Capture the current machine state as a serializable snapshot.
123
+ The snapshot includes state value, context, and timestamp.
124
+ Child machine snapshots are included.
125
+ Action handlers are NOT included — re-register them after restore.
126
+ """
127
+ import copy
128
+ import time
129
+ state_val = self._state.value
130
+ return {
131
+ "state": copy.deepcopy(state_val),
132
+ "context": copy.deepcopy(self.context),
133
+ "children": {k: m.snapshot() for k, m in self._child_machines.items()},
134
+ "active_invoke": self._active_invoke,
135
+ "timestamp": time.time(),
136
+ }
137
+
138
+ async def restore(self, snap: dict[str, Any]) -> None:
139
+ """
140
+ Restore machine state from a previously captured snapshot.
141
+ Action handlers must be re-registered after restore.
142
+ """
143
+ import copy
144
+ # Cancel any active timeout
145
+ self._cancel_timeout()
146
+
147
+ # Restore state and context
148
+ self._state = StateValue(copy.deepcopy(snap["state"]))
149
+ self.context = copy.deepcopy(snap["context"])
150
+
151
+ # If machine was active, restart timeouts for current leaf states
152
+ if self._active:
153
+ for leaf in self._state.leaves():
154
+ self._start_timeout_for_state(leaf)
155
+
156
+ async def resume(self, snap: dict[str, Any]) -> None:
157
+ """
158
+ Boot the machine from a saved snapshot, skipping on_entry for the
159
+ restored state. Use instead of start() when resuming a crashed run.
160
+
161
+ Unlike restore() (which is a live-machine primitive), resume() is the
162
+ cold-start path: the machine was inactive, a snapshot was found on
163
+ disk, and we want to continue from where we left off without
164
+ re-executing the actions that already ran before the crash.
165
+ """
166
+ import copy
167
+ if self._active:
168
+ return
169
+
170
+ self._state = StateValue(copy.deepcopy(snap["state"]))
171
+ self.context = copy.deepcopy(snap["context"])
172
+ self._active = True
173
+
174
+ await self.event_bus.publish(Event(
175
+ type=EventType.MACHINE_STARTED,
176
+ source=self.definition.name,
177
+ payload={
178
+ "machine": self.definition.name,
179
+ "initial_state": self._state.value,
180
+ "resumed": True,
181
+ }
182
+ ))
183
+
184
+ for leaf in self._state.leaves():
185
+ self._start_timeout_for_state(leaf)
186
+
187
+ def register_machines(self, machines: dict[str, MachineDef]) -> None:
188
+ """Register sibling machines for invocation."""
189
+ self._sibling_machines = machines
190
+
191
+ async def start_child_machine(self, state_name: str, invoke_def: InvokeDef) -> None:
192
+ """Start a child machine as part of an invoke state."""
193
+ if self._sibling_machines is None:
194
+ return
195
+ if invoke_def.machine not in self._sibling_machines:
196
+ return
197
+
198
+ child_def = self._sibling_machines[invoke_def.machine]
199
+
200
+ # Map input from parent context
201
+ child_context = dict(child_def.context)
202
+ if invoke_def.input:
203
+ for key, value in invoke_def.input.items():
204
+ field_name = value.replace("ctx.", "")
205
+ child_context[key] = self.context.get(field_name)
206
+
207
+ # Create child machine
208
+ child = OrcaMachine(
209
+ definition=child_def,
210
+ event_bus=self.event_bus,
211
+ context=child_context,
212
+ )
213
+ self._child_machines[state_name] = child
214
+ self._active_invoke = state_name
215
+
216
+ # Set up completion/error listeners
217
+ async def on_transition_handler(old: StateValue, new: StateValue) -> None:
218
+ if new.is_compound():
219
+ return
220
+ child_state = new.leaf()
221
+ child_state_def = child._find_state_def(child_state)
222
+ if child_state_def and child_state_def.is_final:
223
+ # Child reached final state
224
+ if invoke_def.on_done:
225
+ await self.send(invoke_def.on_done, {
226
+ "child": invoke_def.machine,
227
+ "final_state": child_state,
228
+ "context": child.context,
229
+ })
230
+ await child.stop()
231
+ self._child_machines.pop(state_name, None)
232
+ if self._active_invoke == state_name:
233
+ self._active_invoke = None
234
+
235
+ child.on_transition = on_transition_handler
236
+ await child.start()
237
+
238
+ async def stop_child_machine(self, state_name: str) -> None:
239
+ """Stop a child machine associated with a state."""
240
+ if self._active_invoke == state_name:
241
+ child = self._child_machines.get(state_name)
242
+ if child:
243
+ await child.stop()
244
+ self._child_machines.pop(state_name, None)
245
+ self._active_invoke = None
246
+
247
+ def register_action(self, name: str, handler: ActionHandler) -> None:
248
+ """Register a handler for a plain (non-effect) action."""
249
+ self._action_handlers[name] = handler
250
+
251
+ def unregister_action(self, name: str) -> None:
252
+ """Unregister an action handler."""
253
+ self._action_handlers.pop(name, None)
254
+
255
+ async def start(self) -> None:
256
+ """Start the state machine and execute initial state's on_entry."""
257
+ if self._active:
258
+ return
259
+
260
+ self._active = True
261
+
262
+ await self.event_bus.publish(Event(
263
+ type=EventType.MACHINE_STARTED,
264
+ source=self.definition.name,
265
+ payload={
266
+ "machine": self.definition.name,
267
+ "initial_state": self._state.value,
268
+ }
269
+ ))
270
+
271
+ # Execute entry actions for initial state
272
+ await self._execute_entry_actions(self._state.leaf())
273
+
274
+ # Start timeout for initial state if defined
275
+ self._start_timeout_for_state(self._state.leaf())
276
+
277
+ async def stop(self) -> None:
278
+ """Stop the state machine."""
279
+ if not self._active:
280
+ return
281
+
282
+ self._cancel_timeout()
283
+
284
+ # Stop all child machines
285
+ for child in list(self._child_machines.values()):
286
+ await child.stop()
287
+ self._child_machines.clear()
288
+ self._active_invoke = None
289
+
290
+ self._active = False
291
+
292
+ await self.event_bus.publish(Event(
293
+ type=EventType.MACHINE_STOPPED,
294
+ source=self.definition.name,
295
+ ))
296
+
297
+ async def send(
298
+ self,
299
+ event: str | Event,
300
+ payload: dict[str, Any] | None = None,
301
+ ) -> TransitionResult:
302
+ """
303
+ Send an event to the machine.
304
+
305
+ Args:
306
+ event: Event name (str) or Event object
307
+ payload: Optional payload for the event
308
+
309
+ Returns:
310
+ TransitionResult indicating what happened
311
+ """
312
+ if not self._active:
313
+ return TransitionResult(
314
+ taken=False,
315
+ from_state=str(self._state),
316
+ error="Machine is not active"
317
+ )
318
+
319
+ # Normalize to Event, preserving the original event name
320
+ if isinstance(event, str):
321
+ evt = Event(
322
+ type=self._find_event_type(event),
323
+ source=self.definition.name,
324
+ event_name=event, # Store original event name
325
+ payload=payload or {}
326
+ )
327
+ else:
328
+ evt = event
329
+
330
+ # Check if event is explicitly ignored in current state
331
+ event_key = evt.event_name or evt.type.value
332
+ if self._is_event_ignored(event_key):
333
+ return TransitionResult(
334
+ taken=False,
335
+ from_state=str(self._state),
336
+ )
337
+
338
+ # Find matching transition
339
+ transition = self._find_transition(evt)
340
+
341
+ if not transition:
342
+ # No transition found - event unhandled
343
+ return TransitionResult(
344
+ taken=False,
345
+ from_state=str(self._state),
346
+ error=f"No transition for event {event_key} from {self._state.leaf()}"
347
+ )
348
+
349
+ # Evaluate guard if present
350
+ if transition.guard:
351
+ guard_passed = await self._evaluate_guard(transition.guard)
352
+ if not guard_passed:
353
+ return TransitionResult(
354
+ taken=False,
355
+ from_state=str(self._state),
356
+ guard_failed=True,
357
+ error=f"Guard '{transition.guard}' failed"
358
+ )
359
+
360
+ # Execute the transition
361
+ old_state = StateValue(self._state.value)
362
+ new_state_name = transition.target
363
+
364
+ # Cancel any active timeout from the old state
365
+ self._cancel_timeout()
366
+
367
+ # Execute exit actions
368
+ await self._execute_exit_actions(old_state.leaf())
369
+
370
+ # Execute transition action
371
+ if transition.action:
372
+ await self._execute_action(transition.action, evt.payload)
373
+
374
+ # Update state
375
+ if self._is_parallel_state(new_state_name):
376
+ self._state = StateValue(self._build_parallel_state_value(new_state_name))
377
+ elif self._is_compound_state(new_state_name):
378
+ # Enter compound state at its initial child
379
+ initial_child = self._get_initial_child(new_state_name)
380
+ self._state = StateValue({new_state_name: {initial_child: {}}})
381
+ else:
382
+ # Check if we're inside a parallel state and need to update just one region
383
+ updated_in_region = self._try_update_parallel_region(new_state_name)
384
+ if not updated_in_region:
385
+ self._state = StateValue(new_state_name)
386
+
387
+ # Publish transition started
388
+ await self.event_bus.publish(Event(
389
+ type=EventType.TRANSITION_STARTED,
390
+ source=self.definition.name,
391
+ payload={
392
+ "from": str(old_state),
393
+ "to": new_state_name,
394
+ "trigger": evt.type.value,
395
+ }
396
+ ))
397
+
398
+ # Execute entry actions for new state
399
+ if self._is_parallel_state(new_state_name):
400
+ # Execute entry actions for all region initial states
401
+ state_def = self._find_state_def_deep(new_state_name)
402
+ if state_def and state_def.parallel:
403
+ for region in state_def.parallel.regions:
404
+ initial_child = next(
405
+ (s for s in region.states if s.is_initial),
406
+ region.states[0] if region.states else None
407
+ )
408
+ if initial_child:
409
+ await self._execute_entry_actions(initial_child.name)
410
+ else:
411
+ await self._execute_entry_actions(new_state_name)
412
+
413
+ # Start timeout for all active leaf states
414
+ for leaf in self._state.leaves():
415
+ self._start_timeout_for_state(leaf)
416
+
417
+ # Check parallel sync condition
418
+ await self._check_parallel_sync()
419
+
420
+ # Notify callback
421
+ if self.on_transition:
422
+ await self.on_transition(old_state, self._state)
423
+
424
+ # Publish transition completed
425
+ await self.event_bus.publish(Event(
426
+ type=EventType.TRANSITION_COMPLETED,
427
+ source=self.definition.name,
428
+ payload={
429
+ "from": str(old_state),
430
+ "to": str(self._state),
431
+ }
432
+ ))
433
+
434
+ return TransitionResult(
435
+ taken=True,
436
+ from_state=str(old_state),
437
+ to_state=str(self._state)
438
+ )
439
+
440
+ def _find_event_type(self, event_name: str) -> EventType:
441
+ """Map event name to EventType enum."""
442
+ # Build a mapping from event names to event types
443
+ event_type_map = {
444
+ "state_changed": EventType.STATE_CHANGED,
445
+ "transition_started": EventType.TRANSITION_STARTED,
446
+ "transition_completed": EventType.TRANSITION_COMPLETED,
447
+ "effect_executing": EventType.EFFECT_EXECUTING,
448
+ "effect_completed": EventType.EFFECT_COMPLETED,
449
+ "effect_failed": EventType.EFFECT_FAILED,
450
+ "machine_started": EventType.MACHINE_STARTED,
451
+ "machine_stopped": EventType.MACHINE_STOPPED,
452
+ }
453
+
454
+ # Check for direct event name match
455
+ normalized = event_name.lower().replace("-", "_")
456
+ if normalized in event_type_map:
457
+ return event_type_map[normalized]
458
+
459
+ # Check if it's a defined event in the machine
460
+ for defined_event in self.definition.events:
461
+ if defined_event.lower().replace("-", "_") == normalized:
462
+ return EventType.STATE_CHANGED
463
+
464
+ # Default fallback
465
+ return EventType.STATE_CHANGED
466
+
467
+ def _find_transition(self, event: Event) -> Transition | None:
468
+ """Find a transition matching the current state and event."""
469
+ event_key = event.event_name or event.type.value
470
+
471
+ # Check all active leaf states (important for parallel states)
472
+ for current in self._state.leaves():
473
+ # Try direct match on current leaf state
474
+ for t in self.definition.transitions:
475
+ if t.source == current and t.event == event_key:
476
+ return t
477
+
478
+ # For compound states, also check parent's transitions
479
+ # Transitions on parent fire from any child
480
+ parent = self._get_parent_state(current)
481
+ while parent:
482
+ for t in self.definition.transitions:
483
+ if t.source == parent and t.event == event_key:
484
+ return t
485
+ parent = self._get_parent_state(parent)
486
+
487
+ return None
488
+
489
+ def _get_parent_state(self, state_name: str) -> str | None:
490
+ """Get parent state name if state is nested (searches parallel regions too)."""
491
+ def search(states: list[StateDef], parent_name: str | None = None) -> str | None:
492
+ for state in states:
493
+ if state.name == state_name:
494
+ return state.parent or parent_name
495
+ if state.contains:
496
+ for child in state.contains:
497
+ if child.name == state_name:
498
+ return state.name
499
+ found = search(state.contains, state.name)
500
+ if found is not None:
501
+ return found
502
+ if state.parallel:
503
+ for region in state.parallel.regions:
504
+ for child in region.states:
505
+ if child.name == state_name:
506
+ return state.name
507
+ return None
508
+ return search(self.definition.states)
509
+
510
+ def _is_compound_state(self, state_name: str) -> bool:
511
+ """Check if a state has nested children or parallel regions."""
512
+ state = self._find_state_def_deep(state_name)
513
+ if not state:
514
+ return False
515
+ return bool(state.contains) or bool(state.parallel)
516
+
517
+ def _is_parallel_state(self, state_name: str) -> bool:
518
+ """Check if a state has parallel regions."""
519
+ state = self._find_state_def_deep(state_name)
520
+ return bool(state and state.parallel)
521
+
522
+ def _get_initial_child(self, parent_name: str) -> str:
523
+ """Get the initial child state name of a compound state."""
524
+ state = self._find_state_def_deep(parent_name)
525
+ if state and state.contains:
526
+ for child in state.contains:
527
+ if child.is_initial:
528
+ return child.name
529
+ return state.contains[0].name
530
+ return parent_name
531
+
532
+ def _build_parallel_state_value(self, state_name: str) -> dict:
533
+ """Build the StateValue dict for entering a parallel state."""
534
+ state = self._find_state_def_deep(state_name)
535
+ if not state or not state.parallel:
536
+ return {state_name: {}}
537
+ regions = {}
538
+ for region in state.parallel.regions:
539
+ initial_child = next(
540
+ (s for s in region.states if s.is_initial),
541
+ region.states[0] if region.states else None
542
+ )
543
+ if initial_child:
544
+ regions[region.name] = {initial_child.name: {}}
545
+ return {state_name: regions}
546
+
547
+ def _try_update_parallel_region(self, target_state_name: str) -> bool:
548
+ """Update only the relevant region in a parallel state value."""
549
+ if not isinstance(self._state.value, dict):
550
+ return False
551
+ for top_state in self.definition.states:
552
+ if not top_state.parallel:
553
+ continue
554
+ for region in top_state.parallel.regions:
555
+ in_region = any(s.name == target_state_name for s in region.states)
556
+ if in_region and top_state.name in self._state.value:
557
+ self._state.value[top_state.name][region.name] = {target_state_name: {}}
558
+ return True
559
+ return False
560
+
561
+ def _all_regions_final(self, state_name: str) -> bool:
562
+ """Check if all regions of a parallel state have reached final states."""
563
+ state = self._find_state_def_deep(state_name)
564
+ if not state or not state.parallel:
565
+ return False
566
+ current_leaves = self._state.leaves()
567
+ for region in state.parallel.regions:
568
+ final_names = [s.name for s in region.states if s.is_final]
569
+ if not any(leaf in final_names for leaf in current_leaves):
570
+ return False
571
+ return True
572
+
573
+ def _any_region_final(self, state_name: str) -> bool:
574
+ """Check if any region of a parallel state has reached a final state."""
575
+ state = self._find_state_def_deep(state_name)
576
+ if not state or not state.parallel:
577
+ return False
578
+ current_leaves = self._state.leaves()
579
+ for region in state.parallel.regions:
580
+ final_names = [s.name for s in region.states if s.is_final]
581
+ if any(leaf in final_names for leaf in current_leaves):
582
+ return True
583
+ return False
584
+
585
+ async def _check_parallel_sync(self) -> None:
586
+ """Check if any parallel state's sync condition is met and transition via on_done."""
587
+ for state in self.definition.states:
588
+ if not state.parallel or not state.on_done:
589
+ continue
590
+ sync = state.parallel.sync or "all-final"
591
+ should_transition = False
592
+ if sync == "all-final":
593
+ should_transition = self._all_regions_final(state.name)
594
+ elif sync == "any-final":
595
+ should_transition = self._any_region_final(state.name)
596
+ if should_transition:
597
+ old_state = StateValue(self._state.value)
598
+ self._cancel_timeout()
599
+ self._state = StateValue(state.on_done)
600
+ await self._execute_entry_actions(state.on_done)
601
+ self._start_timeout_for_state(self._state.leaf())
602
+ if self.on_transition:
603
+ await self.on_transition(old_state, self._state)
604
+
605
+ async def _evaluate_guard(self, guard_name: str) -> bool:
606
+ """Evaluate a guard by name."""
607
+ # Guards are defined in definition.guards
608
+ if guard_name not in self.definition.guards:
609
+ return True # Unknown guard = allow
610
+
611
+ # Evaluate the guard expression
612
+ guard_expr = self.definition.guards[guard_name]
613
+ return await self._eval_guard(guard_expr)
614
+
615
+ async def _eval_guard(self, expr: GuardExpression) -> bool:
616
+ """Evaluate a guard expression against the machine context."""
617
+ if isinstance(expr, GuardTrue):
618
+ return True
619
+ if isinstance(expr, GuardFalse):
620
+ return False
621
+ if isinstance(expr, GuardNot):
622
+ return not await self._eval_guard(expr.expr)
623
+ if isinstance(expr, GuardAnd):
624
+ return await self._eval_guard(expr.left) and await self._eval_guard(expr.right)
625
+ if isinstance(expr, GuardOr):
626
+ return await self._eval_guard(expr.left) or await self._eval_guard(expr.right)
627
+ if isinstance(expr, GuardCompare):
628
+ return self._eval_compare(expr.op, expr.left, expr.right)
629
+ if isinstance(expr, GuardNullcheck):
630
+ return self._eval_nullcheck(expr.expr, expr.is_null)
631
+ return True
632
+
633
+ def _resolve_variable(self, ref: VariableRef) -> Any:
634
+ """Resolve a variable path against the machine context."""
635
+ current: Any = self.context
636
+ for part in ref.path:
637
+ # Skip "ctx" or "context" prefix — context is already the root
638
+ if part in ("ctx", "context"):
639
+ continue
640
+ if current is None:
641
+ return None
642
+ if isinstance(current, dict):
643
+ current = current.get(part)
644
+ else:
645
+ current = getattr(current, part, None)
646
+ return current
647
+
648
+ def _resolve_value(self, ref: ValueRef) -> Any:
649
+ """Resolve a ValueRef to its Python value."""
650
+ return ref.value
651
+
652
+ def _eval_compare(self, op: str, left: VariableRef, right: ValueRef) -> bool:
653
+ """Evaluate a comparison guard."""
654
+ lhs = self._resolve_variable(left)
655
+ rhs = self._resolve_value(right)
656
+
657
+ # Try numeric comparison
658
+ try:
659
+ lnum = float(lhs) if not isinstance(lhs, (int, float)) else lhs
660
+ rnum = float(rhs) if not isinstance(rhs, (int, float)) else rhs
661
+ both_numeric = True
662
+ except (TypeError, ValueError):
663
+ both_numeric = False
664
+ lnum = rnum = 0
665
+
666
+ if op == "eq":
667
+ return lhs == rhs
668
+ if op == "ne":
669
+ return lhs != rhs
670
+ if op == "lt":
671
+ return lnum < rnum if both_numeric else str(lhs) < str(rhs)
672
+ if op == "gt":
673
+ return lnum > rnum if both_numeric else str(lhs) > str(rhs)
674
+ if op == "le":
675
+ return lnum <= rnum if both_numeric else str(lhs) <= str(rhs)
676
+ if op == "ge":
677
+ return lnum >= rnum if both_numeric else str(lhs) >= str(rhs)
678
+ return False
679
+
680
+ def _eval_nullcheck(self, expr: VariableRef, is_null: bool) -> bool:
681
+ """Evaluate a null check guard."""
682
+ val = self._resolve_variable(expr)
683
+ value_is_null = val is None
684
+ return value_is_null if is_null else not value_is_null
685
+
686
+ async def _execute_entry_actions(self, state_name: str) -> None:
687
+ """Execute on_entry action for a state."""
688
+ state_def = self._find_state_def(state_name)
689
+ if not state_def:
690
+ return
691
+
692
+ # Handle invoke - start child machine if present
693
+ if state_def.invoke:
694
+ await self.start_child_machine(state_name, state_def.invoke)
695
+ return # Don't execute on_entry if invoke is set
696
+
697
+ if not state_def.on_entry:
698
+ return
699
+
700
+ action_def = self._find_action_def(state_def.on_entry)
701
+ if action_def and action_def.has_effect:
702
+ # Execute as effect via event bus
703
+ effect = Effect(
704
+ type=action_def.effect_type or "Effect",
705
+ payload={
706
+ "action": state_def.on_entry,
707
+ "context": self.context,
708
+ "event": None,
709
+ }
710
+ )
711
+
712
+ await self.event_bus.publish(Event(
713
+ type=EventType.EFFECT_EXECUTING,
714
+ source=self.definition.name,
715
+ payload={"effect": effect.type}
716
+ ))
717
+
718
+ result = await self.event_bus.execute_effect(effect)
719
+
720
+ if result.status == EffectStatus.SUCCESS:
721
+ await self.event_bus.publish(Event(
722
+ type=EventType.EFFECT_COMPLETED,
723
+ source=self.definition.name,
724
+ payload={"effect": effect.type, "result": result.data}
725
+ ))
726
+ if result.data:
727
+ if isinstance(result.data, dict):
728
+ self.context.update(result.data)
729
+ else:
730
+ await self.event_bus.publish(Event(
731
+ type=EventType.EFFECT_FAILED,
732
+ source=self.definition.name,
733
+ payload={"effect": effect.type, "error": result.error}
734
+ ))
735
+ else:
736
+ # Simple action — call registered handler directly.
737
+ # The ## actions section is optional documentation; if a handler
738
+ # is registered we always call it regardless of action_def.
739
+ await self._execute_action(state_def.on_entry)
740
+
741
+ async def _execute_exit_actions(self, state_name: str) -> None:
742
+ """Execute on_exit action for a state."""
743
+ # Stop child machine if this state has an invoke
744
+ await self.stop_child_machine(state_name)
745
+
746
+ state_def = self._find_state_def(state_name)
747
+ if not state_def or not state_def.on_exit:
748
+ return
749
+ await self._execute_action(state_def.on_exit)
750
+
751
+ async def _execute_action(self, action_name: str, event_payload: dict[str, Any] | None = None) -> None:
752
+ """Execute a non-effect action via registered handler."""
753
+ handler = self._action_handlers.get(action_name)
754
+ if handler is None:
755
+ return # No handler registered — skip silently
756
+
757
+ result = handler(self.context, event_payload)
758
+ # Support both sync and async handlers
759
+ if asyncio.iscoroutine(result) or asyncio.isfuture(result):
760
+ result = await result
761
+ if result and isinstance(result, dict):
762
+ self.context.update(result)
763
+
764
+ def _start_timeout_for_state(self, state_name: str) -> None:
765
+ """Start a timeout timer for the given state if it has one."""
766
+ state_def = self._find_state_def(state_name)
767
+ if not state_def or not state_def.timeout:
768
+ return
769
+
770
+ # Parse duration string like "1s" or "5" (strip non-numeric suffix)
771
+ import re
772
+ duration_match = re.match(r"(\d+)", state_def.timeout["duration"])
773
+ duration_s = int(duration_match.group(1)) if duration_match else 0
774
+ target = state_def.timeout["target"]
775
+
776
+ async def _timeout_handler():
777
+ await asyncio.sleep(duration_s)
778
+ if self._active and self._state.leaf() == state_name:
779
+ await self._execute_timeout_transition(state_name, target)
780
+
781
+ self._timeout_task = asyncio.create_task(_timeout_handler())
782
+
783
+ def _cancel_timeout(self) -> None:
784
+ """Cancel any active timeout timer."""
785
+ if self._timeout_task is not None:
786
+ self._timeout_task.cancel()
787
+ self._timeout_task = None
788
+
789
+ async def _execute_timeout_transition(self, from_state: str, target: str) -> None:
790
+ """Execute an automatic timeout transition."""
791
+ old_state = StateValue(self._state.value)
792
+
793
+ # Execute exit actions
794
+ await self._execute_exit_actions(from_state)
795
+
796
+ # Update state
797
+ if self._is_parallel_state(target):
798
+ self._state = StateValue(self._build_parallel_state_value(target))
799
+ elif self._is_compound_state(target):
800
+ initial_child = self._get_initial_child(target)
801
+ self._state = StateValue({target: {initial_child: {}}})
802
+ else:
803
+ self._state = StateValue(target)
804
+
805
+ await self.event_bus.publish(Event(
806
+ type=EventType.TRANSITION_STARTED,
807
+ source=self.definition.name,
808
+ payload={
809
+ "from": str(old_state),
810
+ "to": target,
811
+ "trigger": "timeout",
812
+ }
813
+ ))
814
+
815
+ await self._execute_entry_actions(target)
816
+
817
+ # Start timeout for all active leaf states
818
+ for leaf in self._state.leaves():
819
+ self._start_timeout_for_state(leaf)
820
+
821
+ if self.on_transition:
822
+ await self.on_transition(old_state, self._state)
823
+
824
+ await self.event_bus.publish(Event(
825
+ type=EventType.TRANSITION_COMPLETED,
826
+ source=self.definition.name,
827
+ payload={
828
+ "from": str(old_state),
829
+ "to": str(self._state),
830
+ }
831
+ ))
832
+
833
+ def _is_event_ignored(self, event_name: str) -> bool:
834
+ """Check if event is explicitly ignored in current state or parent states."""
835
+ current = self._state.leaf()
836
+ state_def = self._find_state_def(current)
837
+ if state_def and event_name in state_def.ignored_events:
838
+ return True
839
+ # Also check parent state's ignored events
840
+ parent = self._get_parent_state(current)
841
+ while parent:
842
+ parent_def = self._find_state_def(parent)
843
+ if parent_def and event_name in parent_def.ignored_events:
844
+ return True
845
+ parent = self._get_parent_state(parent)
846
+ return False
847
+
848
+ def _find_state_def(self, state_name: str) -> StateDef | None:
849
+ """Find state definition by name (including nested and parallel regions)."""
850
+ return self._find_state_def_deep(state_name)
851
+
852
+ def _find_state_def_deep(self, state_name: str) -> StateDef | None:
853
+ """Search all states including nested and parallel region states."""
854
+ def search(states: list[StateDef]) -> StateDef | None:
855
+ for state in states:
856
+ if state.name == state_name:
857
+ return state
858
+ if state.contains:
859
+ found = search(state.contains)
860
+ if found:
861
+ return found
862
+ if state.parallel:
863
+ for region in state.parallel.regions:
864
+ found = search(region.states)
865
+ if found:
866
+ return found
867
+ return None
868
+ return search(self.definition.states)
869
+
870
+ def _find_action_def(self, action_name: str) -> ActionSignature | None:
871
+ """Find action definition by name."""
872
+ for action in self.definition.actions:
873
+ if action.name == action_name:
874
+ return action
875
+ return None