orca-runtime-python 0.1.16__tar.gz → 0.1.18__tar.gz

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.
Files changed (24) hide show
  1. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/PKG-INFO +1 -1
  2. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/__init__.py +5 -2
  3. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/machine.py +77 -7
  4. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/parser.py +48 -1
  5. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/persistence.py +23 -1
  6. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/types.py +1 -0
  7. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python.egg-info/PKG-INFO +1 -1
  8. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python.egg-info/SOURCES.txt +1 -0
  9. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/pyproject.toml +1 -1
  10. orca_runtime_python-0.1.18/tests/test_production_hardening.py +391 -0
  11. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/README.md +0 -0
  12. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/bus.py +0 -0
  13. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/effects.py +0 -0
  14. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python/logging.py +0 -0
  15. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python.egg-info/dependency_links.txt +0 -0
  16. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python.egg-info/requires.txt +0 -0
  17. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/orca_runtime_python.egg-info/top_level.txt +0 -0
  18. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/setup.cfg +0 -0
  19. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/tests/test_actions_timeouts.py +0 -0
  20. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/tests/test_guards.py +0 -0
  21. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/tests/test_ignored_events.py +0 -0
  22. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/tests/test_md_parser.py +0 -0
  23. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/tests/test_parallel.py +0 -0
  24. {orca_runtime_python-0.1.16 → orca_runtime_python-0.1.18}/tests/test_snapshot_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orca-runtime-python
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Python async runtime for Orca state machines
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/orca-lang/orca-lang
@@ -25,11 +25,11 @@ from .bus import (
25
25
  get_event_bus,
26
26
  )
27
27
 
28
- from .machine import OrcaMachine
28
+ from .machine import OrcaMachine, MachineNotActiveError, TransitionResult
29
29
 
30
30
  from .parser import parse_orca_md, parse_orca_auto
31
31
 
32
- from .persistence import PersistenceAdapter, FilePersistence
32
+ from .persistence import PersistenceAdapter, AsyncPersistenceAdapter, FilePersistence
33
33
 
34
34
  from .logging import LogSink, FileSink, ConsoleSink, MultiSink
35
35
 
@@ -55,11 +55,14 @@ __all__ = [
55
55
  "get_event_bus",
56
56
  # Machine
57
57
  "OrcaMachine",
58
+ "MachineNotActiveError",
59
+ "TransitionResult",
58
60
  # Parser
59
61
  "parse_orca_md",
60
62
  "parse_orca_auto",
61
63
  # Persistence
62
64
  "PersistenceAdapter",
65
+ "AsyncPersistenceAdapter",
63
66
  "FilePersistence",
64
67
  # Logging
65
68
  "LogSink",
@@ -34,6 +34,7 @@ from .types import (
34
34
  InvokeDef,
35
35
  )
36
36
  from .bus import EventBus, Event, EventType, get_event_bus
37
+ from .persistence import PersistenceAdapter, AsyncPersistenceAdapter
37
38
 
38
39
 
39
40
  # Type alias for transition callback
@@ -43,12 +44,24 @@ TransitionCallback = Callable[[StateValue, StateValue], Awaitable[None]]
43
44
  ActionHandler = Callable[..., Any] # async (dict, dict|None) -> dict|None
44
45
 
45
46
 
47
+ class MachineNotActiveError(RuntimeError):
48
+ """
49
+ Raised when send() is called on a machine that has not been started
50
+ or has already been stopped.
51
+
52
+ Always indicates a caller-side sequencing error. The fix is to ensure
53
+ start() or resume() completes before any send() call reaches this machine.
54
+ """
55
+ pass
56
+
57
+
46
58
  @dataclass
47
59
  class TransitionResult:
48
60
  """Result of a transition attempt."""
49
61
  taken: bool
50
62
  from_state: str
51
63
  to_state: str | None = None
64
+ to_state_leaf: str | None = None
52
65
  guard_failed: bool = False
53
66
  error: str | None = None
54
67
 
@@ -80,6 +93,8 @@ class OrcaMachine:
80
93
  event_bus: EventBus | None = None,
81
94
  context: dict[str, Any] | None = None,
82
95
  on_transition: TransitionCallback | None = None,
96
+ persistence: PersistenceAdapter | AsyncPersistenceAdapter | None = None,
97
+ run_id: str | None = None,
83
98
  ):
