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.
- orca_runtime_python/__init__.py +69 -0
- orca_runtime_python/bus.py +227 -0
- orca_runtime_python/effects.py +216 -0
- orca_runtime_python/logging.py +161 -0
- orca_runtime_python/machine.py +875 -0
- orca_runtime_python/parser.py +894 -0
- orca_runtime_python/persistence.py +83 -0
- orca_runtime_python/types.py +279 -0
- orca_runtime_python-0.1.0.dist-info/METADATA +246 -0
- orca_runtime_python-0.1.0.dist-info/RECORD +12 -0
- orca_runtime_python-0.1.0.dist-info/WHEEL +5 -0
- orca_runtime_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|