dominus-sdk-python 2.16.0__tar.gz → 2.18.0__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 (52) hide show
  1. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/PKG-INFO +11 -3
  2. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/README.md +10 -2
  3. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/__init__.py +1 -1
  4. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/workflow.py +595 -2
  5. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus_sdk_python.egg-info/PKG-INFO +11 -3
  6. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/pyproject.toml +1 -1
  7. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/tests/test_workflow_lifecycle.py +299 -0
  8. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/config/__init__.py +0 -0
  9. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/config/endpoints.py +0 -0
  10. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/errors.py +0 -0
  11. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/__init__.py +0 -0
  12. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/auth.py +0 -0
  13. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/cache.py +0 -0
  14. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/console_capture.py +0 -0
  15. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/core.py +0 -0
  16. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/crypto.py +0 -0
  17. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/sse.py +0 -0
  18. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/helpers/trace.py +0 -0
  19. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/__init__.py +0 -0
  20. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/admin.py +0 -0
  21. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/ai.py +0 -0
  22. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/artifacts.py +0 -0
  23. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/auth.py +0 -0
  24. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/courier.py +0 -0
  25. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/db.py +0 -0
  26. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/ddl.py +0 -0
  27. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/fastapi.py +0 -0
  28. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/files.py +0 -0
  29. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/health.py +0 -0
  30. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/jobs.py +0 -0
  31. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/logs.py +0 -0
  32. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/open.py +0 -0
  33. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/oracle/__init__.py +0 -0
  34. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/oracle/audio_capture.py +0 -0
  35. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/oracle/oracle_websocket.py +0 -0
  36. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/oracle/session.py +0 -0
  37. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/oracle/types.py +0 -0
  38. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/oracle/vad_gate.py +0 -0
  39. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/portal.py +0 -0
  40. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/processor.py +0 -0
  41. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/redis.py +0 -0
  42. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/secrets.py +0 -0
  43. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/secure.py +0 -0
  44. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/namespaces/sync.py +0 -0
  45. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/services/__init__.py +0 -0
  46. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus/start.py +0 -0
  47. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus_sdk_python.egg-info/SOURCES.txt +0 -0
  48. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus_sdk_python.egg-info/dependency_links.txt +0 -0
  49. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus_sdk_python.egg-info/requires.txt +0 -0
  50. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/dominus_sdk_python.egg-info/top_level.txt +0 -0
  51. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/setup.cfg +0 -0
  52. {dominus_sdk_python-2.16.0 → dominus_sdk_python-2.18.0}/tests/test_workflow_refs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dominus-sdk-python
3
- Version: 2.16.0
3
+ Version: 2.18.0
4
4
  Summary: Python SDK for the Dominus Orchestrator Platform
5
5
  Author-email: CareBridge Systems <dev@carebridge.io>
6
6
  License: Proprietary
@@ -49,7 +49,7 @@ Async Python SDK for CareBridge Dominus platform services. Routes calls through
49
49
  - Server-side, asyncio-first Python SDK (3.9+)
50
50
  - Namespace API with root-level shortcuts for common operations
51
51
  - Targets production Cloudflare Workers (gateway, JWT, logs) and Cloud Run (orchestrator)
52
- - Version: 2.16.0
52
+ - Version: 2.18.0
53
53
 
54
54
  ## Quick Start
55
55
 
@@ -89,7 +89,15 @@ async for chunk in dominus.ai.stream_agent(
89
89
  ):
90
90
  print(chunk.get("content", ""), end="", flush=True)
91
91
 