84
99
  self.definition = definition
85
100
  self.event_bus = event_bus or get_event_bus()
@@ -92,6 +107,10 @@ class OrcaMachine:
92
107
  self._action_handlers: dict[str, ActionHandler] = {}
93
108
  self._timeout_task: asyncio.Task | None = None
94
109
 
110
+ # Persistence
111
+ self._persistence = persistence
112
+ self._run_id = run_id
113
+
95
114
  # Child machine management
96
115
  self._child_machines: dict[str, OrcaMachine] = {}
97
116
  self._sibling_machines: dict[str, MachineDef] | None = None
@@ -120,14 +139,16 @@ class OrcaMachine:
120
139
  def snapshot(self) -> dict[str, Any]:
121
140
  """
122
141
  Capture the current machine state as a serializable snapshot.
123
- The snapshot includes state value, context, and timestamp.
124
- Child machine snapshots are included.
142
+ The snapshot includes machine name, definition version, state value,
143
+ context, and timestamp. Child machine snapshots are included.
125
144
  Action handlers are NOT included — re-register them after restore.
126
145
  """
127
146
  import copy
128
147
  import time
129
148
  state_val = self._state.value
130
149
  return {
150
+ "machine": self.definition.name,
151
+ "definition_version": self.definition.version,
131
152
  "state": copy.deepcopy(state_val),
132
153
  "context": copy.deepcopy(self.context),
133
154
  "children": {k: m.snapshot() for k, m in self._child_machines.items()},
@@ -153,6 +174,29 @@ class OrcaMachine:
153
174
  for leaf in self._state.leaves():
154
175
  self._start_timeout_for_state(leaf)
155
176
 
177
+ async def load_or_start(self) -> None:
178
+ """
179
+ If a snapshot exists for self._run_id, resume from it.
180
+ Otherwise, start fresh.
181
+
182
+ Replaces the manual pattern of checking persistence, then calling
183
+ resume() or start() explicitly.
184
+ """
185
+ if self._persistence and self._run_id:
186
+ if asyncio.iscoroutinefunction(self._persistence.exists):
187
+ exists = await self._persistence.exists(self._run_id) # type: ignore[misc]
188
+ else:
189
+ exists = self._persistence.exists(self._run_id)
190
+ if exists:
191
+ if asyncio.iscoroutinefunction(self._persistence.load):
192
+ snap = await self._persistence.load(self._run_id) # type: ignore[misc]
193
+ else:
194
+ snap = self._persistence.load(self._run_id)
195
+ if snap:
196
+ await self.resume(snap)
197
+ return
198
+ await self.start()
199
+
156
200
  async def resume(self, snap: dict[str, Any]) -> None:
157
201
  """
158
202
  Boot the machine from a saved snapshot, skipping on_entry for the
@@ -167,6 +211,18 @@ class OrcaMachine:
167
211
  if self._active:
168
212
  return
169
213
 
214
+ snap_version = snap.get("definition_version", "0.1.0")
215
+ if snap_version != self.definition.version:
216
+ import warnings
217
+ warnings.warn(
218
+ f"Snapshot version '{snap_version}' does not match "
219
+ f"machine definition version '{self.definition.version}' "
220
+ f"for machine '{self.definition.name}'. "
221
+ "The snapshot may be incompatible with the current definition.",
222
+ UserWarning,
223
+ stacklevel=2,
224
+ )
225
+
170
226
  self._state = StateValue(copy.deepcopy(snap["state"]))
171
227
  self.context = copy.deepcopy(snap["context"])
172
228
  self._active = True
@@ -310,10 +366,9 @@ class OrcaMachine:
310
366
  TransitionResult indicating what happened
311
367
  """
312
368
  if not self._active:
313
- return TransitionResult(
314
- taken=False,
315
- from_state=str(self._state),
316
- error="Machine is not active"
369
+ raise MachineNotActiveError(
370
+ f"Machine '{self.definition.name}' is not active. "
371
+ "Call start() or resume() before sending events."
317
372
  )
318
373
 
319
374
  # Normalize to Event, preserving the original event name
@@ -431,12 +486,25 @@ class OrcaMachine:
431
486
  }
432
487
  ))
