durable-workflow 2.5.4__tar.gz → 2.5.6__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 (129) hide show
  1. {durable_workflow-2.5.4/src/durable_workflow.egg-info → durable_workflow-2.5.6}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/client.py +42 -1
  4. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/nexus.py +15 -2
  5. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/worker.py +12 -7
  6. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/workflow.py +18 -0
  7. {durable_workflow-2.5.4 → durable_workflow-2.5.6/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  8. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  9. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_client.py +3 -2
  10. durable_workflow-2.5.6/tests/test_nexus_namespace.py +160 -0
  11. durable_workflow-2.5.6/tests/test_nexus_wire.py +38 -0
  12. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay_regression_corpus.py +53 -1
  13. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_runtime_external_payload_transport.py +46 -0
  14. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_worker.py +62 -4
  15. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/LICENSE +0 -0
  16. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/README.md +0 -0
  17. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/setup.cfg +0 -0
  18. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/__init__.py +0 -0
  19. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_activity_process.py +0 -0
  20. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_avro.py +0 -0
  21. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_cancellation_scope.py +0 -0
  22. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  23. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  24. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_prepared_local_activity.py +0 -0
  25. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  26. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/activity.py +0 -0
  27. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/auth_composition.py +0 -0
  28. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/cancellation.py +0 -0
  29. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/errors.py +0 -0
  30. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/external_storage.py +0 -0
  31. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/external_task_input.py +0 -0
  32. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/external_task_result.py +0 -0
  33. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/history_bundle_verify.py +0 -0
  34. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/interceptors.py +0 -0
  35. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/invocable.py +0 -0
  36. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/metrics.py +0 -0
  37. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/py.typed +0 -0
  38. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/python_conformance.py +0 -0
  39. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/replay_conformance.py +0 -0
  40. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/replay_verify.py +0 -0
  41. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/retry_policy.py +0 -0
  42. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/serializer.py +0 -0
  43. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/sync.py +0 -0
  44. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/testing.py +0 -0
  45. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/worker_session.py +0 -0
  46. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  47. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  48. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  49. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/requires.txt +0 -0
  50. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/src/durable_workflow.egg-info/top_level.txt +0 -0
  51. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_activity_cancellation_policies.py +0 -0
  52. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_activity_context.py +0 -0
  53. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_activity_process.py +0 -0
  54. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_auth_composition.py +0 -0
  55. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_avro_value_benchmark.py +0 -0
  56. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_avro_value_protocol.py +0 -0
  57. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_context.py +0 -0
  58. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_remaining_time.py +0 -0
  59. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_admission.py +0 -0
  60. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_authoring.py +0 -0
  61. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_boundary.py +0 -0
  62. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cancellation_scope_opening.py +0 -0
  63. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_child_workflow_policies.py +0 -0
  64. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_ci_checkout.py +0 -0
  65. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_ci_integration_endpoint.py +0 -0
  66. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_client_timeouts.py +0 -0
  67. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_codec_regression_corpus.py +0 -0
  68. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  69. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_groups.py +0 -0
  70. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_history.py +0 -0
  71. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_committed_cancellation_scope_replay.py +0 -0
  72. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_condition_replay_drift.py +0 -0
  73. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_control_plane_parity_fixtures.py +0 -0
  74. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_cancellation.py +0 -0
  75. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_cancellation_client.py +0 -0
  76. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_cancellation_worker.py +0 -0
  77. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_cooperative_remote_worker.py +0 -0
  78. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_docstring_cross_references.py +0 -0
  79. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_durable_selection.py +0 -0
  80. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_errors.py +0 -0
  81. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_external_storage.py +0 -0
  82. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_external_task_input.py +0 -0
  83. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_external_task_result.py +0 -0
  84. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_golden_history_replay.py +0 -0
  85. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_history_bundle_verify.py +0 -0
  86. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_history_event_contract.py +0 -0
  87. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_invocable.py +0 -0
  88. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_message_streams.py +0 -0
  89. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_metrics.py +0 -0
  90. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_order_processing_example.py +0 -0
  91. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_parallel_completion_order_replay.py +0 -0
  92. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_parallel_saga_authoring.py +0 -0
  93. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_patch_history.py +0 -0
  94. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_pending_operation_replay.py +0 -0
  95. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_client.py +0 -0
  96. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_groups.py +0 -0
  97. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_policies.py +0 -0
  98. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_prepared_local_activity_worker.py +0 -0
  99. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_public_boundary_scanner.py +0 -0
  100. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_pypi_project_surface.py +0 -0
  101. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_python_conformance.py +0 -0
  102. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_queries.py +0 -0
  103. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_readme_quickstart.py +0 -0
  104. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_redrive_replay.py +0 -0
  105. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_registration_ambiguity.py +0 -0
  106. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_release_metadata.py +0 -0
  107. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay.py +0 -0
  108. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay_conformance.py +0 -0
  109. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_replay_verify.py +0 -0
  110. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_retry_policy.py +0 -0
  111. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_run_cancellation_scope_replay.py +0 -0
  112. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_schedules.py +0 -0
  113. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_scoped_run_cancellation_context.py +0 -0
  114. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_serializer.py +0 -0
  115. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_signals.py +0 -0
  116. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_sleep.py +0 -0
  117. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_standalone_activity_client.py +0 -0
  118. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_sticky_execution.py +0 -0
  119. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_storage_admission.py +0 -0
  120. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_sync.py +0 -0
  121. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_testing_harness.py +0 -0
  122. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_update_signal_condition_replay.py +0 -0
  123. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_updates.py +0 -0
  124. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_wait_condition.py +0 -0
  125. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_worker_session.py +0 -0
  126. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_entrypoint_validation.py +0 -0
  127. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_result_timeout.py +0 -0
  128. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_streams.py +0 -0
  129. {durable_workflow-2.5.4 → durable_workflow-2.5.6}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.4
3
+ Version: 2.5.6
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
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 = "2.5.4"
7
+ version = "2.5.6"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.5.4"
75
- registry-version = "2.5.4"
74
+ product-train = "2.5.6"
75
+ registry-version = "2.5.6"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -19,6 +19,7 @@ methods without repeating the id on every call.
19
19
  from __future__ import annotations
20
20
 
21
21
  import asyncio
22
+ import copy
22
23
  import hashlib
23
24
  import json as json_module
24
25
  import math
@@ -2672,6 +2673,26 @@ class Client:
2672
2673
  """
2673
2674
  return WorkflowHandle(self, workflow_id=workflow_id, run_id=run_id, workflow_type=workflow_type)
2674
2675
 
2676
+ def _nexus_target_client(self, target_namespace: str | None) -> Client:
2677
+ if target_namespace is not None and not target_namespace.strip():
2678
+ raise ValueError("target_namespace must be nonempty")
2679
+ if target_namespace is None or target_namespace == self.namespace:
2680
+ return self
2681
+ self._auth_token(worker=False)
2682
+ scoped = copy.copy(self)
2683
+ scoped.namespace = target_namespace
2684
+ # Share the HTTP pool, never the caller's namespace discovery or
2685
+ # provider credentials. The worker keeps using the original client.
2686
+ scoped.worker_token = None
2687
+ scoped._cluster_info = None
2688
+ scoped._cluster_info_lock = asyncio.Lock()
2689
+ scoped._runtime_external_payload_transport_cache = None
2690
+ scoped._runtime_external_payload_transport_resolved = False
2691
+ scoped.external_storage = None
2692
+ scoped.external_storage_threshold_bytes = None
2693
+ scoped.external_storage_cache = ExternalPayloadCache()
2694
+ return scoped
2695
+
2675
2696
  async def execute_nexus_operation(
2676
2697
  self,
2677
2698
  endpoint_name: str,
@@ -2685,6 +2706,7 @@ class Client:
2685
2706
  idempotency_key: str | None = None,
2686
2707
  payload_codec: str | None = None,
2687
2708
  caller_namespace: str | None = None,
2709
+ target_namespace: str | None = None,
2688
2710
  caller_workflow_instance_id: str | None = None,
2689
2711
  caller_workflow_run_id: str | None = None,
2690
2712
  service_sdk_language: str | None = None,
@@ -2708,7 +2730,11 @@ class Client:
2708
2730
  the worker uses this client method to make the single durable
2709
2731
  execute request and records the returned service-call surface into
2710
2732
  workflow history.
2733
+
2734
+ ``target_namespace`` selects the service catalog and its payload
2735
+ storage. The caller identity and worker namespace are preserved.
2711
2736
  """
2737
+ execution_client = self._nexus_target_client(target_namespace)
2712
2738
  request_body = nexus_request_payload(
2713
2739
  arguments=list(arguments) if arguments is not None else [],
2714
2740
  payload_codec=payload_codec,
@@ -2736,8 +2762,21 @@ class Client:
2736
2762
  )
2737
2763
  context = f"{endpoint_name}/{service_name}/{operation_name}"
2738
2764
 
2765
+ wire_body = request_body
2766
+ transport = await execution_client._runtime_external_payload_transport()
2767
+ if transport is not None:
2768
+ arguments_envelope = await execution_client._externalize_runtime_payloads(
2769
+ {"codec": request_body["payload_codec"], "blob": request_body["arguments"]},
2770
+ worker=False,
2771
+ transport=transport,
2772
+ uploaded={},
2773
+ slot=("arguments",),
2774
+ )
2775
+ if "external_payload" in arguments_envelope:
2776
+ wire_body = {**request_body, "arguments": arguments_envelope}
2777
+
2739
2778
  try:
2740
- data = await self._request("POST", path, json=request_body, context=context)
2779
+ data = await execution_client._request("POST", path, json=wire_body, context=context)
2741
2780
  except ServerError as exc:
2742
2781
  if exc.status != 409 or not isinstance(exc.body, dict):
2743
2782
  raise
@@ -2747,6 +2786,7 @@ class Client:
2747
2786
  service_name=service_name,
2748
2787
  operation_name=operation_name,
2749
2788
  request_payload=request_body,
2789
+ target_namespace=target_namespace,
2750
2790
  service_sdk_language=service_sdk_language,
2751
2791
  artifact_tuple=artifact_tuple,
2752
2792
  published_artifact_worker_execution=published_artifact_worker_execution,
@@ -2770,6 +2810,7 @@ class Client:
2770
2810
  service_name=service_name,
2771
2811
  operation_name=operation_name,
2772
2812
  request_payload=request_body,
2813
+ target_namespace=target_namespace,
2773
2814
  service_sdk_language=service_sdk_language,
2774
2815
  artifact_tuple=artifact_tuple,
2775
2816
  published_artifact_worker_execution=published_artifact_worker_execution,
@@ -8,6 +8,7 @@ from collections.abc import Mapping, Sequence
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any
10
10
 
11
+ from . import serializer
11
12
  from .errors import NexusOperationFailed
12
13
 
13
14
  NEXUS_OPERATION_RESULT_SCHEMA = "durable-workflow.v2.sdk-python.nexus-operation-result"
@@ -49,6 +50,7 @@ def stable_nexus_idempotency_key(
49
50
  service_name: str,
50
51
  operation_name: str,
51
52
  arguments: Sequence[Any],
53
+ target_namespace: str | None = None,
52
54
  ) -> str:
53
55
  """Return a deterministic idempotency key for one workflow-side Nexus call."""
54
56
  source = {
@@ -61,6 +63,8 @@ def stable_nexus_idempotency_key(
61
63
  "operation_name": operation_name,
62
64
  "arguments": list(arguments),
63
65
  }
66
+ if target_namespace is not None:
67
+ source["target_namespace"] = target_namespace
64
68
  encoded = json.dumps(source, sort_keys=True, separators=(",", ":"), default=_json_default)
65
69
  return f"dw-py-nexus-{hashlib.sha256(encoded.encode()).hexdigest()}"
66
70
 
@@ -87,9 +91,10 @@ def nexus_request_payload(
87
91
  duplicate_start_policy: str | None = None,
88
92
  ) -> dict[str, Any]:
89
93
  """Build the server execute-operation request body."""
94
+ codec = payload_codec or serializer.AVRO_CODEC
90
95
  return _clean_dict({
91
- "arguments": list(arguments),
92
- "payload_codec": payload_codec,
96
+ "arguments": serializer.encode(list(arguments), codec=codec),
97
+ "payload_codec": codec,
93
98
  "mode_override": mode,
94
99
  "wait_for": wait_for,
95
100
  "wait_timeout_seconds": wait_timeout_seconds,
@@ -134,6 +139,7 @@ class NexusOperationResult:
134
139
  caller_observed_error_type: str | None = None
135
140
  typed_error_message: str | None = None
136
141
  result: Any = None
142
+ target_namespace: str | None = None
137
143
 
138
144
  @classmethod
139
145
  def from_response(
@@ -147,6 +153,7 @@ class NexusOperationResult:
147
153
  service_sdk_language: str | None = None,
148
154
  artifact_tuple: Mapping[str, Any] | None = None,
149
155
  published_artifact_worker_execution: bool | None = None,
156
+ target_namespace: str | None = None,
150
157
  ) -> NexusOperationResult:
151
158
  call = data.get("service_call")
152
159
  source = call if isinstance(call, Mapping) else data
@@ -188,6 +195,7 @@ class NexusOperationResult:
188
195
  typed_error_message=_optional_str(source.get("typed_error_message"))
189
196
  or _optional_str(data.get("typed_error_message")),
190
197
  result=response_value,
198
+ target_namespace=target_namespace,
191
199
  )
192
200
 
193
201
  @classmethod
@@ -199,6 +207,9 @@ class NexusOperationResult:
199
207
  if value.get("version") != NEXUS_OPERATION_RESULT_VERSION:
200
208
  raise ValueError("recorded Nexus operation result version is not supported")
201
209
 
210
+ if value.get("target_namespace") != expected.get("target_namespace"):
211
+ raise ValueError("recorded Nexus target namespace does not match the current workflow command")
212
+
202
213
  for key in ("endpoint_name", "service_name", "operation_name", "idempotency_key"):
203
214
  expected_value = expected.get(key)
204
215
  recorded_value = value.get(key)
@@ -235,6 +246,7 @@ class NexusOperationResult:
235
246
  caller_observed_error_type=_optional_str(value.get("caller_observed_error_type")),
236
247
  typed_error_message=_optional_str(value.get("typed_error_message")),
237
248
  result=value.get("result"),
249
+ target_namespace=_optional_str(value.get("target_namespace")),
238
250
  )
239
251
  if result.is_failure:
240
252
  raise result.to_failure()
@@ -287,6 +299,7 @@ class NexusOperationResult:
287
299
  "endpoint_name": self.endpoint_name,
288
300
  "service_name": self.service_name,
289
301
  "operation_name": self.operation_name,
302
+ "target_namespace": self.target_namespace,
290
303
  "idempotency_key": self.request_payload.get("idempotency_key"),
291
304
  "caller_workflow_instance_id": self.caller_workflow_instance_id,
292
305
  "caller_workflow_run_id": self.caller_workflow_run_id,
@@ -1462,6 +1462,7 @@ class Worker:
1462
1462
  idempotency_key=command.idempotency_key,
1463
1463
  payload_codec=command.payload_codec,
1464
1464
  caller_namespace=command.caller_namespace,
1465
+ target_namespace=command.target_namespace,
1465
1466
  caller_workflow_instance_id=_string_or_none(task.get("workflow_id")),
1466
1467
  caller_workflow_run_id=_string_or_none(task.get("run_id")),
1467
1468
  service_sdk_language=command.service_sdk_language,
@@ -4076,11 +4077,12 @@ class Worker:
4076
4077
  try:
4077
4078
  if self._stop.is_set():
4078
4079
  return
4079
- wf_loop = asyncio.create_task(self._poll_workflow_tasks())
4080
- act_loop = asyncio.create_task(self._poll_activity_tasks())
4081
- hb_loop = asyncio.create_task(self._heartbeat_loop())
4082
- loops = [wf_loop, act_loop, hb_loop]
4083
- if self._query_tasks_supported:
4080
+ loops = [asyncio.create_task(self._heartbeat_loop())]
4081
+ if self.workflows:
4082
+ loops.append(asyncio.create_task(self._poll_workflow_tasks()))
4083
+ if self.activities:
4084
+ loops.append(asyncio.create_task(self._poll_activity_tasks()))
4085
+ if self.workflows and self._query_tasks_supported:
4084
4086
  if self._can_clone_client_for_query_tasks():
4085
4087
  self._start_query_task_thread()
4086
4088
  else:
@@ -4242,7 +4244,7 @@ class Worker:
4242
4244
  if self._stop.is_set():
4243
4245
  raise asyncio.CancelledError
4244
4246
  background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
4245
- if self._query_tasks_supported:
4247
+ if self.workflows and self._query_tasks_supported:
4246
4248
  if self._can_clone_client_for_query_tasks():
4247
4249
  self._start_query_task_thread()
4248
4250
  else:
@@ -4294,7 +4296,7 @@ class Worker:
4294
4296
  f"workflow {workflow_id} not terminal after {timeout}s (status={desc.status})"
4295
4297
  )
4296
4298
 
4297
- if next_task_kind == "workflow":
4299
+ if self.workflows and (next_task_kind == "workflow" or not self.activities):
4298
4300
  await self._reserve_workflow_capacity()
4299
4301
  try:
4300
4302
  poll_start = time.perf_counter()
@@ -4346,6 +4348,9 @@ class Worker:
4346
4348
  next_task_kind = "workflow"
4347
4349
  continue
4348
4350
 
4351
+ if not self.activities:
4352
+ await asyncio.sleep(poll_interval)
4353
+ continue
4349
4354
  self._validate_cooperative_activity_handlers()
4350
4355
  if len(self._remote_activity_processes) >= self.max_concurrent_activity_tasks:
4351
4356
  next_task_kind = "workflow"
@@ -1196,6 +1196,7 @@ class NexusServiceCall:
1196
1196
  memo: dict[str, Any] | None = None
1197
1197
  search_attributes: dict[str, Any] | None = None
1198
1198
  duplicate_start_policy: str | None = None
1199
+ target_namespace: str | None = None
1199
1200
 
1200
1201
  def to_server_command(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
1201
1202
  raise RuntimeError(
@@ -1209,6 +1210,7 @@ class NexusServiceCall:
1209
1210
  "service_name": self.service_name,
1210
1211
  "operation_name": self.operation_name,
1211
1212
  "idempotency_key": self.idempotency_key,
1213
+ "target_namespace": self.target_namespace,
1212
1214
  }
1213
1215
 
1214
1216
  def recorded_result(self, value: Any) -> NexusOperationResult:
@@ -2402,6 +2404,7 @@ class WorkflowContext:
2402
2404
  wait_for: str | None = "completed",
2403
2405
  wait_timeout_seconds: int | None = None,
2404
2406
  caller_namespace: str | None = None,
2407
+ target_namespace: str | None = None,
2405
2408
  service_sdk_language: str | None = None,
2406
2409
  artifact_tuple: Mapping[str, Any] | None = None,
2407
2410
  published_artifact_worker_execution: bool | None = None,
@@ -2420,7 +2423,12 @@ class WorkflowContext:
2420
2423
  If the service reports a typed failure, replay raises
2421
2424
  :class:`~durable_workflow.errors.NexusOperationFailed` at the yield
2422
2425
  point so workflow code can compensate or let the workflow fail.
2426
+
2427
+ ``target_namespace`` addresses a service in another namespace and
2428
+ participates in the durable call identity and replay checks.
2423
2429
  """
2430
+ if target_namespace is not None and not target_namespace.strip():
2431
+ raise ValueError("target_namespace must be nonempty")
2424
2432
  args = list(arguments) if arguments is not None else []
2425
2433
  if idempotency_key is None:
2426
2434
  idempotency_key = stable_nexus_idempotency_key(
@@ -2431,6 +2439,7 @@ class WorkflowContext:
2431
2439
  service_name=service_name,
2432
2440
  operation_name=operation_name,
2433
2441
  arguments=args,
2442
+ target_namespace=target_namespace,
2434
2443
  )
2435
2444
  self._nexus_call_counter += 1
2436
2445
  return NexusServiceCall(
@@ -2444,6 +2453,7 @@ class WorkflowContext:
2444
2453
  wait_for=wait_for,
2445
2454
  wait_timeout_seconds=wait_timeout_seconds,
2446
2455
  caller_namespace=caller_namespace,
2456
+ target_namespace=target_namespace,
2447
2457
  service_sdk_language=service_sdk_language,
2448
2458
  artifact_tuple=dict(artifact_tuple) if artifact_tuple is not None else None,
2449
2459
  published_artifact_worker_execution=published_artifact_worker_execution,
@@ -6589,6 +6599,14 @@ def _replay_state(
6589
6599
  if result_cursor < len(resolved_results):
6590
6600
  _assert_next_step_matches(cmd)
6591
6601
  recorded_value = resolved_results[result_cursor]
6602
+ if (
6603
+ isinstance(recorded_value, Mapping)
6604
+ and recorded_value.get("target_namespace") != cmd.target_namespace
6605
+ ):
6606
+ raise NonDeterministicReplayError(
6607
+ current_call_sequence, "NexusServiceCall", ["SideEffectRecorded"],
6608
+ detail="nexus_target_namespace_changed: recorded target differs from the workflow command",
6609
+ )
6592
6610
  result_cursor += 1
6593
6611
  try:
6594
6612
  next_value = cmd.recorded_result(recorded_value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.4
3
+ Version: 2.5.6
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -80,6 +80,8 @@ tests/test_history_event_contract.py
80
80
  tests/test_invocable.py
81
81
  tests/test_message_streams.py
82
82
  tests/test_metrics.py
83
+ tests/test_nexus_namespace.py
84
+ tests/test_nexus_wire.py
83
85
  tests/test_order_processing_example.py
84
86
  tests/test_parallel_completion_order_replay.py
85
87
  tests/test_parallel_saga_authoring.py
@@ -289,7 +289,8 @@ class TestNexusOperations:
289
289
  "/api/service-endpoints/greeter/services/shared/operations/greet/execute",
290
290
  )
291
291
  body = mock.call_args.kwargs["json"]
292
- assert body["arguments"] == ["Ada"]
292
+ assert body["payload_codec"] == "avro"
293
+ assert serializer.decode(body["arguments"], codec=body["payload_codec"]) == ["Ada"]
293
294
  assert body["mode_override"] == "sync"
294
295
  assert body["wait_for"] == "completed"
295
296
  assert body["idempotency_key"] == "idem-1"
@@ -303,7 +304,7 @@ class TestNexusOperations:
303
304
  assert recorded["caller_sdk_language"] == "sdk-python"
304
305
  assert recorded["service_sdk_language"] == "workflow-php"
305
306
  assert recorded["operation_name"] == "greet"
306
- assert recorded["request_payload"]["arguments"] == ["Ada"]
307
+ assert serializer.decode(recorded["request_payload"]["arguments"], codec="avro") == ["Ada"]
307
308
  assert recorded["response_or_failure_surface"]["result"] == {"greeting": "hello Ada"}
308
309
  assert recorded["service_call_id"] == "svc-call-1"
309
310
  assert recorded["artifact_tuple"]["sdk-python"] == "0.4.95"
@@ -0,0 +1,160 @@
1
+ import asyncio
2
+ import json
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from durable_workflow import Client, serializer, workflow
8
+ from durable_workflow.errors import NonDeterministicReplayError
9
+ from durable_workflow.nexus import NexusOperationResult, stable_nexus_idempotency_key
10
+ from durable_workflow.workflow import CompleteWorkflow, NexusServiceCall, WorkflowContext, replay
11
+ from tests.test_runtime_external_payload_transport import FakeRuntimePayloadServer
12
+
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_target_requests_and_concurrent_worker_heartbeat_keep_separate_scopes():
16
+ requests = []
17
+
18
+ async def handler(request):
19
+ requests.append(request)
20
+ await asyncio.sleep(0)
21
+ if request.url.path == "/api/cluster/info":
22
+ return httpx.Response(200, json={"namespace": {"name": request.headers["x-namespace"]}})
23
+ return httpx.Response(200, json={"accepted": True, "id": "call-1", "status": "started"})
24
+
25
+ async with Client(
26
+ "https://runtime.example", namespace="tenant-b", control_token="control", worker_token="worker",
27
+ ) as client:
28
+ await client._http.aclose()
29
+ client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
30
+ client._cluster_info = {"namespace": {"name": "tenant-b"}}
31
+ result, _ = await asyncio.gather(
32
+ client.execute_nexus_operation("greeter", "shared", "greet", ["Ada"], target_namespace="shared",
33
+ caller_workflow_instance_id="caller", caller_workflow_run_id="original-run"),
34
+ client.heartbeat_worker(worker_id="caller-worker"),
35
+ )
36
+ assert client.namespace == "tenant-b"
37
+ assert client._cluster_info == {"namespace": {"name": "tenant-b"}}
38
+
39
+ nexus = next(request for request in requests if request.url.path.endswith("/execute"))
40
+ heartbeat = next(request for request in requests if request.url.path == "/api/worker/heartbeat")
41
+ assert nexus.headers["x-namespace"] == "shared"
42
+ assert nexus.headers["authorization"] == "Bearer control"
43
+ assert heartbeat.headers["x-namespace"] == "tenant-b"
44
+ assert heartbeat.headers["authorization"] == "Bearer worker"
45
+ body = json.loads(nexus.content)
46
+ assert body["caller_namespace"] == "tenant-b"
47
+ assert body["caller_workflow_instance_id"] == "caller"
48
+ assert body["caller_workflow_run_id"] == "original-run"
49
+ assert result.target_namespace == "shared"
50
+ assert result.to_recorded_payload()["target_namespace"] == "shared"
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_cross_namespace_payload_discovery_upload_and_admission_use_target_scope():
55
+ server = FakeRuntimePayloadServer(threshold_bytes=1)
56
+ requests = []
57
+
58
+ def handler(request):
59
+ requests.append(request)
60
+ assert request.headers["x-namespace"] == "shared"
61
+ assert request.headers["authorization"] == "Bearer control"
62
+ if request.url.path == "/api/cluster/info":
63
+ info = server.cluster_info()
64
+ info["namespace"]["name"] = "shared"
65
+ return httpx.Response(200, json=info)
66
+ if request.url.path == "/api/external-payloads/v1":
67
+ reference = server.seed(request.content.decode())
68
+ assert int(request.headers["x-durable-workflow-payload-size"]) == reference["size_bytes"]
69
+ assert request.headers["x-durable-workflow-payload-sha256"] == reference["sha256"]
70
+ return httpx.Response(201, json={
71
+ "schema": "durable-workflow.v2.runtime-external-payload-upload.v1",
72
+ "transport_version": 1, "reference": reference,
73
+ })
74
+ return httpx.Response(200, json={"accepted": True, "id": "call-1", "status": "started"})
75
+
76
+ async with Client(
77
+ "https://runtime.example", namespace="tenant-b", control_token="control", worker_token="worker",
78
+ ) as client:
79
+ await client._http.aclose()
80
+ client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
81
+ client._cluster_info = {"namespace": {"name": "tenant-b"}}
82
+ client._runtime_external_payload_transport_resolved = True
83
+ result = await client.execute_nexus_operation(
84
+ "greeter", "shared", "greet", ["Ada" * 128], target_namespace="shared",
85
+ )
86
+ assert client.namespace == "tenant-b"
87
+ assert client._runtime_external_payload_transport_cache is None
88
+
89
+ assert [request.url.path for request in requests] == [
90
+ "/api/cluster/info", "/api/external-payloads/v1",
91
+ "/api/service-endpoints/greeter/services/shared/operations/greet/execute",
92
+ ]
93
+ body = json.loads(requests[-1].content)
94
+ reference = body["arguments"]["external_payload"]
95
+ assert serializer.decode(server.payloads[reference["reference_id"]].decode(), codec="avro") == ["Ada" * 128]
96
+ assert body["caller_namespace"] == "tenant-b"
97
+ assert serializer.decode(result.request_payload["arguments"], codec="avro") == ["Ada" * 128]
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_target_scope_discards_caller_discovery_payload_cache_and_provider():
102
+ client = Client("https://runtime.example", namespace="tenant-b", token="shared-token")
103
+ client._cluster_info = {"namespace": {"name": "tenant-b"}}
104
+ client._runtime_external_payload_transport_resolved = True
105
+ target = client._nexus_target_client("shared")
106
+ assert target._http is client._http
107
+ assert target.namespace == "shared"
108
+ assert target._cluster_info is None
109
+ assert target._cluster_info_lock is not client._cluster_info_lock
110
+ assert not target._runtime_external_payload_transport_resolved
111
+ assert target.external_storage is None
112
+ assert target.external_storage_cache is not client.external_storage_cache
113
+ await client.aclose()
114
+
115
+
116
+ @workflow.defn(name="nexus-target-caller")
117
+ class NexusTargetCaller:
118
+ def run(self, ctx: WorkflowContext, target: str | None):
119
+ result = yield ctx.call_nexus_service("greeter", "shared", "greet", ["Ada"],
120
+ target_namespace=target, idempotency_key="original-call")
121
+ return result.service_call_id
122
+
123
+
124
+ @pytest.mark.parametrize("target", [None, "shared"])
125
+ def test_original_target_replays_and_changed_target_is_refused(target):
126
+ command = replay(NexusTargetCaller, [], [target], workflow_id="caller", run_id="original-run").commands[0]
127
+ assert isinstance(command, NexusServiceCall)
128
+ result = NexusOperationResult(accepted=True, service_call_id="call-1", endpoint_name="greeter",
129
+ service_name="shared", operation_name="greet", target_namespace=target,
130
+ request_payload={"idempotency_key": command.idempotency_key}, status="completed")
131
+ history = [{"event_type": "SideEffectRecorded", "payload": {
132
+ "sequence": 1, "payload_codec": "avro", "result": serializer.encode(result.to_recorded_payload(), codec="avro"),
133
+ }}]
134
+ outcome = replay(
135
+ NexusTargetCaller, history, [target], workflow_id="caller", run_id="original-run", payload_codec="avro",
136
+ )
137
+ assert isinstance(outcome.commands[0], CompleteWorkflow)
138
+ assert outcome.commands[0].result == "call-1"
139
+ changed = "other" if target else "shared"
140
+ with pytest.raises(NonDeterministicReplayError, match="nexus_target_namespace_changed"):
141
+ replay(NexusTargetCaller, history, [changed], workflow_id="caller", run_id="original-run", payload_codec="avro")
142
+
143
+
144
+ def test_explicit_target_participates_in_call_identity_and_default_stays_compatible():
145
+ identity = dict(workflow_id="caller", run_id="original-run", ordinal=0, endpoint_name="greeter",
146
+ service_name="shared", operation_name="greet", arguments=["Ada"])
147
+ legacy = stable_nexus_idempotency_key(**identity)
148
+ assert stable_nexus_idempotency_key(**identity, target_namespace=None) == legacy
149
+ assert stable_nexus_idempotency_key(**identity, target_namespace="shared") != legacy
150
+ assert stable_nexus_idempotency_key(**identity, target_namespace="shared") != stable_nexus_idempotency_key(
151
+ **identity, target_namespace="other")
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_empty_target_is_refused_before_network_and_worker_credentials_are_not_reused():
156
+ async with Client("https://runtime.example", namespace="tenant-b", worker_token="worker-only") as client:
157
+ with pytest.raises(ValueError, match="nonempty"):
158
+ await client.execute_nexus_operation("greeter", "shared", "greet", target_namespace=" ")
159
+ with pytest.raises(ValueError, match="control-plane requests require"):
160
+ await client.execute_nexus_operation("greeter", "shared", "greet", target_namespace="shared")
@@ -0,0 +1,38 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from durable_workflow import serializer
7
+ from durable_workflow.nexus import nexus_request_payload, stable_nexus_idempotency_key
8
+
9
+
10
+ def test_nexus_request_matches_the_regression_corpus_wire():
11
+ fixture = json.loads((Path(__file__).parent / "fixtures" / "codec_regressions"
12
+ / "nexus-world-arguments-avro.json").read_text())
13
+ request = nexus_request_payload(arguments=["world"])
14
+ assert request["arguments"] == fixture["framing"]["wire_base64"]
15
+ assert serializer.decode(request["arguments"], codec=request["payload_codec"]) == ["world"]
16
+
17
+
18
+ @pytest.mark.parametrize("arguments", [[], ["world"], [None, True, 42, {"nested": [b"\x00\xff", 1.5]}]])
19
+ def test_nexus_arguments_use_the_portable_avro_payload(arguments):
20
+ request = nexus_request_payload(arguments=arguments)
21
+ assert request["payload_codec"] == "avro"
22
+ assert isinstance(request["arguments"], str)
23
+ assert serializer.decode(request["arguments"], codec="avro") == arguments
24
+
25
+
26
+ def test_nexus_refuses_json_as_a_workflow_payload_codec():
27
+ with pytest.raises(ValueError, match="unsupported_payload_codec"):
28
+ nexus_request_payload(arguments=["world"], payload_codec="json")
29
+
30
+
31
+ def test_encoding_does_not_change_the_existing_workflow_call_identity():
32
+ arguments = ["world", {"value": 42}]
33
+ identity = dict(workflow_id="caller", run_id="original-run", ordinal=0,
34
+ endpoint_name="endpoint", service_name="service", operation_name="greet")
35
+ key = stable_nexus_idempotency_key(**identity, arguments=arguments)
36
+ request = nexus_request_payload(arguments=arguments, idempotency_key=key)
37
+ decoded = serializer.decode(request["arguments"], codec=request["payload_codec"])
38
+ assert request["idempotency_key"] == stable_nexus_idempotency_key(**identity, arguments=decoded)
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock
9
9
  import pytest
10
10
 
11
11
  from durable_workflow import Replayer, Worker, serializer, workflow
12
- from durable_workflow.client import Client, WorkflowStreamAppendItem
12
+ from durable_workflow.client import PROTOCOL_VERSION, Client, WorkflowExecution, WorkflowStreamAppendItem
13
13
  from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled, WorkflowPayloadDecodeError
14
14
  from durable_workflow.workflow import (
15
15
  LocalActivityExecutionAborted,
@@ -31,6 +31,7 @@ from tests.test_update_signal_condition_replay import (
31
31
  PostConditionReceiversWorkflow,
32
32
  UpdateSignalConditionTimerWorkflow,
33
33
  )
34
+ from tests.test_worker import compatible_cluster_info
34
35
 
35
36
  FIXTURE_SCHEMA = "durable-workflow.replay-regression/v1"
36
37
  FIXTURE_DIR = Path(__file__).parent / "fixtures" / "replay_regressions"
@@ -259,7 +260,18 @@ class UnsupportedAsyncWorkflow:
259
260
  return "body was never executed"
260
261
 
261
262
 
263
+ @workflow.defn(name="tests.replay.nexus-target")
264
+ class NexusTargetWorkflow:
265
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
266
+ result = yield ctx.call_nexus_service(
267
+ "greeter", "shared", "greet", ["Ada"],
268
+ target_namespace="other", idempotency_key="original-call",
269
+ )
270
+ return result.service_call_id
271
+
272
+
262
273
  WORKFLOWS = [
274
+ NexusTargetWorkflow,
263
275
  UnsupportedAsyncWorkflow,
264
276
  ChildPolicyAuthorWorkflow,
265
277
  ColdReplacementSatisfiedConditionWorkflow,
@@ -478,6 +490,46 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
478
490
  _execute_fixture(fixture)
479
491
 
480
492
 
493
+ @pytest.mark.asyncio
494
+ async def test_workflow_only_driver_resumes_external_activity_without_activity_polling() -> None:
495
+ fixture = json.loads(
496
+ (FIXTURE_DIR / "workflow-only-worker-resumes-external-activity.json").read_text(encoding="utf-8")
497
+ )
498
+ client = AsyncMock(spec=Client)
499
+ client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={"version": PROTOCOL_VERSION})
500
+ client.register_worker.return_value = {"registered": True}
501
+ client.deregister_worker_registration.return_value = {"outcome": "deregistered"}
502
+ client.poll_activity_task.side_effect = AssertionError("workflow-only worker must not poll activities")
503
+ client.poll_workflow_task.side_effect = [None, {
504
+ "task_id": "external-activity-resume-task", "workflow_id": "external-activity-caller",
505
+ "run_id": "original-run", "workflow_type": fixture["workflow"]["type"],
506
+ "workflow_task_attempt": 1, "history_events": fixture["history"],
507
+ "arguments": serializer.encode(fixture["workflow"]["input"], codec="avro"),
508
+ "payload_codec": "avro",
509
+ }]
510
+ client.complete_workflow_task.return_value = {"outcome": "completed"}
511
+
512
+ async def describe(workflow_id: str) -> WorkflowExecution:
513
+ return WorkflowExecution(
514
+ workflow_id=workflow_id, run_id="original-run", workflow_type=fixture["workflow"]["type"],
515
+ status="completed" if client.complete_workflow_task.await_count else "running",
516
+ )
517
+
518
+ client.describe_workflow.side_effect = describe
519
+ worker = Worker(client, task_queue="external-activity", workflows=[GoldenSingleActivityWorkflow], activities=[])
520
+
521
+ result = await worker.run_until(workflow_id="external-activity-caller", timeout=1, poll_interval=0.01)
522
+
523
+ assert result.status == "completed"
524
+ assert result.run_id == "original-run"
525
+ assert client.poll_workflow_task.await_count == 2
526
+ client.poll_activity_task.assert_not_awaited()
527
+ client.complete_workflow_task.assert_awaited_once()
528
+ commands = client.complete_workflow_task.call_args.kwargs["commands"]
529
+ assert [command["type"] for command in commands] == [fixture["expected"]["type"]]
530
+ assert serializer.decode_envelope(commands[0]["result"]) == fixture["expected"]["result"]
531
+
532
+
481
533
  @pytest.mark.asyncio
482
534
  async def test_worker_replays_recorded_side_effects_across_history_pages() -> None:
483
535
  fixture = json.loads((FIXTURE_DIR / "paged-recorded-side-effects.json").read_text(encoding="utf-8"))