pyworkflow-engine 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,930 @@
1
+ """
2
+ LocalContext - In-process workflow execution with optional event sourcing.
3
+
4
+ This context runs workflows locally with support for:
5
+ - Durable mode: Event sourcing, checkpointing, suspend/resume
6
+ - Transient mode: Simple execution without persistence
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import json
14
+ from collections.abc import Awaitable, Callable
15
+ from datetime import UTC, datetime, timedelta
16
+ from typing import Any
17
+
18
+ from loguru import logger
19
+ from pydantic import BaseModel
20
+
21
+ from pyworkflow.context.base import StepFunction, WorkflowContext
22
+ from pyworkflow.core.exceptions import SuspensionSignal
23
+ from pyworkflow.utils.duration import parse_duration
24
+
25
+
26
+ class LocalContext(WorkflowContext):
27
+ """
28
+ Local execution context with optional event sourcing.
29
+
30
+ In durable mode:
31
+ - Steps are checkpointed to storage
32
+ - Sleeps suspend the workflow (can be resumed later)
33
+ - Hooks wait for external events
34
+
35
+ In transient mode:
36
+ - Steps execute directly
37
+ - Sleeps use asyncio.sleep
38
+ - No persistence
39
+
40
+ Example:
41
+ # Create durable context
42
+ ctx = LocalContext(
43
+ run_id="run_123",
44
+ workflow_name="order_workflow",
45
+ storage=FileStorageBackend("./data"),
46
+ durable=True,
47
+ )
48
+
49
+ # Or transient context
50
+ ctx = LocalContext(
51
+ run_id="run_456",
52
+ workflow_name="quick_task",
53
+ durable=False,
54
+ )
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ run_id: str = "local_run",
60
+ workflow_name: str = "local_workflow",
61
+ storage: Any | None = None,
62
+ durable: bool = True,
63
+ event_log: list[Any] | None = None,
64
+ ) -> None:
65
+ """
66
+ Initialize local context.
67
+
68
+ Args:
69
+ run_id: Unique identifier for this workflow run
70
+ workflow_name: Name of the workflow
71
+ storage: Storage backend for event sourcing (required for durable)
72
+ durable: Whether to use durable execution mode
73
+ event_log: Existing events for replay (when resuming)
74
+ """
75
+ super().__init__(run_id=run_id, workflow_name=workflow_name)
76
+ self._storage = storage
77
+ self._durable = durable and storage is not None
78
+ self._event_log = event_log or []
79
+
80
+ # Execution state
81
+ self._step_results: dict[str, Any] = {}
82
+ self._completed_sleeps: set[str] = set()
83
+ self._pending_sleeps: dict[str, Any] = {}
84
+ self._hook_results: dict[str, Any] = {}
85
+ self._pending_hooks: dict[str, Any] = {}
86
+ self._step_counter = 0
87
+ self._retry_states: dict[str, dict[str, Any]] = {}
88
+ self._is_replaying = False
89
+ self._last_warning_count: int = 0 # Track last event count for warning interval
90
+
91
+ # Cancellation state
92
+ self._cancellation_requested: bool = False
93
+ self._cancellation_blocked: bool = False
94
+ self._cancellation_reason: str | None = None
95
+
96
+ # Child workflow state
97
+ self._child_results: dict[str, dict[str, Any]] = {}
98
+ self._pending_children: dict[str, str] = {} # child_id -> child_run_id
99
+
100
+ # Replay state if resuming
101
+ if event_log:
102
+ self._is_replaying = True
103
+ self._replay_events(event_log)
104
+ self._is_replaying = False
105
+
106
+ def _replay_events(self, events: list[Any]) -> None:
107
+ """Replay events to restore state."""
108
+ from pyworkflow.engine.events import EventType
109
+ from pyworkflow.serialization.decoder import deserialize
110
+
111
+ for event in events:
112
+ if event.type == EventType.STEP_COMPLETED:
113
+ step_id = event.data.get("step_id")
114
+ result = deserialize(event.data.get("result"))
115
+ self._step_results[step_id] = result
116
+
117
+ elif event.type == EventType.SLEEP_COMPLETED:
118
+ sleep_id = event.data.get("sleep_id")
119
+ self._completed_sleeps.add(sleep_id)
120
+
121
+ elif event.type == EventType.HOOK_RECEIVED:
122
+ hook_id = event.data.get("hook_id")
123
+ payload = deserialize(event.data.get("payload"))
124
+ self._hook_results[hook_id] = payload
125
+
126
+ elif event.type == EventType.STEP_RETRYING:
127
+ step_id = event.data.get("step_id")
128
+ self._retry_states[step_id] = {
129
+ "step_id": step_id,
130
+ "current_attempt": event.data.get("attempt", 1),
131
+ "resume_at": event.data.get("resume_at"),
132
+ "max_retries": event.data.get("max_retries", 3),
133
+ "retry_delay": event.data.get("retry_strategy", "exponential"),
134
+ "last_error": event.data.get("error", ""),
135
+ }
136
+
137
+ elif event.type == EventType.CANCELLATION_REQUESTED:
138
+ self._cancellation_requested = True
139
+ self._cancellation_reason = event.data.get("reason")
140
+
141
+ # Child workflow events
142
+ elif event.type == EventType.CHILD_WORKFLOW_STARTED:
143
+ child_id = event.data.get("child_id")
144
+ child_run_id = event.data.get("child_run_id")
145
+ if child_id and child_run_id:
146
+ self._pending_children[child_id] = child_run_id
147
+
148
+ elif event.type == EventType.CHILD_WORKFLOW_COMPLETED:
149
+ child_id = event.data.get("child_id")
150
+ child_run_id = event.data.get("child_run_id")
151
+ result = deserialize(event.data.get("result"))
152
+ if child_id:
153
+ self._child_results[child_id] = {
154
+ "child_run_id": child_run_id,
155
+ "result": result,
156
+ "__failed__": False,
157
+ }
158
+ self._pending_children.pop(child_id, None)
159
+
160
+ elif event.type == EventType.CHILD_WORKFLOW_FAILED:
161
+ child_id = event.data.get("child_id")
162
+ child_run_id = event.data.get("child_run_id")
163
+ error = event.data.get("error")
164
+ error_type = event.data.get("error_type")
165
+ if child_id:
166
+ self._child_results[child_id] = {
167
+ "child_run_id": child_run_id,
168
+ "error": error,
169
+ "error_type": error_type,
170
+ "__failed__": True,
171
+ }
172
+ self._pending_children.pop(child_id, None)
173
+
174
+ elif event.type == EventType.CHILD_WORKFLOW_CANCELLED:
175
+ child_id = event.data.get("child_id")
176
+ child_run_id = event.data.get("child_run_id")
177
+ reason = event.data.get("reason")
178
+ if child_id:
179
+ self._child_results[child_id] = {
180
+ "child_run_id": child_run_id,
181
+ "error": f"Cancelled: {reason}",
182
+ "error_type": "CancellationError",
183
+ "__failed__": True,
184
+ }
185
+ self._pending_children.pop(child_id, None)
186
+
187
+ @property
188
+ def is_durable(self) -> bool:
189
+ return self._durable
190
+
191
+ @property
192
+ def storage(self) -> Any | None:
193
+ """Get the storage backend."""
194
+ return self._storage
195
+
196
+ def _get_storage(self) -> Any:
197
+ """Get storage backend, asserting it's not None (for durable mode)."""
198
+ assert self._storage is not None, "Storage not available in transient mode"
199
+ return self._storage
200
+
201
+ @property
202
+ def is_replaying(self) -> bool:
203
+ """Check if currently replaying events."""
204
+ return self._is_replaying
205
+
206
+ @is_replaying.setter
207
+ def is_replaying(self, value: bool) -> None:
208
+ """Set replay mode."""
209
+ self._is_replaying = value
210
+
211
+ # =========================================================================
212
+ # Step result caching (for @step decorator compatibility)
213
+ # =========================================================================
214
+
215
+ def should_execute_step(self, step_id: str) -> bool:
216
+ """Check if a step should be executed (not already cached)."""
217
+ return step_id not in self._step_results
218
+
219
+ def get_step_result(self, step_id: str) -> Any:
220
+ """Get cached step result."""
221
+ return self._step_results.get(step_id)
222
+
223
+ def cache_step_result(self, step_id: str, result: Any) -> None:
224
+ """Cache a step result."""
225
+ self._step_results[step_id] = result
226
+
227
+ # =========================================================================
228
+ # Retry state management (for @step decorator compatibility)
229
+ # =========================================================================
230
+
231
+ def get_retry_state(self, step_id: str) -> dict[str, Any] | None:
232
+ """Get retry state for a step."""
233
+ return self._retry_states.get(step_id)
234
+
235
+ def set_retry_state(
236
+ self,
237
+ step_id: str,
238
+ attempt: int,
239
+ resume_at: Any,
240
+ max_retries: int,
241
+ retry_delay: Any,
242
+ last_error: str,
243
+ ) -> None:
244
+ """Set retry state for a step."""
245
+ self._retry_states[step_id] = {
246
+ "step_id": step_id,
247
+ "current_attempt": attempt,
248
+ "resume_at": resume_at,
249
+ "max_retries": max_retries,
250
+ "retry_delay": retry_delay,
251
+ "last_error": last_error,
252
+ }
253
+
254
+ def clear_retry_state(self, step_id: str) -> None:
255
+ """Clear retry state for a step."""
256
+ self._retry_states.pop(step_id, None)
257
+
258
+ # =========================================================================
259
+ # Sleep state management (for @step decorator and EventReplayer compatibility)
260
+ # =========================================================================
261
+
262
+ @property
263
+ def pending_sleeps(self) -> dict[str, Any]:
264
+ """Get pending sleeps (sleep_id -> resume_at)."""
265
+ return self._pending_sleeps
266
+
267
+ def add_pending_sleep(self, sleep_id: str, resume_at: Any) -> None:
268
+ """Add a pending sleep."""
269
+ self._pending_sleeps[sleep_id] = resume_at
270
+
271
+ def mark_sleep_completed(self, sleep_id: str) -> None:
272
+ """Mark a sleep as completed."""
273
+ self._completed_sleeps.add(sleep_id)
274
+
275
+ def should_execute_sleep(self, sleep_id: str) -> bool:
276
+ """Check if a sleep should be executed (not already completed)."""
277
+ return sleep_id not in self._completed_sleeps
278
+
279
+ def is_sleep_completed(self, sleep_id: str) -> bool:
280
+ """Check if a sleep has been completed."""
281
+ return sleep_id in self._completed_sleeps
282
+
283
+ @property
284
+ def completed_sleeps(self) -> set[str]:
285
+ """Get the set of completed sleep IDs."""
286
+ return self._completed_sleeps
287
+
288
+ # =========================================================================
289
+ # Hook state management (for EventReplayer compatibility)
290
+ # =========================================================================
291
+
292
+ @property
293
+ def pending_hooks(self) -> dict[str, Any]:
294
+ """Get pending hooks."""
295
+ return self._pending_hooks
296
+
297
+ def add_pending_hook(self, hook_id: str, data: Any) -> None:
298
+ """Add a pending hook."""
299
+ self._pending_hooks[hook_id] = data
300
+
301
+ def cache_hook_result(self, hook_id: str, payload: Any) -> None:
302
+ """Cache a hook result."""
303
+ self._hook_results[hook_id] = payload
304
+
305
+ def has_hook_result(self, hook_id: str) -> bool:
306
+ """Check if a hook result exists."""
307
+ return hook_id in self._hook_results
308
+
309
+ def get_hook_result(self, hook_id: str) -> Any:
310
+ """Get a cached hook result."""
311
+ return self._hook_results.get(hook_id)
312
+
313
+ # =========================================================================
314
+ # Child workflow state management
315
+ # =========================================================================
316
+
317
+ @property
318
+ def pending_children(self) -> dict[str, str]:
319
+ """Get pending child workflows (child_id -> child_run_id)."""
320
+ return self._pending_children
321
+
322
+ @property
323
+ def child_results(self) -> dict[str, dict[str, Any]]:
324
+ """Get child workflow results."""
325
+ return self._child_results
326
+
327
+ def has_child_result(self, child_id: str) -> bool:
328
+ """Check if a child workflow result exists."""
329
+ return child_id in self._child_results
330
+
331
+ def get_child_result(self, child_id: str) -> dict[str, Any]:
332
+ """Get cached child workflow result."""
333
+ return self._child_results.get(child_id, {})
334
+
335
+ def cache_child_result(
336
+ self,
337
+ child_id: str,
338
+ child_run_id: str,
339
+ result: Any,
340
+ failed: bool = False,
341
+ error: str | None = None,
342
+ error_type: str | None = None,
343
+ ) -> None:
344
+ """
345
+ Cache a child workflow result.
346
+
347
+ Args:
348
+ child_id: Deterministic child identifier
349
+ child_run_id: The child workflow's run ID
350
+ result: The result (if successful)
351
+ failed: Whether the child failed
352
+ error: Error message (if failed)
353
+ error_type: Exception type (if failed)
354
+ """
355
+ if failed:
356
+ self._child_results[child_id] = {
357
+ "child_run_id": child_run_id,
358
+ "error": error,
359
+ "error_type": error_type,
360
+ "__failed__": True,
361
+ }
362
+ else:
363
+ self._child_results[child_id] = {
364
+ "child_run_id": child_run_id,
365
+ "result": result,
366
+ "__failed__": False,
367
+ }
368
+ self._pending_children.pop(child_id, None)
369
+
370
+ def add_pending_child(self, child_id: str, child_run_id: str) -> None:
371
+ """Add a pending child workflow."""
372
+ self._pending_children[child_id] = child_run_id
373
+
374
+ # =========================================================================
375
+ # Event log access (for EventReplayer compatibility)
376
+ # =========================================================================
377
+
378
+ @property
379
+ def event_log(self) -> list[Any]:
380
+ """Get the event log."""
381
+ return self._event_log
382
+
383
+ @event_log.setter
384
+ def event_log(self, events: list[Any]) -> None:
385
+ """Set the event log."""
386
+ self._event_log = events
387
+
388
+ @property
389
+ def step_results(self) -> dict[str, Any]:
390
+ """Get step results."""
391
+ return self._step_results
392
+
393
+ @property
394
+ def hook_results(self) -> dict[str, Any]:
395
+ """Get hook results."""
396
+ return self._hook_results
397
+
398
+ @property
399
+ def retry_state(self) -> dict[str, dict[str, Any]]:
400
+ """Get retry states."""
401
+ return self._retry_states
402
+
403
+ # =========================================================================
404
+ # Event limit validation
405
+ # =========================================================================
406
+
407
+ async def validate_event_limits(self) -> None:
408
+ """
409
+ Validate event count against configured soft/hard limits.
410
+
411
+ - Soft limit: Log warning, then every N events after
412
+ - Hard limit: Raise EventLimitExceededError
413
+
414
+ Called before recording new events to prevent runaway workflows.
415
+
416
+ Raises:
417
+ EventLimitExceededError: If event count exceeds hard limit
418
+ """
419
+ if not self._durable or self._storage is None:
420
+ return # Skip validation for transient mode
421
+
422
+ from pyworkflow.config import get_config
423
+ from pyworkflow.core.exceptions import EventLimitExceededError
424
+
425
+ config = get_config()
426
+
427
+ # Get current event count from storage
428
+ events = await self._get_storage().get_events(self._run_id)
429
+ event_count = len(events)
430
+
431
+ # Hard limit check - fail immediately
432
+ if event_count >= config.event_hard_limit:
433
+ raise EventLimitExceededError(self._run_id, event_count, config.event_hard_limit)
434
+
435
+ # Soft limit check with interval warnings
436
+ if event_count >= config.event_soft_limit:
437
+ # Calculate if we should log a warning
438
+ should_warn = (
439
+ self._last_warning_count == 0 # First warning
440
+ or event_count >= self._last_warning_count + config.event_warning_interval
441
+ )
442
+
443
+ if should_warn:
444
+ logger.warning(
445
+ f"Workflow approaching event limit: {event_count}/{config.event_hard_limit}",
446
+ run_id=self._run_id,
447
+ event_count=event_count,
448
+ soft_limit=config.event_soft_limit,
449
+ hard_limit=config.event_hard_limit,
450
+ )
451
+ self._last_warning_count = event_count
452
+
453
+ # =========================================================================
454
+ # Step execution
455
+ # =========================================================================
456
+
457
+ async def run(
458
+ self,
459
+ func: StepFunction,
460
+ *args: Any,
461
+ name: str | None = None,
462
+ **kwargs: Any,
463
+ ) -> Any:
464
+ """
465
+ Execute a step function.
466
+
467
+ In durable mode:
468
+ - Generates deterministic step ID
469
+ - Checks for cached result (replay)
470
+ - Records events to storage
471
+
472
+ In transient mode:
473
+ - Executes function directly
474
+
475
+ Args:
476
+ func: Step function to execute
477
+ *args: Arguments for the function
478
+ name: Optional step name
479
+ **kwargs: Keyword arguments for the function
480
+
481
+ Returns:
482
+ Result of the step function
483
+ """
484
+ step_name: str = name or getattr(func, "__name__", None) or "step"
485
+
486
+ if not self._durable:
487
+ # Transient mode - execute directly
488
+ logger.debug(f"[transient] Running step: {step_name}")
489
+ return await self._execute_func(func, *args, **kwargs)
490
+
491
+ # Durable mode - use event sourcing
492
+ step_id = self._generate_step_id(step_name, args, kwargs)
493
+
494
+ # Check if already completed (replay)
495
+ if step_id in self._step_results:
496
+ logger.debug(f"[replay] Step {step_name} already completed, using cached result")
497
+ return self._step_results[step_id]
498
+
499
+ # Record step start
500
+ await self._record_step_start(step_id, step_name, args, kwargs)
501
+
502
+ logger.info(f"Running step: {step_name}", run_id=self._run_id, step_id=step_id)
503
+
504
+ try:
505
+ # Execute the function
506
+ result = await self._execute_func(func, *args, **kwargs)
507
+
508
+ # Record completion
509
+ await self._record_step_complete(step_id, step_name, result)
510
+
511
+ # Cache result
512
+ self._step_results[step_id] = result
513
+
514
+ logger.info(f"Step completed: {step_name}", run_id=self._run_id, step_id=step_id)
515
+ return result
516
+
517
+ except Exception as e:
518
+ await self._record_step_failed(step_id, e)
519
+ raise
520
+
521
+ async def _execute_func(self, func: Callable, *args: Any, **kwargs: Any) -> Any:
522
+ """Execute a function, handling both sync and async."""
523
+ if asyncio.iscoroutinefunction(func):
524
+ return await func(*args, **kwargs)
525
+ return func(*args, **kwargs)
526
+
527
+ def _generate_step_id(self, step_name: str, args: tuple, kwargs: dict) -> str:
528
+ """Generate deterministic step ID."""
529
+ from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
530
+
531
+ args_str = serialize_args(*args)
532
+ kwargs_str = serialize_kwargs(**kwargs)
533
+ content = f"{step_name}:{args_str}:{kwargs_str}"
534
+ hash_hex = hashlib.sha256(content.encode()).hexdigest()[:16]
535
+ return f"step_{step_name}_{hash_hex}"
536
+
537
+ async def _record_step_start(
538
+ self, step_id: str, step_name: str, args: tuple, kwargs: dict
539
+ ) -> None:
540
+ """Record step started event."""
541
+ from pyworkflow.engine.events import create_step_started_event
542
+ from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
543
+
544
+ event = create_step_started_event(
545
+ run_id=self._run_id,
546
+ step_id=step_id,
547
+ step_name=step_name,
548
+ args=serialize_args(*args),
549
+ kwargs=serialize_kwargs(**kwargs),
550
+ attempt=1,
551
+ )
552
+ await self._get_storage().record_event(event)
553
+
554
+ async def _record_step_complete(self, step_id: str, step_name: str, result: Any) -> None:
555
+ """Record step completed event."""
556
+ from pyworkflow.engine.events import create_step_completed_event
557
+ from pyworkflow.serialization.encoder import serialize
558
+
559
+ event = create_step_completed_event(
560
+ run_id=self._run_id,
561
+ step_id=step_id,
562
+ result=serialize(result),
563
+ step_name=step_name,
564
+ )
565
+ await self._get_storage().record_event(event)
566
+
567
+ async def _record_step_failed(self, step_id: str, error: Exception) -> None:
568
+ """Record step failed event."""
569
+ from pyworkflow.engine.events import create_step_failed_event
570
+
571
+ event = create_step_failed_event(
572
+ run_id=self._run_id,
573
+ step_id=step_id,
574
+ error=str(error),
575
+ error_type=type(error).__name__,
576
+ is_retryable=True,
577
+ attempt=1,
578
+ )
579
+ await self._get_storage().record_event(event)
580
+
581
+ # =========================================================================
582
+ # Sleep
583
+ # =========================================================================
584
+
585
+ async def sleep(self, duration: str | int | float) -> None:
586
+ """
587
+ Sleep for the specified duration.
588
+
589
+ In durable mode:
590
+ - Records sleep event
591
+ - Raises SuspensionSignal to pause workflow
592
+ - Workflow can be resumed later
593
+
594
+ In transient mode:
595
+ - Uses asyncio.sleep
596
+
597
+ Args:
598
+ duration: Sleep duration (string like "5m" or seconds)
599
+ """
600
+ # Parse duration
601
+ duration_seconds = parse_duration(duration) if isinstance(duration, str) else int(duration)
602
+
603
+ if not self._durable:
604
+ # Transient mode - just sleep
605
+ logger.debug(f"[transient] Sleeping {duration_seconds}s")
606
+ await asyncio.sleep(duration_seconds)
607
+ return
608
+
609
+ # Check for cancellation before sleeping
610
+ self.check_cancellation()
611
+
612
+ # Durable mode - suspend workflow
613
+ sleep_id = self._generate_sleep_id(duration_seconds)
614
+
615
+ # Check if already completed (replay)
616
+ if sleep_id in self._completed_sleeps:
617
+ logger.debug(f"[replay] Sleep {sleep_id} already completed, skipping")
618
+ return
619
+
620
+ # Calculate resume time
621
+ resume_at = datetime.now(UTC).timestamp() + duration_seconds
622
+
623
+ # Check if we should resume now
624
+ if datetime.now(UTC).timestamp() >= resume_at:
625
+ logger.debug(f"Sleep {sleep_id} time elapsed, continuing")
626
+ self._completed_sleeps.add(sleep_id)
627
+ return
628
+
629
+ # Validate event limits before recording sleep event
630
+ await self.validate_event_limits()
631
+
632
+ # Record sleep started and suspend
633
+ await self._record_sleep_start(sleep_id, duration_seconds, resume_at)
634
+
635
+ logger.info(
636
+ f"Suspending workflow for {duration_seconds}s",
637
+ run_id=self._run_id,
638
+ sleep_id=sleep_id,
639
+ )
640
+
641
+ raise SuspensionSignal(
642
+ reason=f"sleep:{sleep_id}",
643
+ resume_at=datetime.fromtimestamp(resume_at, tz=UTC),
644
+ )
645
+
646
+ def _generate_sleep_id(self, duration_seconds: int) -> str:
647
+ """Generate deterministic sleep ID."""
648
+ self._step_counter += 1
649
+ return f"sleep_{self._step_counter}_{duration_seconds}s"
650
+
651
+ async def _record_sleep_start(
652
+ self, sleep_id: str, duration_seconds: int, resume_at: float
653
+ ) -> None:
654
+ """Record sleep started event."""
655
+ from pyworkflow.engine.events import create_sleep_started_event
656
+
657
+ event = create_sleep_started_event(
658
+ run_id=self._run_id,
659
+ sleep_id=sleep_id,
660
+ duration_seconds=duration_seconds,
661
+ resume_at=datetime.fromtimestamp(resume_at, tz=UTC),
662
+ )
663
+ await self._get_storage().record_event(event)
664
+
665
+ # =========================================================================
666
+ # Parallel execution
667
+ # =========================================================================
668
+
669
+ async def parallel(self, *tasks: Any) -> list[Any]:
670
+ """Execute multiple tasks in parallel."""
671
+ return list(await asyncio.gather(*tasks))
672
+
673
+ # =========================================================================
674
+ # External events (hooks)
675
+ # =========================================================================
676
+
677
+ async def wait_for_event(
678
+ self,
679
+ event_name: str,
680
+ timeout: str | int | None = None,
681
+ ) -> Any:
682
+ """
683
+ Wait for an external event.
684
+
685
+ In durable mode:
686
+ - Creates a hook
687
+ - Suspends workflow waiting for webhook
688
+ - Returns payload when webhook received
689
+
690
+ Args:
691
+ event_name: Name for the event/hook
692
+ timeout: Optional timeout
693
+
694
+ Returns:
695
+ Event payload
696
+ """
697
+ if not self._durable:
698
+ raise NotImplementedError("wait_for_event requires durable mode with storage")
699
+
700
+ hook_id = f"hook_{event_name}_{self._step_counter}"
701
+ self._step_counter += 1
702
+
703
+ # Check if already received (replay)
704
+ if hook_id in self._hook_results:
705
+ logger.debug(f"[replay] Hook {hook_id} already received")
706
+ return self._hook_results[hook_id]
707
+
708
+ # Record hook created and suspend
709
+ await self._record_hook_created(hook_id, event_name, timeout)
710
+
711
+ logger.info(
712
+ f"Waiting for event: {event_name}",
713
+ run_id=self._run_id,
714
+ hook_id=hook_id,
715
+ )
716
+
717
+ raise SuspensionSignal(
718
+ reason=f"hook:{hook_id}",
719
+ hook_id=hook_id,
720
+ )
721
+
722
+ async def _record_hook_created(
723
+ self, hook_id: str, event_name: str, timeout: str | int | None
724
+ ) -> None:
725
+ """Record hook created event."""
726
+ from pyworkflow.engine.events import create_hook_created_event
727
+
728
+ timeout_seconds = None
729
+ if timeout:
730
+ timeout_seconds = parse_duration(timeout) if isinstance(timeout, str) else int(timeout)
731
+
732
+ event = create_hook_created_event(
733
+ run_id=self._run_id,
734
+ hook_id=hook_id,
735
+ hook_name=event_name,
736
+ timeout_seconds=timeout_seconds,
737
+ )
738
+ await self._get_storage().record_event(event)
739
+
740
+ async def hook(
741
+ self,
742
+ name: str,
743
+ timeout: int | None = None,
744
+ on_created: Callable[[str], Awaitable[None]] | None = None,
745
+ payload_schema: type[BaseModel] | None = None,
746
+ ) -> Any:
747
+ """
748
+ Wait for an external event (webhook, approval, callback).
749
+
750
+ In durable mode:
751
+ - Generates hook_id and composite token (run_id:hook_id)
752
+ - Checks if already received (replay mode)
753
+ - Records HOOK_CREATED event (idempotency checked via events)
754
+ - Calls on_created callback with token (if provided)
755
+ - Raises SuspensionSignal to pause workflow
756
+
757
+ In transient mode:
758
+ - Raises NotImplementedError (hooks require durability)
759
+
760
+ Args:
761
+ name: Human-readable name for the hook
762
+ timeout: Optional timeout in seconds
763
+ on_created: Optional async callback called with token when hook is created
764
+ payload_schema: Optional Pydantic model class for payload validation
765
+
766
+ Returns:
767
+ Payload from resume_hook()
768
+
769
+ Raises:
770
+ NotImplementedError: If not in durable mode
771
+ """
772
+ if not self._durable:
773
+ raise NotImplementedError(
774
+ "hook() requires durable mode with storage. "
775
+ "Initialize LocalContext with durable=True and a storage backend."
776
+ )
777
+
778
+ # Check for cancellation before waiting for hook
779
+ self.check_cancellation()
780
+
781
+ # Generate deterministic hook_id
782
+ self._step_counter += 1
783
+ hook_id = f"hook_{name}_{self._step_counter}"
784
+
785
+ # Check if already received (replay mode)
786
+ if hook_id in self._hook_results:
787
+ logger.debug(f"[replay] Hook {hook_id} already received")
788
+ return self._hook_results[hook_id]
789
+
790
+ # Generate composite token: run_id:hook_id
791
+ from pyworkflow.primitives.resume_hook import create_hook_token
792
+
793
+ actual_token = create_hook_token(self._run_id, hook_id)
794
+
795
+ # Calculate expiration time
796
+ expires_at = None
797
+ if timeout:
798
+ expires_at = datetime.now(UTC) + timedelta(seconds=timeout)
799
+
800
+ # Validate event limits before recording hook event
801
+ await self.validate_event_limits()
802
+
803
+ # Record HOOK_CREATED event (this is the source of truth for hook existence)
804
+ from pyworkflow.engine.events import create_hook_created_event
805
+
806
+ event = create_hook_created_event(
807
+ run_id=self._run_id,
808
+ hook_id=hook_id,
809
+ hook_name=name,
810
+ token=actual_token,
811
+ timeout_seconds=timeout,
812
+ expires_at=expires_at,
813
+ )
814
+ await self._get_storage().record_event(event)
815
+
816
+ # Convert Pydantic model to JSON schema if provided
817
+ schema_json = None
818
+ if payload_schema is not None:
819
+ schema_json = json.dumps(payload_schema.model_json_schema())
820
+
821
+ # Create Hook record in storage for querying
822
+ from pyworkflow.storage.schemas import Hook
823
+
824
+ hook_record = Hook(
825
+ hook_id=hook_id,
826
+ run_id=self._run_id,
827
+ token=actual_token,
828
+ name=name,
829
+ expires_at=expires_at,
830
+ payload_schema=schema_json,
831
+ )
832
+ await self._get_storage().create_hook(hook_record)
833
+
834
+ # Track pending hook locally
835
+ self._pending_hooks[hook_id] = {
836
+ "token": actual_token,
837
+ "name": name,
838
+ "expires_at": expires_at.isoformat() if expires_at else None,
839
+ }
840
+
841
+ # Call on_created callback if provided (before suspension)
842
+ if on_created is not None:
843
+ await on_created(actual_token)
844
+
845
+ logger.info(
846
+ f"Waiting for hook: {name}",
847
+ run_id=self._run_id,
848
+ hook_id=hook_id,
849
+ token=actual_token,
850
+ )
851
+
852
+ raise SuspensionSignal(
853
+ reason=f"hook:{hook_id}",
854
+ hook_id=hook_id,
855
+ token=actual_token,
856
+ )
857
+
858
+ # =========================================================================
859
+ # Cancellation support
860
+ # =========================================================================
861
+
862
+ def is_cancellation_requested(self) -> bool:
863
+ """
864
+ Check if cancellation has been requested for this workflow.
865
+
866
+ Returns:
867
+ True if cancellation was requested, False otherwise
868
+ """
869
+ return self._cancellation_requested
870
+
871
+ def request_cancellation(self, reason: str | None = None) -> None:
872
+ """
873
+ Mark this workflow as cancelled.
874
+
875
+ This sets the cancellation flag. The workflow will raise
876
+ CancellationError at the next cancellation check point.
877
+
878
+ Args:
879
+ reason: Optional reason for cancellation
880
+ """
881
+ self._cancellation_requested = True
882
+ self._cancellation_reason = reason
883
+ logger.info(
884
+ "Cancellation requested for workflow",
885
+ run_id=self._run_id,
886
+ reason=reason,
887
+ )
888
+
889
+ def check_cancellation(self) -> None:
890
+ """
891
+ Check for cancellation and raise if requested.
892
+
893
+ This should be called at interruptible points (before steps,
894
+ during sleeps, etc.) to allow graceful cancellation.
895
+
896
+ Raises:
897
+ CancellationError: If cancellation was requested and not blocked
898
+ """
899
+ if self._cancellation_requested and not self._cancellation_blocked:
900
+ from pyworkflow.core.exceptions import CancellationError
901
+
902
+ logger.info(
903
+ "Cancellation check triggered - raising CancellationError",
904
+ run_id=self._run_id,
905
+ reason=self._cancellation_reason,
906
+ )
907
+ raise CancellationError(
908
+ message=f"Workflow was cancelled: {self._cancellation_reason or 'no reason provided'}",
909
+ reason=self._cancellation_reason,
910
+ )
911
+
912
+ @property
913
+ def cancellation_blocked(self) -> bool:
914
+ """
915
+ Check if cancellation is currently blocked (within a shield scope).
916
+
917
+ Returns:
918
+ True if cancellation is blocked, False otherwise
919
+ """
920
+ return self._cancellation_blocked
921
+
922
+ @property
923
+ def cancellation_reason(self) -> str | None:
924
+ """
925
+ Get the reason for cancellation, if any.
926
+
927
+ Returns:
928
+ The cancellation reason or None if not cancelled
929
+ """
930
+ return self._cancellation_reason