433
488
 
489
+ await self._auto_save()
490
+
434
491
  return TransitionResult(
435
492
  taken=True,
436
493
  from_state=str(old_state),
437
- to_state=str(self._state)
494
+ to_state=str(self._state),
495
+ to_state_leaf=self._state.leaf(),
438
496
  )
439
497
 
498
+ async def _auto_save(self) -> None:
499
+ """Save a snapshot if persistence and run_id are configured."""
500
+ if self._persistence is None or self._run_id is None:
501
+ return
502
+ snap = self.snapshot()
503
+ if asyncio.iscoroutinefunction(self._persistence.save):
504
+ await self._persistence.save(self._run_id, snap) # type: ignore[misc]
505
+ else:
506
+ self._persistence.save(self._run_id, snap)
507
+
440
508
  def _find_event_type(self, event_name: str) -> EventType:
441
509
  """Map event name to EventType enum."""
442
510
  # Build a mapping from event names to event types
@@ -830,6 +898,8 @@ class OrcaMachine:
830
898
  }
831
899
  ))
832
900
 
901
+ await self._auto_save()
902
+
833
903
  def _is_event_ignored(self, event_name: str) -> bool:
834
904
  """Check if event is explicitly ignored in current state or parent states."""
835
905
  current = self._state.leaf()
@@ -650,9 +650,43 @@ def _map_op(op: str) -> str:
650
650
  }.get(op, "eq")
651
651
 
652
652
 
653
+ def _validate_machine_def(defn: MachineDef) -> None:
654
+ """Post-parse structural validation. Raises ParseError on invalid definitions."""
655
+ if not defn.states:
656
+ raise ParseError(f"Machine '{defn.name}': no states defined.")
657
+ if not any(s.is_initial for s in defn.states):
658
+ raise ParseError(f"Machine '{defn.name}': no [initial] state.")
659
+ state_names = {s.name for s in defn.states}
660
+ # Include nested/parallel child state names
661
+ def _collect_names(states: list[StateDef]) -> None:
662
+ for s in states:
663
+ state_names.add(s.name)
664
+ if s.contains:
665
+ _collect_names(s.contains)
666
+ if s.parallel:
667
+ for region in s.parallel.regions:
668
+ _collect_names(region.states)
669
+ _collect_names(defn.states)
670
+ for t in defn.transitions:
671
+ if t.source not in state_names:
672
+ raise ParseError(
673
+ f"Machine '{defn.name}': transition source '{t.source}' is not defined."
674
+ )
675
+ if t.target not in state_names:
676
+ raise ParseError(
677
+ f"Machine '{defn.name}': transition target '{t.target}' is not defined."
678
+ )
679
+ guard_ref = t.guard.lstrip("!") if t.guard else None
680
+ if guard_ref and guard_ref not in defn.guards:
681
+ raise ParseError(
682
+ f"Machine '{defn.name}': guard '{t.guard}' is used but not defined."
683
+ )
684
+
685
+
653
686
  def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
654
687
  """Parse a single machine from already-split elements."""
655
688
  machine_name = "unknown"
689
+ machine_version = "0.1.0"
656
690
  context: dict[str, Any] = {}
657
691
  events: list[str] = []
658
692
  transitions: list[Transition] = []
@@ -661,6 +695,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
661
695
  effects: list[EffectDef] = []
662
696
  state_entries: list[_MdStateEntry] = []
663
697
  current_state_entry: _MdStateEntry | None = None
