durable-workflow 0.4.63__tar.gz → 0.4.65__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 (62) hide show
  1. {durable_workflow-0.4.63/src/durable_workflow.egg-info → durable_workflow-0.4.65}/PKG-INFO +1 -1
  2. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/pyproject.toml +1 -1
  3. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/client.py +22 -0
  4. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/workflow.py +47 -8
  5. {durable_workflow-0.4.63 → durable_workflow-0.4.65/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_client.py +15 -0
  7. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_wait_condition.py +372 -0
  8. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/LICENSE +0 -0
  9. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/README.md +0 -0
  10. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/setup.cfg +0 -0
  11. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/__init__.py +0 -0
  12. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/_avro.py +0 -0
  13. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/activity.py +0 -0
  14. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/auth_composition.py +0 -0
  15. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/errors.py +0 -0
  16. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_storage.py +0 -0
  17. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_task_input.py +0 -0
  18. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/external_task_result.py +0 -0
  19. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/history_bundle_verify.py +0 -0
  20. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/interceptors.py +0 -0
  21. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/invocable.py +0 -0
  22. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/metrics.py +0 -0
  23. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/py.typed +0 -0
  24. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/replay_verify.py +0 -0
  25. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/retry_policy.py +0 -0
  26. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/serializer.py +0 -0
  27. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/sync.py +0 -0
  28. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/testing.py +0 -0
  29. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow/worker.py +0 -0
  30. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  31. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  32. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  33. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/requires.txt +0 -0
  34. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/src/durable_workflow.egg-info/top_level.txt +0 -0
  35. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_activity_context.py +0 -0
  36. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_auth_composition.py +0 -0
  37. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_control_plane_parity_fixtures.py +0 -0
  38. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_errors.py +0 -0
  39. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_external_storage.py +0 -0
  40. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_external_task_input.py +0 -0
  41. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_external_task_result.py +0 -0
  42. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_golden_history_replay.py +0 -0
  43. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_history_bundle_verify.py +0 -0
  44. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_history_event_contract.py +0 -0
  45. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_invocable.py +0 -0
  46. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_metrics.py +0 -0
  47. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_order_processing_example.py +0 -0
  48. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_public_boundary_scanner.py +0 -0
  49. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_queries.py +0 -0
  50. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_readme_quickstart.py +0 -0
  51. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_replay.py +0 -0
  52. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_replay_verify.py +0 -0
  53. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_retry_policy.py +0 -0
  54. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_schedules.py +0 -0
  55. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_serializer.py +0 -0
  56. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_signals.py +0 -0
  57. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_sleep.py +0 -0
  58. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_standalone_activity_client.py +0 -0
  59. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_sync.py +0 -0
  60. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_testing_harness.py +0 -0
  61. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_updates.py +0 -0
  62. {durable_workflow-0.4.63 → durable_workflow-0.4.65}/tests/test_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.63
3
+ Version: 0.4.65
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "0.4.63"
7
+ version = "0.4.65"
8
8
  description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -148,6 +148,7 @@ class NamespaceDescription:
148
148
  status: str | None = None
149
149
  created_at: str | None = None
150
150
  updated_at: str | None = None
151
+ deleted: dict[str, int] | None = None
151
152
  external_payload_storage: ExternalPayloadStoragePolicy | None = None
152
153
 
153
154
  @classmethod
@@ -155,6 +156,13 @@ class NamespaceDescription:
155
156
  external_payload_storage = None
156
157
  if isinstance(data.get("external_payload_storage"), dict):
157
158
  external_payload_storage = ExternalPayloadStoragePolicy.from_dict(data)
159
+ deleted = None
160
+ if isinstance(data.get("deleted"), dict):
161
+ deleted = {
162
+ str(key): value
163
+ for key, value in data["deleted"].items()
164
+ if isinstance(value, int)
165
+ }
158
166
 
159
167
  return cls(
160
168
  name=str(data.get("name", "")),
@@ -163,6 +171,7 @@ class NamespaceDescription:
163
171
  status=data.get("status"),
164
172
  created_at=data.get("created_at"),
165
173
  updated_at=data.get("updated_at"),
174
+ deleted=deleted,
166
175
  external_payload_storage=external_payload_storage,
167
176
  )
168
177
 
@@ -1572,6 +1581,19 @@ class Client:
1572
1581
  )