92
- # Saved workflow lifecycle through workflow-manager
92
+ # Preferred Authority-backed one-call lifecycle
93
+ execution = await dominus.workflow.ensure(
94
+ "wf://carebridge/report-cycle",
95
+ subject="PCM47474562",
96
+ company="summit-radiology",
97
+ inputs={"report_snapshot": "ar://carebridge/summit-radiology/production/snapshot/report-1"},
98
+ )
99
+
100
+ # Saved workflow lifecycle through workflow-manager when you need explicit artifacts
93
101
  run = await dominus.workflow.create_run(
94
102
  workflow_id="wf_saved_123",
95
103
  context={"report_ref": {"report_id": "r-1", "accession": "a-1", "session_number": "2"}},
@@ -7,7 +7,7 @@ Async Python SDK for CareBridge Dominus platform services. Routes calls through
7
7
  - Server-side, asyncio-first Python SDK (3.9+)
8
8
  - Namespace API with root-level shortcuts for common operations
9
9
  - Targets production Cloudflare Workers (gateway, JWT, logs) and Cloud Run (orchestrator)
10
- - Version: 2.16.0
10
+ - Version: 2.18.0
11
11
 
12
12
  ## Quick Start
13
13
 
@@ -47,7 +47,15 @@ async for chunk in dominus.ai.stream_agent(
47
47
  ):
48
48
  print(chunk.get("content", ""), end="", flush=True)
49
49
 
50
- # Saved workflow lifecycle through workflow-manager
50
+ # Preferred Authority-backed one-call lifecycle
51
+ execution = await dominus.workflow.ensure(
52
+ "wf://carebridge/report-cycle",
53
+ subject="PCM47474562",
54
+ company="summit-radiology",
55
+ inputs={"report_snapshot": "ar://carebridge/summit-radiology/production/snapshot/report-1"},
56
+ )
57
+
58
+ # Saved workflow lifecycle through workflow-manager when you need explicit artifacts
51
59
  run = await dominus.workflow.create_run(
52
60
  workflow_id="wf_saved_123",
53
61
  context={"report_ref": {"report_id": "r-1", "accession": "a-1", "session_number": "2"}},
@@ -180,7 +180,7 @@ from .errors import (
180
180
  TimeoutError as DominusTimeoutError,
181
181
  )
182
182
 
183
- __version__ = "2.16.0"
183
+ __version__ = "2.18.0"
184
184
  __all__ = [
185
185
  # Main SDK instance
186
186
  "dominus",
@@ -34,6 +34,9 @@ Usage:
34
34
  await dominus.workflow.validate_run(run["run_id"])
35
35
  result = await dominus.workflow.start_run(run["run_id"], mode="async")
36
36
  """
37
+ import base64
38
+ import json
39
+ import uuid
37
40
  from typing import Any, Dict, List, Optional, TYPE_CHECKING
38
41
  from urllib.parse import urlencode
39
42
 
@@ -86,6 +89,158 @@ class WorkflowNamespace:
86
89
  return {"workflow_id": str(workflow_id).strip()}
87
90
  raise ValueError("workflow_id or workflow_ref is required")
88
91
 
92
+ @staticmethod
93
+ def _instance_base_endpoint(workflow_id: str) -> str:
94
+ from urllib.parse import quote
95
+
96
+ return f"/api/workflow/workflows/{quote(workflow_id, safe='')}/instances"
97
+
98
+ @staticmethod
99
+ def _build_instance_nudge_policy(
100
+ nudge_policy: Optional[Dict[str, Any]] = None,
101
+ ) -> Optional[Dict[str, Any]]:
102
+ if not nudge_policy:
103
+ return None
104
+
105
+ policy = dict(nudge_policy)
106
+ result: Dict[str, Any] = {}
107
+ if "mode" in policy and policy["mode"] is not None:
108
+ result["mode"] = policy["mode"]
109
+ if "max_rerun_count" in policy and policy["max_rerun_count"] is not None:
110
+ result["max_rerun_count"] = policy["max_rerun_count"]
111
+ elif "maxRerunCount" in policy and policy["maxRerunCount"] is not None:
112
+ result["max_rerun_count"] = policy["maxRerunCount"]
113
+ if "rerun_window_seconds" in policy and policy["rerun_window_seconds"] is not None:
114
+ result["rerun_window_seconds"] = policy["rerun_window_seconds"]
115
+ elif "rerunWindowSeconds" in policy and policy["rerunWindowSeconds"] is not None:
116
+ result["rerun_window_seconds"] = policy["rerunWindowSeconds"]
117
+
118
+ for key, value in policy.items():
119
+ if value is None:
120
+ continue
121
+ if key in {"mode", "max_rerun_count", "maxRerunCount", "rerun_window_seconds", "rerunWindowSeconds"}:
122
+ continue
123
+ result[key] = value
124
+
125
+ return result or None
126
+
127
+ def _build_ensure_instance_body(
128
+ self,
129
+ *,
130
+ group: str,
131
+ owner: str,
132
+ instance_id: Optional[str] = None,
133
+ environment: Optional[str] = None,
134
+ nudge_policy: Optional[Dict[str, Any]] = None,
135
+ bindings: Optional[Dict[str, Any]] = None,
136
+ mode: Optional[str] = None,
137
+ context: Optional[Dict[str, Any]] = None,
138
+ ) -> Dict[str, Any]:
139
+ body: Dict[str, Any] = {
140
+ "params": {
141
+ "group": group,
142
+ "owner": owner,
143
+ }
144
+ }
145
+ if instance_id:
146
+ body["instance_id"] = instance_id
147
+ if environment:
148
+ body["params"]["env"] = environment
149
+ policy = self._build_instance_nudge_policy(nudge_policy)
150
+ if policy:
151
+ body["params"]["nudge_policy"] = policy
152
+ if bindings:
153
+ body["bindings"] = bindings
154
+ if mode:
155
+ body["mode"] = mode
156
+ if context:
157
+ body["context"] = context
158
+ return body
159
+
160
+ @staticmethod
161
+ def _decode_authority_run_id(run_id: str) -> Optional[Dict[str, str]]:
162
+ normalized = str(run_id or "").strip()
163
+ if not normalized:
164
+ return None
165
+ try:
166
+ padding = "=" * ((4 - len(normalized) % 4) % 4)
167
+ decoded = base64.urlsafe_b64decode((normalized + padding).encode("utf-8")).decode("utf-8")
168
+ payload = json.loads(decoded)
169
+ except Exception:
170
+ return None
171
+
172
+ required = ("project_id", "environment", "workflow_id", "instance_id")
173
+ if all(isinstance(payload.get(field), str) and str(payload.get(field)).strip() for field in required):
174
+ return {field: str(payload[field]).strip() for field in required}
175
+ return None
176
+
177
+ def _is_authority_run_id(self, run_id: str) -> bool:
178
+ return self._decode_authority_run_id(run_id) is not None
179
+
180
+ @staticmethod
181
+ def _authority_mutation_body(
182
+ body: Dict[str, Any],
183
+ *,
184
+ idempotency_key: Optional[str] = None,
185
+ initiator_type: Optional[str] = None,
186
+ initiator_id: Optional[str] = None,
187
+ ) -> Dict[str, Any]:
188
+ result = dict(body)
189
+ if initiator_type:
190
+ result["initiator_type"] = initiator_type
191
+ if initiator_id:
192
+ result["initiator_id"] = initiator_id
193
+ result["idempotency_key"] = idempotency_key or uuid.uuid4().hex
194
+ return result
195
+
196
+ def _build_authority_ensure_body(
197
+ self,
198
+ workflow_id: str,
199
+ *,
200
+ workflow_ref: Optional[str] = None,
201
+ subject: Optional[str] = None,
202
+ company: Optional[str] = None,
203
+ inputs: Optional[Dict[str, Any]] = None,
204
+ context: Optional[Dict[str, Any]] = None,
205
+ bindings: Optional[Dict[str, Any]] = None,
206
+ instance_id: Optional[str] = None,
207
+ mode: str = "async",
208
+ target_project_id: Optional[str] = None,
209
+ target_environment: Optional[str] = None,
210
+ initiator_type: Optional[str] = None,
211
+ initiator_id: Optional[str] = None,
212
+ idempotency_key: Optional[str] = None,
213
+ ) -> Dict[str, Any]:
214
+ body: Dict[str, Any] = {"mode": mode}
215
+ if workflow_ref:
216
+ body["workflow_ref"] = workflow_ref
217
+ elif self._is_workflow_ref(workflow_id):
218
+ body["workflow_ref"] = workflow_id
219
+ else:
220
+ body["workflow_id"] = workflow_id
221
+ if subject is not None:
222
+ body["subject"] = subject
223
+ if company is not None:
224
+ body["company"] = company
225
+ if inputs:
226
+ body["inputs"] = inputs
227
+ if context:
228
+ body["context"] = context
229
+ if bindings:
230
+ body["bindings"] = bindings
231
+ if instance_id:
232
+ body["instance_id"] = instance_id
233
+ if target_project_id:
234
+ body["target_project_id"] = target_project_id
235
+ if target_environment:
236
+ body["target_environment"] = target_environment
237
+ return self._authority_mutation_body(
238
+ body,
239
+ idempotency_key=idempotency_key,
240
+ initiator_type=initiator_type,
241
+ initiator_id=initiator_id,
242
+ )
243
+
89
244
  async def _api(
90
245
  self,
91
246
  endpoint: str,
@@ -172,6 +327,16 @@ class WorkflowNamespace:
172
327
  Returns:
173
328
  Dict with workflow metadata and optionally content
174
329
  """
330
+ if not include_content and self._is_authority_run_id(workflow_id):
331
+ return await self.get_run_state(workflow_id)
332
+ endpoint = self._workflow_lookup_endpoint(workflow_id, include_content=include_content)
333
+ return await self._api(endpoint=endpoint, method="GET")
334
+
335
+ async def get_workflow(
336
+ self,
337
+ workflow_id: str,
338
+ include_content: bool = False,
339
+ ) -> Dict[str, Any]:
175
340
  endpoint = self._workflow_lookup_endpoint(workflow_id, include_content=include_content)
176
341
  return await self._api(endpoint=endpoint, method="GET")
177
342
 
@@ -620,6 +785,410 @@ class WorkflowNamespace:
620
785
  body=body,
621
786
  )
622
787
 
788
+ async def ensure(
789
+ self,
790
+ workflow_id: str,
791
+ *,
792
+ instance_id: Optional[str] = None,
793
+ group: Optional[str] = None,
794
+ owner: Optional[str] = None,
795
+ environment: Optional[str] = None,
796
+ nudge_policy: Optional[Dict[str, Any]] = None,
797
+ bindings: Optional[Dict[str, Any]] = None,
798
+ mode: str = "blocking",
799
+ context: Optional[Dict[str, Any]] = None,
800
+ workflow_ref: Optional[str] = None,
801
+ subject: Optional[str] = None,
802
+ company: Optional[str] = None,
803
+ inputs: Optional[Dict[str, Any]] = None,
804
+ target_project_id: Optional[str] = None,
805
+ target_environment: Optional[str] = None,
806
+ initiator_type: Optional[str] = None,
807
+ initiator_id: Optional[str] = None,
808
+ idempotency_key: Optional[str] = None,
809
+ ) -> Dict[str, Any]:
810
+ """Ensure a workflow instance exists and optionally launch execution."""
811
+ if (
812
+ subject is not None
813
+ or company is not None
814
+ or inputs is not None
815
+ or workflow_ref is not None
816
+ or target_project_id is not None
817
+ or target_environment is not None
818
+ or initiator_type is not None
819
+ or initiator_id is not None
820
+ or idempotency_key is not None
821
+ or group is None
822
+ or owner is None
823
+ ):
824
+ return await self._api(
825
+ endpoint="/api/authority/runs/ensure",
826
+ body=self._build_authority_ensure_body(
827
+ workflow_id,
828
+ workflow_ref=workflow_ref,
829
+ subject=subject,
830
+ company=company,
831
+ inputs=inputs,
832
+ context=context,
833
+ bindings=bindings,
834
+ instance_id=instance_id,
835
+ mode=mode,
836
+ target_project_id=target_project_id,
837
+ target_environment=target_environment,
838
+ initiator_type=initiator_type,
839
+ initiator_id=initiator_id,
840
+ idempotency_key=idempotency_key,
841
+ ),
842
+ )
843
+ return await self.ensure_instance(
844
+ workflow_id,
845
+ instance_id=instance_id,
846
+ group=group,
847
+ owner=owner,
848
+ environment=environment,
849
+ nudge_policy=nudge_policy,
850
+ bindings=bindings,
851
+ mode=mode,
852
+ context=context,
853
+ )
854
+
855
+ async def ensure_instance(
856
+ self,
857
+ workflow_id: str,
858
+ *,
859
+ instance_id: Optional[str] = None,
860
+ group: str,
861
+ owner: str,
862
+ environment: Optional[str] = None,
863
+ nudge_policy: Optional[Dict[str, Any]] = None,
864
+ bindings: Optional[Dict[str, Any]] = None,
865
+ mode: str = "blocking",
866
+ context: Optional[Dict[str, Any]] = None,
867
+ ) -> Dict[str, Any]:
868
+ """Ensure a workflow instance exists and optionally launch execution."""
869
+ if mode == "streaming":
870
+ raise ValueError("Use stream_ensure_instance() for streaming instance execution")
871
+
872
+ return await self._api(
873
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/ensure",
874
+ body=self._build_ensure_instance_body(
875
+ instance_id=instance_id,
876
+ group=group,
877
+ owner=owner,
878
+ environment=environment,
879
+ nudge_policy=nudge_policy,
880
+ bindings=bindings,
881
+ mode=mode,
882
+ context=context,
883
+ ),
884
+ )
885
+
886
+ async def stream_ensure_instance(
887
+ self,
888
+ workflow_id: str,
889
+ *,
890
+ instance_id: Optional[str] = None,
891
+ group: str,
892
+ owner: str,
893
+ environment: Optional[str] = None,
894
+ nudge_policy: Optional[Dict[str, Any]] = None,
895
+ bindings: Optional[Dict[str, Any]] = None,
896
+ context: Optional[Dict[str, Any]] = None,
897
+ on_chunk: Optional[Any] = None,
898
+ ):
899
+ """Ensure a workflow instance and stream execution events."""
900
+ async for chunk in self._client._stream_request(
901
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/ensure",
902
+ body=self._build_ensure_instance_body(
903
+ instance_id=instance_id,
904
+ group=group,
905
+ owner=owner,
906
+ environment=environment,
907
+ nudge_policy=nudge_policy,
908
+ bindings=bindings,
909
+ mode="streaming",
910
+ context=context,
911
+ ),
912
+ on_chunk=on_chunk,
913
+ use_gateway=True,
914
+ ):
915
+ yield chunk
916
+
917
+ async def get_instance_status(self, workflow_id: str, instance_id: str) -> Dict[str, Any]:
918
+ """Get workflow instance status."""
919
+ from urllib.parse import quote
920
+
921
+ return await self._api(
922
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/status",
923
+ method="GET",
924
+ )
925
+
926
+ async def list_instances(
927
+ self,
928
+ *,
929
+ workflow_id: Optional[str] = None,
930
+ group: Optional[str] = None,
931
+ owner: Optional[str] = None,
932
+ environment: Optional[str] = None,
933
+ status: Optional[str] = None,
934
+ limit: int = 50,
935
+ offset: int = 0,
936
+ ) -> List[Dict[str, Any]]:
937
+ """List workflow instances."""
938
+ params = [f"limit={limit}", f"offset={offset}"]
939
+ if workflow_id:
940
+ params.append(f"workflow_id={workflow_id}")
941
+ if group:
942
+ params.append(f"group={group}")
943
+ if owner:
944
+ params.append(f"owner={owner}")
945
+ if environment:
946
+ params.append(f"environment={environment}")
947
+ if status:
948
+ params.append(f"status={status}")
949
+
950
+ result = await self._api(
951
+ endpoint=f"/api/workflow/instances?{'&'.join(params)}",
952
+ method="GET",
953
+ )
954
+ return result.get("instances", result) if isinstance(result, dict) else result
955
+
956
+ async def list_runs(
957
+ self,
958
+ *,
959
+ workflow_id: Optional[str] = None,
960
+ subject: Optional[str] = None,
961
+ company: Optional[str] = None,
962
+ status: Optional[str] = None,
963
+ limit: int = 50,
964
+ offset: int = 0,
965
+ target_project_id: Optional[str] = None,
966
+ target_environment: Optional[str] = None,
967
+ ) -> List[Dict[str, Any]]:
968
+ params = [f"limit={limit}", f"offset={offset}"]
969
+ if workflow_id:
970
+ params.append(f"workflow_id={workflow_id}")
971
+ if subject:
972
+ params.append(f"owner={subject}")
973
+ if company:
974
+ params.append(f"group={company}")
975
+ if status:
976
+ params.append(f"status={status}")
977
+ if target_project_id:
978
+ params.append(f"target_project_id={target_project_id}")
979
+ if target_environment:
980
+ params.append(f"target_environment={target_environment}")
981
+
982
+ result = await self._api(
983
+ endpoint=f"/api/authority/runs?{'&'.join(params)}",
984
+ method="GET",
985
+ )
986
+ return result.get("runs", result) if isinstance(result, dict) else result
987
+
988
+ async def get_run_state(self, run_id: str) -> Dict[str, Any]:
989
+ return await self._api(
990
+ endpoint=f"/api/authority/runs/{run_id}",
991
+ method="GET",
992
+ )
993
+
994
+ async def get_run_timeline(self, run_id: str) -> Dict[str, Any]:
995
+ return await self._api(
996
+ endpoint=f"/api/authority/runs/{run_id}/timeline",
997
+ method="GET",
998
+ )
999
+
1000
+ async def get_instance_artifacts(self, workflow_id: str, instance_id: str) -> Dict[str, Any]:
1001
+ """Get workflow instance artifact addresses."""
1002
+ from urllib.parse import quote
1003
+
1004
+ return await self._api(
1005
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/artifacts",
1006
+ method="GET",
1007
+ )
1008
+
1009
+ async def get_instance_outputs(self, workflow_id: str, instance_id: str) -> Dict[str, Any]:
1010
+ """Get workflow instance accepted outputs."""
1011
+ from urllib.parse import quote
1012
+
1013
+ return await self._api(
1014
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/outputs",
1015
+ method="GET",
1016
+ )
1017
+
1018
+ async def get_instance_history(
1019
+ self,
1020
+ workflow_id: str,
1021
+ instance_id: str,
1022
+ *,
1023
+ limit: int = 50,
1024
+ offset: int = 0,
1025
+ ) -> Dict[str, Any]:
1026
+ """Get workflow instance execution history."""
1027
+ from urllib.parse import quote
1028
+
1029
+ return await self._api(
1030
+ endpoint=(
1031
+ f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/history"
1032
+ f"?limit={limit}&offset={offset}"
1033
+ ),
1034
+ method="GET",
1035
+ )
1036
+
1037
+ async def nudge(
1038
+ self,
1039
+ workflow_id: str,
1040
+ instance_id: Optional[str] = None,
1041
+ *,
1042
+ reason: str,
1043
+ trigger_artifact: Optional[str] = None,
1044
+ mode: str = "async",
1045
+ target_project_id: Optional[str] = None,
1046
+ target_environment: Optional[str] = None,
1047
+ initiator_type: Optional[str] = None,
1048
+ initiator_id: Optional[str] = None,
1049
+ idempotency_key: Optional[str] = None,
1050
+ ) -> Dict[str, Any]:
1051
+ """Nudge a workflow instance into a new execution."""
1052
+ if instance_id is None and self._is_authority_run_id(workflow_id):
1053
+ body: Dict[str, Any] = {"reason": reason, "mode": mode}
1054
+ if trigger_artifact:
1055
+ body["trigger_artifact"] = trigger_artifact
1056
+ if target_project_id:
1057
+ body["target_project_id"] = target_project_id
1058
+ if target_environment:
1059
+ body["target_environment"] = target_environment
1060
+ return await self._api(
1061
+ endpoint=f"/api/authority/runs/{workflow_id}/nudge",
1062
+ body=self._authority_mutation_body(
1063
+ body,
1064
+ idempotency_key=idempotency_key,
1065
+ initiator_type=initiator_type,
1066
+ initiator_id=initiator_id,
1067
+ ),
1068
+ )
1069
+ return await self.nudge_instance(
1070
+ workflow_id,
1071
+ instance_id,
1072
+ reason=reason,
1073
+ trigger_artifact=trigger_artifact,
1074
+ mode=mode,
1075
+ )
1076
+
1077
+ async def nudge_instance(
1078
+ self,
1079
+ workflow_id: str,
1080
+ instance_id: str,
1081
+ *,
1082
+ reason: str,
1083
+ trigger_artifact: Optional[str] = None,
1084
+ mode: str = "async",
1085
+ ) -> Dict[str, Any]:
1086
+ """Nudge a workflow instance into a new execution."""
1087
+ from urllib.parse import quote
1088
+
1089
+ if mode == "streaming":
1090
+ raise ValueError("Use stream_nudge_instance() for streaming instance execution")
1091
+
1092
+ body: Dict[str, Any] = {"reason": reason, "mode": mode}
1093
+ if trigger_artifact:
1094
+ body["trigger_artifact"] = trigger_artifact
1095
+
1096
+ return await self._api(
1097
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/nudge",
1098
+ body=body,
1099
+ )
1100
+
1101
+ async def stream_nudge_instance(
1102
+ self,
1103
+ workflow_id: str,
1104
+ instance_id: str,
1105
+ *,
1106
+ reason: str,
1107
+ trigger_artifact: Optional[str] = None,
1108
+ on_chunk: Optional[Any] = None,
1109
+ ):
1110
+ """Nudge a workflow instance and stream execution events."""
1111
+ from urllib.parse import quote
1112
+
1113
+ body: Dict[str, Any] = {"reason": reason, "mode": "streaming"}
1114
+ if trigger_artifact:
1115
+ body["trigger_artifact"] = trigger_artifact
1116
+
1117
+ async for chunk in self._client._stream_request(
1118
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/nudge",
1119
+ body=body,
1120
+ on_chunk=on_chunk,
1121
+ use_gateway=True,
1122
+ ):
1123
+ yield chunk
1124
+
1125
+ async def retry(
1126
+ self,
1127
+ workflow_id: str,
1128
+ instance_id: Optional[str] = None,
1129
+ *,
1130
+ reason: str = "retry",
1131
+ trigger_artifact: Optional[str] = None,
1132
+ mode: str = "async",
1133
+ target_project_id: Optional[str] = None,
1134
+ target_environment: Optional[str] = None,
1135
+ initiator_type: Optional[str] = None,
1136
+ initiator_id: Optional[str] = None,
1137
+ idempotency_key: Optional[str] = None,
1138
+ ) -> Dict[str, Any]:
1139
+ """Retry a workflow instance. Alias for nudge with a default retry reason."""
1140
+ if instance_id is None and self._is_authority_run_id(workflow_id):
1141
+ body: Dict[str, Any] = {"reason": reason, "mode": mode}
1142
+ if trigger_artifact:
1143
+ body["trigger_artifact"] = trigger_artifact
1144
+ if target_project_id:
1145
+ body["target_project_id"] = target_project_id
1146
+ if target_environment:
1147
+ body["target_environment"] = target_environment
1148
+ return await self._api(
1149
+ endpoint=f"/api/authority/runs/{workflow_id}/retry",
1150
+ body=self._authority_mutation_body(
1151
+ body,
1152
+ idempotency_key=idempotency_key,
1153
+ initiator_type=initiator_type,
1154
+ initiator_id=initiator_id,
1155
+ ),
1156
+ )
1157
+ return await self.retry_instance(
1158
+ workflow_id,
1159
+ instance_id,
1160
+ reason=reason,
1161
+ trigger_artifact=trigger_artifact,
1162
+ mode=mode,
1163
+ )
1164
+
1165
+ async def retry_instance(
1166
+ self,
1167
+ workflow_id: str,
1168
+ instance_id: str,
1169
+ *,
1170
+ reason: str = "retry",
1171
+ trigger_artifact: Optional[str] = None,
1172
+ mode: str = "async",
1173
+ ) -> Dict[str, Any]:
1174
+ """Retry a workflow instance. Alias for nudge with a default retry reason."""
1175
+ return await self.nudge_instance(
1176
+ workflow_id,
1177
+ instance_id,
1178
+ reason=reason,
1179
+ trigger_artifact=trigger_artifact,
1180
+ mode=mode,
1181
+ )
1182
+
1183
+ async def cancel_instance(self, workflow_id: str, instance_id: str) -> Dict[str, Any]:
1184
+ """Cancel a running workflow instance."""
1185
+ from urllib.parse import quote
1186
+
1187
+ return await self._api(
1188
+ endpoint=f"{self._instance_base_endpoint(workflow_id)}/{quote(instance_id, safe='')}/cancel",
1189
+ body={},
1190
+ )
1191
+
623
1192
  async def get_run(self, run_id: str) -> Dict[str, Any]:
624
1193
  """Get a saved-workflow run."""
625
1194
  return await self._api(
@@ -787,8 +1356,32 @@ class WorkflowNamespace:
787
1356
  )
788
1357
  return result.get("events", result) if isinstance(result, dict) else result
789
1358
 
790
- async def cancel(self, execution_id: str) -> Dict[str, Any]:
791
- """Cancel a saved-workflow execution."""
1359
+ async def cancel(
1360
+ self,
1361
+ execution_id: str,
1362
+ *,
1363
+ target_project_id: Optional[str] = None,
1364
+ target_environment: Optional[str] = None,
1365
+ initiator_type: Optional[str] = None,
1366
+ initiator_id: Optional[str] = None,
1367
+ idempotency_key: Optional[str] = None,
1368
+ ) -> Dict[str, Any]:
1369
+ """Cancel a saved-workflow execution or Authority-backed run."""
1370
+ if self._is_authority_run_id(execution_id):
1371
+ body: Dict[str, Any] = {}
1372
+ if target_project_id:
1373
+ body["target_project_id"] = target_project_id
1374
+ if target_environment:
1375
+ body["target_environment"] = target_environment
1376
+ return await self._api(
1377
+ endpoint=f"/api/authority/runs/{execution_id}/cancel",
1378
+ body=self._authority_mutation_body(
1379
+ body,
1380
+ idempotency_key=idempotency_key,
1381
+ initiator_type=initiator_type,
1382
+ initiator_id=initiator_id,
1383
+ ),
1384
+ )
792
1385
  return await self._api(
793
1386
  endpoint=f"/api/workflow/executions/{execution_id}/cancel",
794
1387
  body={},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dominus-sdk-python
3
- Version: 2.16.0
3
+ Version: 2.18.0
4
4
  Summary: Python SDK for the Dominus Orchestrator Platform
5
5
  Author-email: CareBridge Systems <dev@carebridge.io>
6
6
  License: Proprietary
@@ -49,7 +49,7 @@ Async Python SDK for CareBridge Dominus platform services. Routes calls through
49
49
  - Server-side, asyncio-first Python SDK (3.9+)
50
50
  - Namespace API with root-level shortcuts for common operations
51
51
  - Targets production Cloudflare Workers (gateway, JWT, logs) and Cloud Run (orchestrator)
52
- - Version: 2.16.0
52
+ - Version: 2.18.0
53
53
 
54
54
  ## Quick Start
55
55
 
@@ -89,7 +89,15 @@ async for chunk in dominus.ai.stream_agent(
89
89
  ):
90
90
  print(chunk.get("content", ""), end="", flush=True)
91
91
 
92
- # Saved workflow lifecycle through workflow-manager
92
+ # Preferred Authority-backed one-call lifecycle
93
+ execution = await dominus.workflow.ensure(
94
+ "wf://carebridge/report-cycle",
95
+ subject="PCM47474562",
96
+ company="summit-radiology",
97
+ inputs={"report_snapshot": "ar://carebridge/summit-radiology/production/snapshot/report-1"},
98
+ )
99
+
100
+ # Saved workflow lifecycle through workflow-manager when you need explicit artifacts
93
101
  run = await dominus.workflow.create_run(
94
102
  workflow_id="wf_saved_123",
95
103
  context={"report_ref": {"report_id": "r-1", "accession": "a-1", "session_number": "2"}},
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dominus-sdk-python"
7
- version = "2.16.0"
7
+ version = "2.18.0"
8
8
  description = "Python SDK for the Dominus Orchestrator Platform"
9
9
  readme = "README.md"
10
10
  license = {text = "Proprietary"}
@@ -1,4 +1,6 @@
1
1
  import pytest
2
+ import base64
3
+ import json
2
4
 
3
5
  from dominus.namespaces.ai import WorkflowSubNamespace
4
6
  from dominus.namespaces.workflow import WorkflowNamespace
@@ -53,6 +55,18 @@ class FakeClient:
53
55
  yield chunk
54
56
 
55
57
 
58
+ def authority_run_id(**overrides):
59
+ payload = {
60
+ "project_id": "proj-1",
61
+ "environment": "production",
62
+ "workflow_id": "wf-instance",
63
+ "instance_id": "inst-1",
64
+ }
65
+ payload.update(overrides)
66
+ encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
67
+ return encoded.rstrip("=")
68
+
69
+
56
70
  @pytest.mark.asyncio
57
71
  async def test_ai_workflow_create_run_requires_inline_definition():
58
72
  namespace = WorkflowSubNamespace(FakeClient())
@@ -253,6 +267,66 @@ async def test_saved_workflow_facade_lifecycle_contract_supports_processor_shape
253
267
  assert streamed == [{"_event": "workflow_start"}]
254
268
 
255
269
 
270
+ @pytest.mark.asyncio
271
+ async def test_workflow_ensure_supports_authority_one_call_lifecycle():
272
+ client = FakeClient()
273
+ namespace = WorkflowNamespace(client)
274
+
275
+ await namespace.ensure(
276
+ "wf://carebridge/report-cycle",
277
+ subject="PCM47474562",
278
+ company="summit-radiology",
279
+ inputs={"report_snapshot": "ar://artifact"},
280
+ target_project_id="proj-1",
281
+ target_environment="production",
282
+ )
283
+
284
+ assert client.calls[0]["endpoint"] == "/api/authority/runs/ensure"
285
+ body = client.calls[0]["body"]
286
+ assert body["workflow_ref"] == "wf://carebridge/report-cycle"
287
+ assert body["subject"] == "PCM47474562"
288
+ assert body["company"] == "summit-radiology"
289
+ assert body["inputs"] == {"report_snapshot": "ar://artifact"}
290
+ assert body["target_project_id"] == "proj-1"
291
+ assert body["target_environment"] == "production"
292
+ assert isinstance(body["idempotency_key"], str) and body["idempotency_key"]
293
+
294
+
295
+ @pytest.mark.asyncio
296
+ async def test_workflow_get_detects_authority_run_ids():
297
+ client = FakeClient()
298
+ namespace = WorkflowNamespace(client)
299
+ run_id = authority_run_id()
300
+
301
+ await namespace.get(run_id)
302
+
303
+ assert client.calls[0]["endpoint"] == f"/api/authority/runs/{run_id}"
304
+ assert client.calls[0]["method"] == "GET"
305
+
306
+
307
+ @pytest.mark.asyncio
308
+ async def test_workflow_retry_nudge_and_cancel_support_authority_run_ids():
309
+ client = FakeClient()
310
+ namespace = WorkflowNamespace(client)
311
+ run_id = authority_run_id()
312
+
313
+ await namespace.retry(run_id, reason="manual-retry")
314
+ await namespace.nudge(run_id, reason="new-input", trigger_artifact="artifact-1")
315
+ await namespace.cancel(run_id)
316
+
317
+ assert [call["endpoint"] for call in client.calls] == [
318
+ f"/api/authority/runs/{run_id}/retry",
319
+ f"/api/authority/runs/{run_id}/nudge",
320
+ f"/api/authority/runs/{run_id}/cancel",
321
+ ]
322
+ assert client.calls[0]["body"]["reason"] == "manual-retry"
323
+ assert client.calls[1]["body"]["reason"] == "new-input"
324
+ assert client.calls[1]["body"]["trigger_artifact"] == "artifact-1"
325
+ assert isinstance(client.calls[0]["body"]["idempotency_key"], str)
326
+ assert isinstance(client.calls[1]["body"]["idempotency_key"], str)
327
+ assert isinstance(client.calls[2]["body"]["idempotency_key"], str)
328
+
329
+
256
330
  @pytest.mark.asyncio
257
331
  async def test_execute_pipeline_routes_to_native_pipeline_runner():
258
332
  client = FakeClient()
@@ -314,3 +388,228 @@ async def test_execute_pipeline_forwards_resume_execution_id():
314
388
 
315
389
  call = client.calls[0]
316
390
  assert call["body"]["config"]["resume_execution_id"] == "resume-1"
391
+
392
+
393
+ @pytest.mark.asyncio
394
+ async def test_saved_workflow_instance_ensure_contract_matches_workflow_manager():
395
+ client = FakeClient()
396
+ namespace = WorkflowNamespace(client)
397
+
398
+ await namespace.ensure_instance(
399
+ "wf-instance",
400
+ instance_id="inst-1",
401
+ group="reports",
402
+ owner="company:acme",
403
+ environment="production",
404
+ nudge_policy={
405
+ "mode": "coalesce",
406
+ "maxRerunCount": 3,
407
+ "rerunWindowSeconds": 120,
408
+ "cooldown_seconds": 15,
409
+ },
410
+ bindings={"subject": "PCM47474562"},
411
+ mode="async",
412
+ context={"company": "acme"},
413
+ )
414
+
415
+ call = client.calls[0]
416
+ assert call["endpoint"] == "/api/workflow/workflows/wf-instance/instances/ensure"
417
+ assert call["body"] == {
418
+ "instance_id": "inst-1",
419
+ "params": {
420
+ "group": "reports",
421
+ "owner": "company:acme",
422
+ "env": "production",
423
+ "nudge_policy": {
424
+ "mode": "coalesce",
425
+ "max_rerun_count": 3,
426
+ "rerun_window_seconds": 120,
427
+ "cooldown_seconds": 15,
428
+ },
429
+ },
430
+ "bindings": {"subject": "PCM47474562"},
431
+ "mode": "async",
432
+ "context": {"company": "acme"},
433
+ }
434
+
435
+
436
+ @pytest.mark.asyncio
437
+ async def test_saved_workflow_instance_ensure_alias_and_stream_guard():
438
+ client = FakeClient()
439
+ namespace = WorkflowNamespace(client)
440
+
441
+ await namespace.ensure(
442
+ "wf-instance",
443
+ group="reports",
444
+ owner="company:acme",
445
+ mode="ensure_only",
446
+ )
447
+
448
+ call = client.calls[0]
449
+ assert call["endpoint"] == "/api/workflow/workflows/wf-instance/instances/ensure"
450
+ assert call["body"] == {
451
+ "params": {
452
+ "group": "reports",
453
+ "owner": "company:acme",
454
+ },
455
+ "mode": "ensure_only",
456
+ }
457
+
458
+ with pytest.raises(ValueError, match="Use stream_ensure_instance\\(\\) for streaming instance execution"):
459
+ await namespace.ensure_instance(
460
+ "wf-instance",
461
+ group="reports",
462
+ owner="company:acme",
463
+ mode="streaming",
464
+ )
465
+
466
+
467
+ @pytest.mark.asyncio
468
+ async def test_stream_ensure_instance_uses_streaming_mode():
469
+ client = FakeClient(stream_chunks=[{"_event": "instance_ensured"}, {"_event": "workflow_complete"}])
470
+ namespace = WorkflowNamespace(client)
471
+
472
+ streamed = [
473
+ chunk
474
+ async for chunk in namespace.stream_ensure_instance(
475
+ "wf-instance",
476
+ group="reports",
477
+ owner="company:acme",
478
+ bindings={"subject": "PCM47474562"},
479
+ )
480
+ ]
481
+
482
+ assert client.stream_calls == [
483
+ {
484
+ "endpoint": "/api/workflow/workflows/wf-instance/instances/ensure",
485
+ "body": {
486
+ "params": {
487
+ "group": "reports",
488
+ "owner": "company:acme",
489
+ },
490
+ "bindings": {"subject": "PCM47474562"},
491
+ "mode": "streaming",
492
+ },
493
+ "timeout": 300.0,
494
+ "use_gateway": True,
495
+ }
496
+ ]
497
+ assert streamed == [{"_event": "instance_ensured"}, {"_event": "workflow_complete"}]
498
+
499
+
500
+ @pytest.mark.asyncio
501
+ async def test_workflow_instance_read_routes_match_contract():
502
+ client = FakeClient()
503
+ namespace = WorkflowNamespace(client)
504
+
505
+ await namespace.get_instance_status("wf-instance", "inst/1")
506
+ await namespace.list_instances(
507
+ workflow_id="wf-instance",
508
+ group="reports",
509
+ owner="company:acme",
510
+ environment="production",
511
+ status="idle",
512
+ limit=25,
513
+ offset=5,
514
+ )
515
+ await namespace.get_instance_artifacts("wf-instance", "inst-1")
516
+ await namespace.get_instance_outputs("wf-instance", "inst-1")
517
+ await namespace.get_instance_history("wf-instance", "inst-1", limit=10, offset=2)
518
+
519
+ assert client.calls[0]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst%2F1/status"
520
+ assert client.calls[0]["method"] == "GET"
521
+ assert client.calls[1]["endpoint"] == (
522
+ "/api/workflow/instances"
523
+ "?limit=25&offset=5&workflow_id=wf-instance&group=reports&owner=company:acme"
524
+ "&environment=production&status=idle"
525
+ )
526
+ assert client.calls[1]["method"] == "GET"
527
+ assert client.calls[2]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst-1/artifacts"
528
+ assert client.calls[2]["method"] == "GET"
529
+ assert client.calls[3]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst-1/outputs"
530
+ assert client.calls[3]["method"] == "GET"
531
+ assert client.calls[4]["endpoint"] == (
532
+ "/api/workflow/workflows/wf-instance/instances/inst-1/history?limit=10&offset=2"
533
+ )
534
+ assert client.calls[4]["method"] == "GET"
535
+
536
+
537
+ @pytest.mark.asyncio
538
+ async def test_workflow_instance_nudge_retry_and_cancel_contract():
539
+ client = FakeClient(stream_chunks=[{"_event": "instance_nudged"}])
540
+ namespace = WorkflowNamespace(client)
541
+
542
+ await namespace.nudge_instance(
543
+ "wf-instance",
544
+ "inst-1",
545
+ reason="artifact-updated",
546
+ trigger_artifact="report_snapshot",
547
+ mode="blocking",
548
+ )
549
+ await namespace.nudge(
550
+ "wf-instance",
551
+ "inst-2",
552
+ reason="manual-retry",
553
+ )
554
+ await namespace.retry_instance(
555
+ "wf-instance",
556
+ "inst-3",
557
+ trigger_artifact="report_snapshot",
558
+ mode="async",
559
+ )
560
+ await namespace.cancel_instance("wf-instance", "inst-4")
561
+
562
+ streamed = [
563
+ chunk
564
+ async for chunk in namespace.stream_nudge_instance(
565
+ "wf-instance",
566
+ "inst-5",
567
+ reason="artifact-updated",
568
+ trigger_artifact="report_snapshot",
569
+ )
570
+ ]
571
+
572
+ assert client.calls[0]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst-1/nudge"
573
+ assert client.calls[0]["body"] == {
574
+ "reason": "artifact-updated",
575
+ "trigger_artifact": "report_snapshot",
576
+ "mode": "blocking",
577
+ }
578
+
579
+ assert client.calls[1]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst-2/nudge"
580
+ assert client.calls[1]["body"] == {
581
+ "reason": "manual-retry",
582
+ "mode": "async",
583
+ }
584
+
585
+ assert client.calls[2]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst-3/nudge"
586
+ assert client.calls[2]["body"] == {
587
+ "reason": "retry",
588
+ "trigger_artifact": "report_snapshot",
589
+ "mode": "async",
590
+ }
591
+
592
+ assert client.calls[3]["endpoint"] == "/api/workflow/workflows/wf-instance/instances/inst-4/cancel"
593
+ assert client.calls[3]["body"] == {}
594
+
595
+ assert client.stream_calls == [
596
+ {
597
+ "endpoint": "/api/workflow/workflows/wf-instance/instances/inst-5/nudge",
598
+ "body": {
599
+ "reason": "artifact-updated",
600
+ "trigger_artifact": "report_snapshot",
601
+ "mode": "streaming",
602
+ },
603
+ "timeout": 300.0,
604
+ "use_gateway": True,
605
+ }
606
+ ]
607
+ assert streamed == [{"_event": "instance_nudged"}]
608
+
609
+ with pytest.raises(ValueError, match="Use stream_nudge_instance\\(\\) for streaming instance execution"):
610
+ await namespace.nudge_instance(
611
+ "wf-instance",
612
+ "inst-1",
613
+ reason="artifact-updated",
614
+ mode="streaming",
615
+ )