698
+ in_machine_meta: bool = False
664
699
 
665
700
  i = 0
666
701
  while i < len(elements):
@@ -671,9 +706,13 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
671
706
  if el.level == 1 and el.text.startswith("machine "):
672
707
  machine_name = el.text[8:].strip()
673
708
  current_state_entry = None
709
+ in_machine_meta = True
674
710
  i += 1
675
711
  continue
676
712
 
713
+ # Section and state headings clear machine-meta mode
714
+ in_machine_meta = False
715
+
677
716
  # Section headings
678
717
  section_name = el.text.lower()
679
718
  if section_name in ("context", "events", "transitions", "guards", "actions", "effects"):
@@ -807,6 +846,11 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
807
846
  elif isinstance(el, _MdBulletList):
808
847
  for item in el.items:
809
848
  _parse_md_state_bullet(current_state_entry, item)
849
+ elif in_machine_meta and isinstance(el, _MdBulletList):
850
+ # Machine-level metadata bullets (e.g. "- version: 1.2.0")
851
+ for item in el.items:
852
+ if item.startswith("version:"):
853
+ machine_version = item[len("version:"):].strip()
810
854
 
811
855
  i += 1
812
856
 
@@ -814,7 +858,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
814
858
  base_level = state_entries[0].level if state_entries else 2
815
859
  states, _ = _build_md_states_at_level(state_entries, 0, base_level)
816
860
 