1573
1582
  return NamespaceDescription.from_dict(data)
1574
1583
 
1584
+ async def delete_namespace(self, name: str) -> NamespaceDescription:
1585
+ """Delete a namespace through the server lifecycle surface."""
1586
+ data = await self._request("DELETE", f"/namespaces/{quote(name, safe='')}", context=name)
1587
+ if not isinstance(data, dict):
1588
+ raise ServerError(
1589
+ 200,
1590
+ {
1591
+ "reason": "invalid_namespace_response",
1592
+ "message": f"expected JSON object, got {type(data).__name__}",
1593
+ },
1594
+ )
1595
+ return NamespaceDescription.from_dict(data)
1596
+
1575
1597
  async def set_namespace_external_storage(
1576
1598
  self,
1577
1599
  name: str | None = None,
@@ -2296,7 +2296,7 @@ def _replay_state(
2296
2296
  if _is_external_receiver_event(event_type):
2297
2297
  explicit_sequence = _workflow_sequence(payload) is not None
2298
2298
  if current_wait_id is None:
2299
- if not explicit_sequence:
2299
+ if prefix_can_bind_to_first_wait or not explicit_sequence:
2300
2300
  prefix_receivers.append(index)
2301
2301
  continue
2302
2302
 
@@ -2562,9 +2562,31 @@ def _replay_state(
2562
2562
  first = True
2563
2563
  pending: list[Command] = []
2564
2564
  advanced_cmd: Any = None
2565
+ terminal_condition_reopen_cmd: WaitCondition | None = None
2566
+
2567
+ def _condition_wait_has_pending_receivers(condition_wait_id: str | None) -> bool:
2568
+ if condition_wait_id is None:
2569
+ return False
2570
+ return any(receiver.condition_wait_id == condition_wait_id for receiver in pending_receivers)
2571
+
2572
+ def _consume_terminal_condition_reopens() -> None:
2573
+ nonlocal wait_yield_count
2574
+ if terminal_condition_reopen_cmd is None:
2575
+ return
2576
+ while wait_yield_count < len(wait_opened):
2577
+ opened = wait_opened[wait_yield_count]
2578
+ if not _same_logical_condition_wait(opened, terminal_condition_reopen_cmd):
2579
+ break
2580
+ opened_id = opened.get("condition_wait_id")
2581
+ if not isinstance(opened_id, str):
2582
+ break
2583
+ if opened_id in wait_resolutions or _condition_wait_has_pending_receivers(opened_id):
2584
+ break
2585
+ wait_yield_count += 1
2565
2586
 
2566
2587
  def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
2567
2588
  _apply_due_receivers()
2589
+ _consume_terminal_condition_reopens()
2568
2590
  commands = list(pending) if include_pending else []
2569
2591
  if isinstance(value, ContinueAsNew):
2570
2592
  _assert_no_unconsumed_history("continue as new")
@@ -2652,6 +2674,9 @@ def _replay_state(
2652
2674
  step = _next_unconsumed_recorded_step()
2653
2675
  if step is not None:
2654
2676
  _assert_step_matches(cmd, step)
2677
+ # Terminal cleanup may only skip a later open after this
2678
+ # yielded wait has already replayed at least one false reopen.
2679
+ consumed_reopen_for_current_wait = False
2655
2680
  while True:
2656
2681
  resolution: str | None = None
2657
2682
  opened: dict[str, Any] | None = None
@@ -2666,7 +2691,15 @@ def _replay_state(
2666
2691
  if isinstance(opened_id, str):
2667
2692
  resolution = wait_resolutions.get(opened_id)
2668
2693
  _apply_condition_wait_receivers(opened_id)
2694
+ next_wait_index = wait_yield_count + 1
2695
+ has_reopened_same_wait = (
2696
+ opened is not None
2697
+ and next_wait_index < len(wait_opened)
2698
+ and _same_logical_condition_wait(wait_opened[next_wait_index], cmd)
2699
+ )
2700
+
2669
2701
  if resolution == "timed_out":
2702
+ terminal_condition_reopen_cmd = None
2670
2703
  next_value = False
2671
2704
  wait_yield_count += 1
2672
2705
  break
@@ -2677,21 +2710,27 @@ def _replay_state(
2677
2710
  message=f"wait_condition predicate raised: {exc}",
2678
2711
  exception_type=type(exc).__name__,
2679
2712
  )])
2680
- if satisfied or resolution == "satisfied":
2713
+ if resolution == "satisfied":
2714
+ terminal_condition_reopen_cmd = None
2715
+ next_value = True
2716
+ wait_yield_count += 1
2717
+ break
2718
+ if satisfied:
2719
+ terminal_condition_reopen_cmd = (
2720
+ cmd
2721
+ if has_reopened_same_wait and consumed_reopen_for_current_wait
2722
+ else None
2723
+ )
2681
2724
  next_value = True
2682
2725
  wait_yield_count += 1
2683
2726
  break
2684
2727
 
2685
- next_wait_index = wait_yield_count + 1
2686
2728
  # A single logical wait can be re-opened in history after
2687
2729
  # non-satisfying signals. Consume repeated physical opens
2688
2730
  # with the same key/fingerprint before declaring the
2689
2731
  # logical wait still pending.
2690
- if (
2691
- opened is not None
2692
- and next_wait_index < len(wait_opened)
2693
- and _same_logical_condition_wait(wait_opened[next_wait_index], cmd)
2694
- ):
2732
+ if has_reopened_same_wait:
2733
+ consumed_reopen_for_current_wait = True
2695
2734
  wait_yield_count = next_wait_index
2696
2735
  continue
2697
2736
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.63
3
+ Version: 0.4.65
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -1080,6 +1080,21 @@ class TestNamespaces:
1080
1080
  assert result.description == fixture["semantic_body"]["description"]
1081
1081
  assert result.retention_days == fixture["semantic_body"]["retention_days"]
1082
1082
 
1083
+ @pytest.mark.asyncio
1084
+ async def test_delete_namespace_matches_polyglot_fixture(self, client: Client) -> None:
1085
+ fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "namespace-delete-parity.json"
1086
+ fixture = json.loads(fixture_path.read_text())
1087
+ resp = _mock_response(200, fixture["response_body"])
1088
+
1089
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1090
+ result = await client.delete_namespace(**fixture["sdk_python"]["args"])
1091
+
1092
+ assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
1093
+ assert result.name == fixture["semantic_body"]["name"]
1094
+ assert result.status == fixture["semantic_body"]["status"]
1095
+ assert result.deleted is not None
1096
+ assert sorted(result.deleted) == sorted(fixture["semantic_body"]["deleted_keys"])
1097
+
1083
1098
  @pytest.mark.asyncio
1084
1099
  async def test_set_namespace_external_storage_matches_polyglot_fixture(self, client: Client) -> None:
1085
1100
  fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "namespace-set-storage-driver-parity.json"
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import pytest
4
+
3
5
  from durable_workflow import serializer, workflow
6
+ from durable_workflow.errors import NonDeterministicReplayError
4
7
  from durable_workflow.workflow import (
5
8
  CompleteWorkflow,
6
9
  FailWorkflow,
@@ -169,6 +172,40 @@ class SignalCounterUntilFinished:
169
172
  return self.current()
170
173
 
171
174
 
175
+ @workflow.defn(name="two-same-key-signal-waits")
176
+ class TwoSameKeySignalWaits:
177
+ def __init__(self) -> None:
178
+ self.ready = False
179
+ self.stage = "booting"
180
+ self.events: list[str] = []
181
+
182
+ @workflow.signal("release")
183
+ def release(self, label: str) -> None:
184
+ self.ready = True
185
+ self.events.append(f"signal:{label}")
186
+
187
+ @workflow.query("state")
188
+ def state(self) -> dict[str, object]:
189
+ return {
190
+ "ready": self.ready,
191
+ "stage": self.stage,
192
+ "events": list(self.events),
193
+ }
194
+
195
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
196
+ self.stage = "first"
197
+ yield ctx.wait_condition(lambda: self.ready, key="gate")
198
+ self.events.append("passed:first")
199
+
200
+ self.ready = False
201
+ self.stage = "second"
202
+ yield ctx.wait_condition(lambda: self.ready, key="gate")
203
+ self.events.append("passed:second")
204
+
205
+ self.stage = "completed"
206
+ return self.state()
207
+
208
+
172
209
  class TestCtxWaitCondition:
173
210
  def test_wait_condition_returns_dataclass_with_predicate_and_key(self) -> None:
174
211
  ctx = WorkflowContext(run_id="x")
@@ -562,6 +599,341 @@ class TestReplayWaitCondition:
562
599
  assert outcome.commands[0].result == expected
563
600
  assert query_state(SignalCounterUntilFinished, history, [], "current") == expected
564
601
 
602
+ def test_stale_sequence_after_condition_resolution_binds_to_next_wait(self) -> None:
603
+ history = [
604
+ {
605
+ "event_type": "ConditionWaitOpened",
606
+ "payload": {
607
+ "condition_wait_id": "wait-advance",
608
+ "condition_key": "advance",
609
+ "sequence": 21,
610
+ },
611
+ },
612
+ _signal_received_event("advance", ["Ada"], workflow_sequence=21),
613
+ {
614
+ "event_type": "ConditionWaitSatisfied",
615
+ "payload": {
616
+ "condition_wait_id": "wait-advance",
617
+ "condition_key": "advance",
618
+ "sequence": 21,
619
+ },
620
+ },
621
+ _signal_received_event("finish", [], workflow_sequence=21),
622
+ {
623
+ "event_type": "ConditionWaitOpened",
624
+ "payload": {
625
+ "condition_wait_id": "wait-finish",
626
+ "condition_key": "finish",
627
+ "sequence": 22,
628
+ },
629
+ },
630
+ ]
631
+
632
+ expected = {
633
+ "stage": "completed",
634
+ "name": "Ada",
635
+ "finished": True,
636
+ "events": ["started", "signal:Ada", "advanced:Ada", "signal:finish", "finish"],
637
+ }
638
+
639
+ outcome = replay(TwoStageSignalWait, history, [])
640
+
641
+ assert len(outcome.commands) == 1
642
+ assert isinstance(outcome.commands[0], CompleteWorkflow)
643
+ assert outcome.commands[0].result == expected
644
+ assert query_state(TwoStageSignalWait, history, [], "state") == expected
645
+
646
+ def test_terminal_pending_same_key_wait_without_prior_reopen_remains_history(self) -> None:
647
+ history = [
648
+ {
649
+ "event_type": "ConditionWaitOpened",
650
+ "payload": {
651
+ "condition_wait_id": "wait-before-finish",
652
+ "condition_key": "done",
653
+ "sequence": 21,
654
+ "timeout_seconds": 30,
655
+ },
656
+ },
657
+ {
658
+ "event_type": "TimerScheduled",
659
+ "payload": {
660
+ "timer_kind": "condition_timeout",
661
+ "condition_wait_id": "wait-before-finish",
662
+ "condition_key": "done",
663
+ "sequence": 21,
664
+ "delay_seconds": 30,
665
+ },
666
+ },
667
+ _signal_received_event("finish", [], workflow_sequence=21),
668
+ {
669
+ "event_type": "ConditionWaitOpened",
670
+ "payload": {
671
+ "condition_wait_id": "wait-after-finish",
672
+ "condition_key": "done",
673
+ "sequence": 22,
674
+ "timeout_seconds": 30,
675
+ },
676
+ },
677
+ {
678
+ "event_type": "TimerScheduled",
679
+ "payload": {
680
+ "timer_kind": "condition_timeout",
681
+ "condition_wait_id": "wait-after-finish",
682
+ "condition_key": "done",
683
+ "sequence": 22,
684
+ "delay_seconds": 30,
685
+ },
686
+ },
687
+ ]
688
+
689
+ with pytest.raises(NonDeterministicReplayError) as exc_info:
690
+ replay(SignalCounterUntilFinished, history, [])
691
+
692
+ assert exc_info.value.workflow_sequence == 22
693
+ assert exc_info.value.expected_shape == "complete workflow"
694
+ assert "ConditionWaitOpened" in exc_info.value.recorded_event_types
695
+ assert "TimerScheduled" in exc_info.value.recorded_event_types
696
+
697
+ def test_terminal_stale_reopened_wait_after_false_reopen_is_consumed(self) -> None:
698
+ history = [
699
+ {
700
+ "event_type": "ConditionWaitOpened",
701
+ "payload": {
702
+ "condition_wait_id": "wait-count-3",
703
+ "condition_key": "done",
704
+ "sequence": 20,
705
+ "timeout_seconds": 30,
706
+ },
707
+ },
708
+ {
709
+ "event_type": "TimerScheduled",
710
+ "payload": {
711
+ "timer_kind": "condition_timeout",
712
+ "condition_wait_id": "wait-count-3",
713
+ "condition_key": "done",
714
+ "sequence": 20,
715
+ "delay_seconds": 30,
716
+ },
717
+ },
718
+ _signal_received_event("increment", [3], workflow_sequence=20),
719
+ {
720
+ "event_type": "ConditionWaitOpened",
721
+ "payload": {
722
+ "condition_wait_id": "wait-before-finish",
723
+ "condition_key": "done",
724
+ "sequence": 21,
725
+ "timeout_seconds": 30,
726
+ },
727
+ },
728
+ {
729
+ "event_type": "TimerScheduled",
730
+ "payload": {
731
+ "timer_kind": "condition_timeout",
732
+ "condition_wait_id": "wait-before-finish",
733
+ "condition_key": "done",
734
+ "sequence": 21,
735
+ "delay_seconds": 30,
736
+ },
737
+ },
738
+ _signal_received_event("finish", [], workflow_sequence=21),
739
+ {
740
+ "event_type": "ConditionWaitOpened",
741
+ "payload": {
742
+ "condition_wait_id": "wait-after-finish",
743
+ "condition_key": "done",
744
+ "sequence": 22,
745
+ "timeout_seconds": 30,
746
+ },
747
+ },
748
+ {
749
+ "event_type": "TimerScheduled",
750
+ "payload": {
751
+ "timer_kind": "condition_timeout",
752
+ "condition_wait_id": "wait-after-finish",
753
+ "condition_key": "done",
754
+ "sequence": 22,
755
+ "delay_seconds": 30,
756
+ },
757
+ },
758
+ ]
759
+
760
+ expected = {
761
+ "count": 3,
762
+ "done": True,
763
+ "events": [
764
+ {"signal": "increment", "amount": 3, "count": 3},
765
+ {"signal": "finish", "count": 3},
766
+ ],
767
+ }
768
+
769
+ outcome = replay(SignalCounterUntilFinished, history, [])
770
+
771
+ assert len(outcome.commands) == 1
772
+ assert isinstance(outcome.commands[0], CompleteWorkflow)
773
+ assert outcome.commands[0].result == expected
774
+ assert query_state(SignalCounterUntilFinished, history, [], "current") == expected
775
+
776
+ @pytest.mark.parametrize(
777
+ ("resolution_event_type", "resolution_payload"),
778
+ [
779
+ (
780
+ "ConditionWaitTimedOut",
781
+ {
782
+ "condition_wait_id": "wait-after-finish",
783
+ "condition_key": "done",
784
+ "sequence": 22,
785
+ },
786
+ ),
787
+ (
788
+ "ConditionWaitSatisfied",
789
+ {
790
+ "condition_wait_id": "wait-after-finish",
791
+ "condition_key": "done",
792
+ "sequence": 22,
793
+ },
794
+ ),
795
+ (
796
+ "TimerFired",
797
+ {
798
+ "timer_kind": "condition_timeout",
799
+ "condition_wait_id": "wait-after-finish",
800
+ "condition_key": "done",
801
+ "sequence": 22,
802
+ },
803
+ ),
804
+ ],
805
+ )
806
+ def test_terminal_reopened_wait_with_resolution_remains_replay_history(
807
+ self,
808
+ resolution_event_type: str,
809
+ resolution_payload: dict[str, object],
810
+ ) -> None:
811
+ history = [
812
+ {
813
+ "event_type": "ConditionWaitOpened",
814
+ "payload": {
815
+ "condition_wait_id": "wait-before-finish",
816
+ "condition_key": "done",
817
+ "sequence": 21,
818
+ "timeout_seconds": 30,
819
+ },
820
+ },
821
+ {
822
+ "event_type": "TimerScheduled",
823
+ "payload": {
824
+ "timer_kind": "condition_timeout",
825
+ "condition_wait_id": "wait-before-finish",
826
+ "condition_key": "done",
827
+ "sequence": 21,
828
+ "delay_seconds": 30,
829
+ },
830
+ },
831
+ _signal_received_event("finish", [], workflow_sequence=21),
832
+ {
833
+ "event_type": "ConditionWaitOpened",
834
+ "payload": {
835
+ "condition_wait_id": "wait-after-finish",
836
+ "condition_key": "done",
837
+ "sequence": 22,
838
+ "timeout_seconds": 30,
839
+ },
840
+ },
841
+ {
842
+ "event_type": "TimerScheduled",
843
+ "payload": {
844
+ "timer_kind": "condition_timeout",
845
+ "condition_wait_id": "wait-after-finish",
846
+ "condition_key": "done",
847
+ "sequence": 22,
848
+ "delay_seconds": 30,
849
+ },
850
+ },
851
+ {
852
+ "event_type": resolution_event_type,
853
+ "payload": resolution_payload,
854
+ },
855
+ ]
856
+
857
+ with pytest.raises(NonDeterministicReplayError) as exc_info:
858
+ replay(SignalCounterUntilFinished, history, [])
859
+
860
+ assert exc_info.value.workflow_sequence == 22
861
+ assert exc_info.value.expected_shape == "complete workflow"
862
+ assert "ConditionWaitOpened" in exc_info.value.recorded_event_types
863
+ assert resolution_event_type in exc_info.value.recorded_event_types
864
+
865
+ def test_sequential_same_key_waits_do_not_consume_second_wait_as_reopen(self) -> None:
866
+ history = [
867
+ {
868
+ "event_type": "ConditionWaitOpened",
869
+ "payload": {
870
+ "condition_wait_id": "wait-first",
871
+ "condition_key": "gate",
872
+ "sequence": 21,
873
+ },
874
+ },
875
+ _signal_received_event("release", ["first"], workflow_sequence=21),
876
+ {
877
+ "event_type": "ConditionWaitOpened",
878
+ "payload": {
879
+ "condition_wait_id": "wait-second",
880
+ "condition_key": "gate",
881
+ "sequence": 22,
882
+ },
883
+ },
884
+ _signal_received_event("release", ["second"], workflow_sequence=22),
885
+ ]
886
+
887
+ expected = {
888
+ "ready": True,
889
+ "stage": "completed",
890
+ "events": [
891
+ "signal:first",
892
+ "passed:first",
893
+ "signal:second",
894
+ "passed:second",
895
+ ],
896
+ }
897
+
898
+ outcome = replay(TwoSameKeySignalWaits, history, [])
899
+
900
+ assert len(outcome.commands) == 1
901
+ assert isinstance(outcome.commands[0], CompleteWorkflow)
902
+ assert outcome.commands[0].result == expected
903
+ assert query_state(TwoSameKeySignalWaits, history, [], "state") == expected
904
+
905
+ def test_pending_sequential_same_key_wait_replays_as_pending_wait(self) -> None:
906
+ history = [
907
+ {
908
+ "event_type": "ConditionWaitOpened",
909
+ "payload": {
910
+ "condition_wait_id": "wait-first",
911
+ "condition_key": "gate",
912
+ "sequence": 21,
913
+ },
914
+ },
915
+ _signal_received_event("release", ["first"], workflow_sequence=21),
916
+ {
917
+ "event_type": "ConditionWaitOpened",
918
+ "payload": {
919
+ "condition_wait_id": "wait-second",
920
+ "condition_key": "gate",
921
+ "sequence": 22,
922
+ },
923
+ },
924
+ ]
925
+
926
+ outcome = replay(TwoSameKeySignalWaits, history, [])
927
+
928
+ assert len(outcome.commands) == 1
929
+ assert isinstance(outcome.commands[0], WaitCondition)
930
+ assert outcome.commands[0].condition_key == "gate"
931
+ assert query_state(TwoSameKeySignalWaits, history, [], "state") == {
932
+ "ready": False,
933
+ "stage": "second",
934
+ "events": ["signal:first", "passed:first"],
935
+ }
936
+
565
937
  def test_repeated_wait_after_activity_can_be_satisfied_by_later_signal(self) -> None:
566
938
  history = [
567
939
  {