817
- return MachineDef(
861
+ defn = MachineDef(
818
862
  name=machine_name,
819
863
  context=context,
820
864
  events=events,
@@ -823,7 +867,10 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
823
867
  guards=guards,
824
868
  actions=actions,
825
869
  effects=effects,
870
+ version=machine_version,
826
871
  )
872
+ _validate_machine_def(defn)
873
+ return defn
827
874
 
828
875
 
829
876
  def parse_orca_md(source: str) -> MachineDef:
@@ -22,7 +22,7 @@ from typing import Any, Protocol, runtime_checkable
22
22
  @runtime_checkable
23
23
  class PersistenceAdapter(Protocol):
24
24
  """
25
- Protocol for Orca machine state persistence.
25
+ Synchronous persistence adapter. Suitable for local file I/O.
26
26
 
27
27
  Implementations must support three operations keyed by run_id:
28
28
  save, load (returns None if not found), and exists.
@@ -41,6 +41,28 @@ class PersistenceAdapter(Protocol):
41
41
  ...
42
42
 
43
43
 
44
+ @runtime_checkable
45
+ class AsyncPersistenceAdapter(Protocol):
46
+ """
47
+ Async persistence adapter for database and network backends.
48
+
49
+ Drop-in replacement for PersistenceAdapter when using async drivers
50
+ (asyncpg, aioredis, aiobotocore, etc.). OrcaMachine accepts either.
51
+ """
52
+
53
+ async def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
54
+ """Persist snapshot under run_id. Overwrites any prior snapshot."""
55
+ ...
56
+
57
+ async def load(self, run_id: str) -> dict[str, Any] | None:
58
+ """Return the snapshot for run_id, or None if it doesn't exist."""
59
+ ...
60
+
61
+ async def exists(self, run_id: str) -> bool:
62
+ """Return True if a snapshot exists for run_id."""
63
+ ...
64
+
65
+
44
66
  class FilePersistence:
45
67
  """
46
68
  File-based persistence adapter.
@@ -99,6 +99,7 @@ class MachineDef:
99
99
  guards: dict[str, GuardExpression] = field(default_factory=dict)
100
100
  actions: list[ActionSignature] = field(default_factory=list)
101
101
  effects: list[EffectDef] = field(default_factory=list)
102
+ version: str = "0.1.0"
102
103
 
103
104
 
104
105
  class GuardExpression:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orca-runtime-python
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Python async runtime for Orca state machines
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/orca-lang/orca-lang
@@ -18,4 +18,5 @@ tests/test_guards.py
18
18
  tests/test_ignored_events.py
19
19
  tests/test_md_parser.py
20
20
  tests/test_parallel.py
21
+ tests/test_production_hardening.py
21
22
  tests/test_snapshot_restore.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "orca-runtime-python"
7
- version = "0.1.16"
7
+ version = "0.1.18"
8
8
  description = "Python async runtime for Orca state machines"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -0,0 +1,391 @@
1
+ """Tests for production hardening gaps (Gap 1-4, M-1, M-2, M-3)."""
2
+
3
+ import asyncio
4
+ import warnings
5
+ from typing import Any
6
+ from orca_runtime_python.parser import parse_orca_md, ParseError
7
+ from orca_runtime_python.machine import OrcaMachine, MachineNotActiveError
8
+ from orca_runtime_python.persistence import PersistenceAdapter, AsyncPersistenceAdapter
9
+ from orca_runtime_python.bus import EventBus
10
+
11
+
12
+ SIMPLE_MD = """# machine simple
13
+ ## state idle [initial]
14
+ ## state done [final]
15
+ ## transitions
16
+ | Source | Event | Guard | Target | Action |
17
+ |--------|-------|-------|--------|--------|
18
+ | idle | GO | | done | |
19
+ """
20
+
21
+ VERSIONED_MD = """# machine versioned
22
+ - version: 2.0.0
23
+ ## state idle [initial]
24
+ ## state done [final]
25
+ ## transitions
26
+ | Source | Event | Guard | Target | Action |
27
+ |--------|-------|-------|--------|--------|
28
+ | idle | GO | | done | |
29
+ """
30
+
31
+
32
+ # ── Gap 1: snapshot includes machine name and definition_version ──────────────
33
+
34
+ async def _test_snapshot_includes_machine_name():
35
+ bus = EventBus()
36
+ defn = parse_orca_md(SIMPLE_MD)
37
+ machine = OrcaMachine(defn, event_bus=bus)
38
+ await machine.start()
39
+ snap = machine.snapshot()
40
+ assert snap["machine"] == "simple", f"Expected 'simple', got '{snap['machine']}'"
41
+ assert "definition_version" in snap, "Expected definition_version in snapshot"
42
+ assert snap["definition_version"] == "0.1.0"
43
+
44
+
45
+ def test_snapshot_includes_machine_name():
46
+ asyncio.run(_test_snapshot_includes_machine_name())
47
+
48
+
49
+ # ── Gap 2: send() before start() raises MachineNotActiveError ────────────────
50
+
51
+ async def _test_send_before_start_raises():
52
+ bus = EventBus()
53
+ defn = parse_orca_md(SIMPLE_MD)
54
+ machine = OrcaMachine(defn, event_bus=bus)
55
+ raised = False
56
+ try:
57
+ await machine.send("GO")
58
+ except MachineNotActiveError:
59
+ raised = True
60
+ assert raised, "Expected MachineNotActiveError when send() called before start()"
61
+
62
+
63
+ async def _test_send_after_stop_raises():
64
+ bus = EventBus()
65
+ defn = parse_orca_md(SIMPLE_MD)
66
+ machine = OrcaMachine(defn, event_bus=bus)
67
+ await machine.start()
68
+ await machine.stop()
69
+ raised = False
70
+ try:
71
+ await machine.send("GO")
72
+ except MachineNotActiveError:
73
+ raised = True
74
+ assert raised, "Expected MachineNotActiveError when send() called after stop()"
75
+
76
+
77
+ async def _test_send_after_start_does_not_raise():
78
+ bus = EventBus()
79
+ defn = parse_orca_md(SIMPLE_MD)
80
+ machine = OrcaMachine(defn, event_bus=bus)
81
+ await machine.start()
82
+ result = await machine.send("GO")
83
+ assert result.taken is True
84
+
85
+
86
+ def test_send_before_start_raises():
87
+ asyncio.run(_test_send_before_start_raises())
88
+
89
+ def test_send_after_stop_raises():
90
+ asyncio.run(_test_send_after_stop_raises())
91
+
92
+ def test_send_after_start_does_not_raise():
93
+ asyncio.run(_test_send_after_start_does_not_raise())
94
+
95
+
96
+ # ── Gap 3: AsyncPersistenceAdapter + auto-save + load_or_start ───────────────
97
+
98
+ class _InMemorySync:
99
+ """Synchronous in-memory persistence for testing."""
100
+ def __init__(self):
101
+ self._store: dict[str, Any] = {}
102
+ self.save_calls: int = 0
103
+
104
+ def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
105
+ self._store[run_id] = snapshot
106
+ self.save_calls += 1
107
+
108
+ def load(self, run_id: str) -> dict[str, Any] | None:
109
+ return self._store.get(run_id)
110
+
111
+ def exists(self, run_id: str) -> bool:
112
+ return run_id in self._store
113
+
114
+
115
+ class _InMemoryAsync:
116
+ """Async in-memory persistence for testing."""
117
+ def __init__(self):
118
+ self._store: dict[str, Any] = {}
119
+ self.save_calls: int = 0
120
+
121
+ async def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
122
+ self._store[run_id] = snapshot
123
+ self.save_calls += 1
124
+
125
+ async def load(self, run_id: str) -> dict[str, Any] | None:
126
+ return self._store.get(run_id)
127
+
128
+ async def exists(self, run_id: str) -> bool:
129
+ return run_id in self._store
130
+
131
+
132
+ async def _test_auto_save_sync_persistence():
133
+ bus = EventBus()
134
+ defn = parse_orca_md(SIMPLE_MD)
135
+ p = _InMemorySync()
136
+ machine = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-1")
137
+ await machine.start()
138
+ assert p.save_calls == 0, "No save before first transition"
139
+ await machine.send("GO")
140
+ assert p.save_calls == 1, "Expected one save after transition"
141
+ snap = p.load("run-1")
142
+ assert snap is not None
143
+ assert snap["machine"] == "simple"
144
+ assert snap["state"] == "done"
145
+
146
+
147
+ async def _test_auto_save_async_persistence():
148
+ bus = EventBus()
149
+ defn = parse_orca_md(SIMPLE_MD)
150
+ p = _InMemoryAsync()
151
+ machine = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-1")
152
+ await machine.start()
153
+ await machine.send("GO")
154
+ assert p.save_calls == 1, "Expected one save after transition"
155
+ snap = await p.load("run-1")
156
+ assert snap is not None
157
+ assert snap["state"] == "done"
158
+
159
+
160
+ async def _test_load_or_start_fresh():
161
+ bus = EventBus()
162
+ defn = parse_orca_md(SIMPLE_MD)
163
+ p = _InMemorySync()
164
+ machine = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-fresh")
165
+ await machine.load_or_start()
166
+ assert machine.is_active
167
+ assert machine.state.leaf() == "idle"
168
+
169
+
170
+ async def _test_load_or_start_resumes():
171
+ bus = EventBus()
172
+ defn = parse_orca_md(SIMPLE_MD)
173
+ p = _InMemorySync()
174
+
175
+ # First run: advance to done and save
176
+ m1 = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-resume")
177
+ await m1.start()
178
+ await m1.send("GO")
179
+ assert p.exists("run-resume")
180
+
181
+ # Second run: load_or_start should resume
182
+ bus2 = EventBus()
183
+ m2 = OrcaMachine(defn, event_bus=bus2, persistence=p, run_id="run-resume")
184
+ await m2.load_or_start()
185
+ assert m2.is_active
186
+ assert m2.state.leaf() == "done"
187
+
188
+
189
+ async def _test_load_or_start_no_persistence():
190
+ bus = EventBus()
191
+ defn = parse_orca_md(SIMPLE_MD)
192
+ machine = OrcaMachine(defn, event_bus=bus)
193
+ await machine.load_or_start()
194
+ assert machine.is_active
195
+ assert machine.state.leaf() == "idle"
196
+
197
+
198
+ def test_auto_save_sync_persistence():
199
+ asyncio.run(_test_auto_save_sync_persistence())
200
+
201
+ def test_auto_save_async_persistence():
202
+ asyncio.run(_test_auto_save_async_persistence())
203
+
204
+ def test_load_or_start_fresh():
205
+ asyncio.run(_test_load_or_start_fresh())
206
+
207
+ def test_load_or_start_resumes():
208
+ asyncio.run(_test_load_or_start_resumes())
209
+
210
+ def test_load_or_start_no_persistence():
211
+ asyncio.run(_test_load_or_start_no_persistence())
212
+
213
+
214
+ # ── Gap 4: version field on MachineDef; snapshot includes it; resume warns ───
215
+
216
+ def test_machine_def_default_version():
217
+ defn = parse_orca_md(SIMPLE_MD)
218
+ assert defn.version == "0.1.0"
219
+
220
+
221
+ def test_machine_def_parsed_version():
222
+ defn = parse_orca_md(VERSIONED_MD)
223
+ assert defn.version == "2.0.0"
224
+
225
+
226
+ async def _test_resume_version_mismatch_warns():
227
+ bus = EventBus()
228
+ defn = parse_orca_md(VERSIONED_MD)
229
+ machine = OrcaMachine(defn, event_bus=bus)
230
+
231
+ stale_snap = {
232
+ "machine": "versioned",
233
+ "definition_version": "1.0.0",
234
+ "state": "idle",
235
+ "context": {},
236
+ "children": {},
237
+ "active_invoke": None,
238
+ "timestamp": 0.0,
239
+ }
240
+
241
+ with warnings.catch_warnings(record=True) as caught:
242
+ warnings.simplefilter("always")
243
+ await machine.resume(stale_snap)
244
+ version_warnings = [w for w in caught if issubclass(w.category, UserWarning)]
245
+ assert len(version_warnings) == 1
246
+ assert "1.0.0" in str(version_warnings[0].message)
247
+ assert "2.0.0" in str(version_warnings[0].message)
248
+
249
+
250
+ async def _test_resume_matching_version_no_warn():
251
+ bus = EventBus()
252
+ defn = parse_orca_md(VERSIONED_MD)
253
+ machine = OrcaMachine(defn, event_bus=bus)
254
+
255
+ snap = {
256
+ "machine": "versioned",
257
+ "definition_version": "2.0.0",
258
+ "state": "idle",
259
+ "context": {},
260
+ "children": {},
261
+ "active_invoke": None,
262
+ "timestamp": 0.0,
263
+ }
264
+
265
+ with warnings.catch_warnings(record=True) as caught:
266
+ warnings.simplefilter("always")
267
+ await machine.resume(snap)
268
+ version_warnings = [w for w in caught if issubclass(w.category, UserWarning)]
269
+ assert len(version_warnings) == 0, "No warning expected for matching versions"
270
+
271
+
272
+ def test_resume_version_mismatch_warns():
273
+ asyncio.run(_test_resume_version_mismatch_warns())
274
+
275
+ def test_resume_matching_version_no_warn():
276
+ asyncio.run(_test_resume_matching_version_no_warn())
277
+
278
+
279
+ # ── M-1: parser raises ParseError on structurally invalid input ──────────────
280
+
281
+ def test_parse_error_no_initial_state():
282
+ md = """# machine bad
283
+ ## state a
284
+ ## state b
285
+ ## transitions
286
+ | Source | Event | Guard | Target | Action |
287
+ |--------|-------|-------|--------|--------|
288
+ | a | GO | | b | |
289
+ """
290
+ raised = False
291
+ try:
292
+ parse_orca_md(md)
293
+ except ParseError as e:
294
+ raised = True
295
+ assert "no [initial] state" in str(e)
296
+ assert raised, "Expected ParseError for missing [initial] state"
297
+
298
+
299
+ def test_parse_error_undefined_transition_target():
300
+ md = """# machine bad
301
+ ## state a [initial]
302
+ ## transitions
303
+ | Source | Event | Guard | Target | Action |
304
+ |--------|-------|-------|--------|--------|
305
+ | a | GO | | nonexistent | |
306
+ """
307
+ raised = False
308
+ try:
309
+ parse_orca_md(md)
310
+ except ParseError as e:
311
+ raised = True
312
+ assert "nonexistent" in str(e)
313
+ assert raised, "Expected ParseError for undefined transition target"
314
+
315
+
316
+ def test_parse_error_undefined_guard():
317
+ md = """# machine bad
318
+ ## state a [initial]
319
+ ## state b [final]
320
+ ## transitions
321
+ | Source | Event | Guard | Target | Action |
322
+ |--------|-------|-------|--------|--------|
323
+ | a | GO | noSuchGuard | b | |
324
+ """
325
+ raised = False
326
+ try:
327
+ parse_orca_md(md)
328
+ except ParseError as e:
329
+ raised = True
330
+ assert "noSuchGuard" in str(e)
331
+ assert raised, "Expected ParseError for undefined guard"
332
+
333
+
334
+ def test_parse_valid_machine_no_error():
335
+ defn = parse_orca_md(SIMPLE_MD)
336
+ assert defn.name == "simple"
337
+
338
+
339
+ # ── M-2: TransitionResult.to_state_leaf ──────────────────────────────────────
340
+
341
+ async def _test_transition_result_leaf():
342
+ bus = EventBus()
343
+ defn = parse_orca_md(SIMPLE_MD)
344
+ machine = OrcaMachine(defn, event_bus=bus)
345
+ await machine.start()
346
+ result = await machine.send("GO")
347
+ assert result.taken is True
348
+ assert result.to_state_leaf == "done", f"Expected 'done', got '{result.to_state_leaf}'"
349
+ assert result.to_state == "done"
350
+
351
+
352
+ def test_transition_result_leaf():
353
+ asyncio.run(_test_transition_result_leaf())
354
+
355
+
356
+ if __name__ == "__main__":
357
+ tests = [
358
+ ("Gap1: snapshot includes machine name", test_snapshot_includes_machine_name),
359
+ ("Gap2: send before start raises", test_send_before_start_raises),
360
+ ("Gap2: send after stop raises", test_send_after_stop_raises),
361
+ ("Gap2: send after start ok", test_send_after_start_does_not_raise),
362
+ ("Gap3: auto-save sync", test_auto_save_sync_persistence),
363
+ ("Gap3: auto-save async", test_auto_save_async_persistence),
364
+ ("Gap3: load_or_start fresh", test_load_or_start_fresh),
365
+ ("Gap3: load_or_start resumes", test_load_or_start_resumes),
366
+ ("Gap3: load_or_start no persistence", test_load_or_start_no_persistence),
367
+ ("Gap4: default version", test_machine_def_default_version),
368
+ ("Gap4: parsed version", test_machine_def_parsed_version),
369
+ ("Gap4: resume version mismatch warns", test_resume_version_mismatch_warns),
370
+ ("Gap4: resume matching version no warn", test_resume_matching_version_no_warn),
371
+ ("M-1: no initial state error", test_parse_error_no_initial_state),
372
+ ("M-1: undefined target error", test_parse_error_undefined_transition_target),
373
+ ("M-1: undefined guard error", test_parse_error_undefined_guard),
374
+ ("M-1: valid machine no error", test_parse_valid_machine_no_error),
375
+ ("M-2: to_state_leaf", test_transition_result_leaf),
376
+ ]
377
+
378
+ passed = 0
379
+ failed = 0
380
+ for name, fn in tests:
381
+ try:
382
+ fn()
383
+ print(f" PASS {name}")
384
+ passed += 1
385
+ except Exception as e:
386
+ print(f" FAIL {name}: {e}")
387
+ failed += 1
388
+
389
+ print(f"\n{passed} passed, {failed} failed, {passed + failed} total")
390
+ if failed > 0:
391
+ exit(1)