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,682 @@
1
+ """
2
+ Workflow execution engine.
3
+
4
+ The executor is responsible for:
5
+ - Starting new workflow runs
6
+ - Resuming existing runs
7
+ - Managing workflow lifecycle
8
+ - Coordinating with storage backend and runtimes
9
+
10
+ Supports multiple runtimes (local, celery) and durability modes (durable, transient).
11
+ """
12
+
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from loguru import logger
18
+
19
+ from pyworkflow.core.exceptions import (
20
+ ContinueAsNewSignal,
21
+ SuspensionSignal,
22
+ WorkflowAlreadyRunningError,
23
+ WorkflowNotFoundError,
24
+ )
25
+ from pyworkflow.core.registry import get_workflow_by_func
26
+ from pyworkflow.core.workflow import execute_workflow_with_context
27
+ from pyworkflow.engine.events import (
28
+ create_cancellation_requested_event,
29
+ create_workflow_cancelled_event,
30
+ create_workflow_continued_as_new_event,
31
+ )
32
+ from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
33
+ from pyworkflow.storage.base import StorageBackend
34
+ from pyworkflow.storage.schemas import RunStatus, WorkflowRun
35
+
36
+
37
+ class ConfigurationError(Exception):
38
+ """Configuration error for PyWorkflow."""
39
+
40
+ pass
41
+
42
+
43
+ async def start(
44
+ workflow_func: Callable,
45
+ *args: Any,
46
+ runtime: str | None = None,
47
+ durable: bool | None = None,
48
+ storage: StorageBackend | None = None,
49
+ idempotency_key: str | None = None,
50
+ **kwargs: Any,
51
+ ) -> str:
52
+ """
53
+ Start a new workflow execution.
54
+
55
+ The runtime and durability mode can be specified per-call, or will use
56
+ the configured defaults.
57
+
58
+ Args:
59
+ workflow_func: Workflow function decorated with @workflow
60
+ *args: Positional arguments for workflow
61
+ runtime: Runtime to use ("local", "celery", etc.) or None for default
62
+ durable: Whether workflow is durable (None = use workflow/config default)
63
+ storage: Storage backend instance (None = use configured storage)
64
+ idempotency_key: Optional key for idempotent execution
65
+ **kwargs: Keyword arguments for workflow
66
+
67
+ Returns:
68
+ run_id: Unique identifier for this workflow run
69
+
70
+ Examples:
71
+ # Basic usage (uses configured defaults)
72
+ run_id = await start(my_workflow, 42)
73
+
74
+ # Transient workflow (no persistence)
75
+ run_id = await start(my_workflow, 42, durable=False)
76
+
77
+ # Durable workflow with storage
78
+ run_id = await start(
79
+ my_workflow, 42,
80
+ durable=True,
81
+ storage=InMemoryStorageBackend()
82
+ )
83
+
84
+ # Explicit local runtime
85
+ run_id = await start(my_workflow, 42, runtime="local")
86
+
87
+ # With idempotency key
88
+ run_id = await start(
89
+ my_workflow, 42,
90
+ idempotency_key="unique-operation-id"
91
+ )
92
+ """
93
+ from pyworkflow.config import get_config
94
+ from pyworkflow.runtime import get_runtime, validate_runtime_durable
95
+
96
+ config = get_config()
97
+
98
+ # Get workflow metadata
99
+ workflow_meta = get_workflow_by_func(workflow_func)
100
+ if not workflow_meta:
101
+ raise ValueError(
102
+ f"Function {workflow_func.__name__} is not registered as a workflow. "
103
+ f"Did you forget the @workflow decorator?"
104
+ )
105
+
106
+ workflow_name = workflow_meta.name
107
+
108
+ # Resolve runtime
109
+ runtime_name = runtime or config.default_runtime
110
+ runtime_instance = get_runtime(runtime_name)
111
+
112
+ # Resolve durable flag (priority: call arg > decorator > config default)
113
+ workflow_durable = getattr(workflow_func, "__workflow_durable__", None)
114
+ effective_durable = (
115
+ durable
116
+ if durable is not None
117
+ else workflow_durable
118
+ if workflow_durable is not None
119
+ else config.default_durable
120
+ )
121
+
122
+ # Validate runtime + durable combination
123
+ validate_runtime_durable(runtime_instance, effective_durable)
124
+
125
+ # Resolve storage
126
+ effective_storage = storage or config.storage
127
+ if effective_durable and effective_storage is None:
128
+ raise ConfigurationError(
129
+ "Durable workflows require storage. Either:\n"
130
+ " 1. Pass storage=... to start()\n"
131
+ " 2. Configure globally via pyworkflow.configure(storage=...)\n"
132
+ " 3. Use durable=False for transient workflows"
133
+ )
134
+
135
+ # Check idempotency key (only for durable workflows with storage)
136
+ if idempotency_key and effective_durable and effective_storage:
137
+ existing_run = await effective_storage.get_run_by_idempotency_key(idempotency_key)
138
+ if existing_run:
139
+ if existing_run.status == RunStatus.RUNNING:
140
+ raise WorkflowAlreadyRunningError(existing_run.run_id)
141
+ logger.info(
142
+ f"Workflow with idempotency key '{idempotency_key}' already exists",
143
+ run_id=existing_run.run_id,
144
+ status=existing_run.status.value,
145
+ )
146
+ return existing_run.run_id
147
+
148
+ # Generate run_id
149
+ run_id = f"run_{uuid.uuid4().hex[:16]}"
150
+
151
+ logger.info(
152
+ f"Starting workflow: {workflow_name}",
153
+ run_id=run_id,
154
+ workflow_name=workflow_name,
155
+ runtime=runtime_name,
156
+ durable=effective_durable,
157
+ )
158
+
159
+ # Execute via runtime
160
+ return await runtime_instance.start_workflow(
161
+ workflow_func=workflow_meta.func,
162
+ args=args,
163
+ kwargs=kwargs,
164
+ run_id=run_id,
165
+ workflow_name=workflow_name,
166
+ storage=effective_storage,
167
+ durable=effective_durable,
168
+ idempotency_key=idempotency_key,
169
+ max_duration=workflow_meta.max_duration,
170
+ metadata={}, # Run-level metadata
171
+ )
172
+
173
+
174
+ async def resume(
175
+ run_id: str,
176
+ runtime: str | None = None,
177
+ storage: StorageBackend | None = None,
178
+ ) -> Any:
179
+ """
180
+ Resume a suspended workflow.
181
+
182
+ Args:
183
+ run_id: Workflow run identifier
184
+ runtime: Runtime to use (None = use configured default)
185
+ storage: Storage backend (None = use configured storage)
186
+
187
+ Returns:
188
+ Workflow result (if completed) or None (if suspended again)
189
+
190
+ Examples:
191
+ # Resume with configured defaults
192
+ result = await resume("run_abc123")
193
+
194
+ # Resume with explicit storage
195
+ result = await resume("run_abc123", storage=my_storage)
196
+ """
197
+ from pyworkflow.config import get_config
198
+ from pyworkflow.runtime import get_runtime
199
+
200
+ config = get_config()
201
+
202
+ # Resolve runtime and storage
203
+ runtime_name = runtime or config.default_runtime
204
+ runtime_instance = get_runtime(runtime_name)
205
+ effective_storage = storage or config.storage
206
+
207
+ if effective_storage is None:
208
+ raise ConfigurationError(
209
+ "Cannot resume workflow without storage. "
210
+ "Configure storage via pyworkflow.configure(storage=...) "
211
+ "or pass storage=... to resume()"
212
+ )
213
+
214
+ logger.info(
215
+ f"Resuming workflow: {run_id}",
216
+ run_id=run_id,
217
+ runtime=runtime_name,
218
+ )
219
+
220
+ return await runtime_instance.resume_workflow(
221
+ run_id=run_id,
222
+ storage=effective_storage,
223
+ )
224
+
225
+
226
+ # Internal functions for Celery tasks
227
+ # These execute workflows locally on workers
228
+
229
+
230
+ async def _execute_workflow_local(
231
+ workflow_func: Callable,
232
+ run_id: str,
233
+ workflow_name: str,
234
+ storage: StorageBackend,
235
+ args: tuple,
236
+ kwargs: dict,
237
+ event_log: list | None = None,
238
+ ) -> Any:
239
+ """
240
+ Execute workflow locally (used by Celery tasks).
241
+
242
+ This is an internal function called by Celery workers to execute
243
+ workflows. It handles the actual workflow execution with context.
244
+
245
+ Args:
246
+ workflow_func: Workflow function to execute
247
+ run_id: Workflow run ID
248
+ workflow_name: Workflow name
249
+ storage: Storage backend
250
+ args: Workflow arguments
251
+ kwargs: Workflow keyword arguments
252
+ event_log: Optional event log for replay
253
+
254
+ Returns:
255
+ Workflow result or None if suspended
256
+
257
+ Raises:
258
+ Exception: On workflow failure
259
+ """
260
+ try:
261
+ result = await execute_workflow_with_context(
262
+ workflow_func=workflow_func,
263
+ run_id=run_id,
264
+ workflow_name=workflow_name,
265
+ storage=storage,
266
+ args=args,
267
+ kwargs=kwargs,
268
+ event_log=event_log,
269
+ durable=True, # Celery tasks are always durable
270
+ )
271
+
272
+ # Update run status to completed
273
+ await storage.update_run_status(
274
+ run_id=run_id, status=RunStatus.COMPLETED, result=serialize_args(result)
275
+ )
276
+
277
+ logger.info(
278
+ f"Workflow completed successfully: {workflow_name}",
279
+ run_id=run_id,
280
+ workflow_name=workflow_name,
281
+ )
282
+
283
+ return result
284
+
285
+ except SuspensionSignal as e:
286
+ # Workflow suspended (sleep or hook)
287
+ await storage.update_run_status(run_id=run_id, status=RunStatus.SUSPENDED)
288
+
289
+ logger.info(
290
+ f"Workflow suspended: {e.reason}",
291
+ run_id=run_id,
292
+ workflow_name=workflow_name,
293
+ reason=e.reason,
294
+ )
295
+
296
+ return None
297
+
298
+ except ContinueAsNewSignal as e:
299
+ # Workflow continuing as new execution
300
+ new_run_id = await _handle_continue_as_new(
301
+ current_run_id=run_id,
302
+ workflow_func=workflow_func,
303
+ workflow_name=workflow_name,
304
+ storage=storage,
305
+ new_args=e.workflow_args,
306
+ new_kwargs=e.workflow_kwargs,
307
+ )
308
+
309
+ logger.info(
310
+ f"Workflow continued as new: {workflow_name}",
311
+ old_run_id=run_id,
312
+ new_run_id=new_run_id,
313
+ )
314
+
315
+ return None
316
+
317
+ except Exception as e:
318
+ # Workflow failed
319
+ await storage.update_run_status(run_id=run_id, status=RunStatus.FAILED, error=str(e))
320
+
321
+ logger.error(
322
+ f"Workflow failed: {workflow_name}",
323
+ run_id=run_id,
324
+ workflow_name=workflow_name,
325
+ error=str(e),
326
+ exc_info=True,
327
+ )
328
+
329
+ raise
330
+
331
+
332
+ async def _handle_continue_as_new(
333
+ current_run_id: str,
334
+ workflow_func: Callable,
335
+ workflow_name: str,
336
+ storage: StorageBackend,
337
+ new_args: tuple,
338
+ new_kwargs: dict,
339
+ ) -> str:
340
+ """
341
+ Handle continue-as-new by creating new run and linking it to current.
342
+
343
+ This is an internal function that:
344
+ 1. Generates new run_id
345
+ 2. Records WORKFLOW_CONTINUED_AS_NEW event in current run
346
+ 3. Updates current run status to CONTINUED_AS_NEW
347
+ 4. Updates current run's continued_to_run_id
348
+ 5. Creates new WorkflowRun with continued_from_run_id
349
+ 6. Starts new workflow execution via runtime
350
+
351
+ Args:
352
+ current_run_id: The run ID of the current workflow
353
+ workflow_func: Workflow function
354
+ workflow_name: Workflow name
355
+ storage: Storage backend
356
+ new_args: Arguments for the new workflow
357
+ new_kwargs: Keyword arguments for the new workflow
358
+
359
+ Returns:
360
+ New run ID
361
+ """
362
+ from datetime import UTC, datetime
363
+
364
+ from pyworkflow.config import get_config
365
+ from pyworkflow.runtime import get_runtime
366
+
367
+ # Generate new run_id
368
+ new_run_id = f"run_{uuid.uuid4().hex[:16]}"
369
+
370
+ # Serialize arguments
371
+ args_json = serialize_args(*new_args)
372
+ kwargs_json = serialize_kwargs(**new_kwargs)
373
+
374
+ # Record continuation event in current run's log
375
+ continuation_event = create_workflow_continued_as_new_event(
376
+ run_id=current_run_id,
377
+ new_run_id=new_run_id,
378
+ args=args_json,
379
+ kwargs=kwargs_json,
380
+ )
381
+ await storage.record_event(continuation_event)
382
+
383
+ # Update current run status and link to new run
384
+ await storage.update_run_status(
385
+ run_id=current_run_id,
386
+ status=RunStatus.CONTINUED_AS_NEW,
387
+ )
388
+ await storage.update_run_continuation(
389
+ run_id=current_run_id,
390
+ continued_to_run_id=new_run_id,
391
+ )
392
+
393
+ # Get current run to copy metadata
394
+ current_run = await storage.get_run(current_run_id)
395
+ nesting_depth = current_run.nesting_depth if current_run else 0
396
+ parent_run_id = current_run.parent_run_id if current_run else None
397
+
398
+ # Create new workflow run linked to current
399
+ new_run = WorkflowRun(
400
+ run_id=new_run_id,
401
+ workflow_name=workflow_name,
402
+ status=RunStatus.PENDING,
403
+ created_at=datetime.now(UTC),
404
+ input_args=args_json,
405
+ input_kwargs=kwargs_json,
406
+ continued_from_run_id=current_run_id,
407
+ nesting_depth=nesting_depth,
408
+ parent_run_id=parent_run_id,
409
+ )
410
+ await storage.create_run(new_run)
411
+
412
+ # Start new workflow via runtime
413
+ config = get_config()
414
+ runtime = get_runtime(config.default_runtime)
415
+
416
+ # Trigger execution of the new run
417
+ await runtime.start_workflow(
418
+ workflow_func=workflow_func,
419
+ args=new_args,
420
+ kwargs=new_kwargs,
421
+ run_id=new_run_id,
422
+ workflow_name=workflow_name,
423
+ storage=storage,
424
+ durable=True,
425
+ )
426
+
427
+ return new_run_id
428
+
429
+
430
+ async def get_workflow_run(
431
+ run_id: str,
432
+ storage: StorageBackend | None = None,
433
+ ) -> WorkflowRun | None:
434
+ """
435
+ Get workflow run information.
436
+
437
+ Args:
438
+ run_id: Workflow run identifier
439
+ storage: Storage backend (defaults to configured storage or FileStorageBackend)
440
+
441
+ Returns:
442
+ WorkflowRun if found, None otherwise
443
+ """
444
+ if storage is None:
445
+ from pyworkflow.config import get_config
446
+
447
+ config = get_config()
448
+ storage = config.storage
449
+
450
+ if storage is None:
451
+ from pyworkflow.storage.file import FileStorageBackend
452
+
453
+ storage = FileStorageBackend()
454
+
455
+ return await storage.get_run(run_id)
456
+
457
+
458
+ async def get_workflow_events(
459
+ run_id: str,
460
+ storage: StorageBackend | None = None,
461
+ ) -> list:
462
+ """
463
+ Get all events for a workflow run.
464
+
465
+ Args:
466
+ run_id: Workflow run identifier
467
+ storage: Storage backend (defaults to configured storage or FileStorageBackend)
468
+
469
+ Returns:
470
+ List of events ordered by sequence
471
+ """
472
+ if storage is None:
473
+ from pyworkflow.config import get_config
474
+
475
+ config = get_config()
476
+ storage = config.storage
477
+
478
+ if storage is None:
479
+ from pyworkflow.storage.file import FileStorageBackend
480
+
481
+ storage = FileStorageBackend()
482
+
483
+ return await storage.get_events(run_id)
484
+
485
+
486
+ async def get_workflow_chain(
487
+ run_id: str,
488
+ storage: StorageBackend | None = None,
489
+ ) -> list[WorkflowRun]:
490
+ """
491
+ Get all workflow runs in a continue-as-new chain.
492
+
493
+ Given any run_id in a chain, returns all runs from the original
494
+ execution to the most recent continuation, ordered from oldest to newest.
495
+
496
+ Args:
497
+ run_id: Any run ID in the chain
498
+ storage: Storage backend (defaults to configured storage or FileStorageBackend)
499
+
500
+ Returns:
501
+ List of WorkflowRun ordered from oldest to newest in the chain
502
+
503
+ Examples:
504
+ # Get full history of a long-running polling workflow
505
+ chain = await get_workflow_chain("run_abc123")
506
+ print(f"Workflow has continued {len(chain) - 1} times")
507
+ for run in chain:
508
+ print(f" {run.run_id}: {run.status.value}")
509
+ """
510
+ if storage is None:
511
+ from pyworkflow.config import get_config
512
+
513
+ config = get_config()
514
+ storage = config.storage
515
+
516
+ if storage is None:
517
+ from pyworkflow.storage.file import FileStorageBackend
518
+
519
+ storage = FileStorageBackend()
520
+
521
+ return await storage.get_workflow_chain(run_id)
522
+
523
+
524
+ async def cancel_workflow(
525
+ run_id: str,
526
+ reason: str | None = None,
527
+ wait: bool = False,
528
+ timeout: float | None = None,
529
+ storage: StorageBackend | None = None,
530
+ ) -> bool:
531
+ """
532
+ Request cancellation of a workflow.
533
+
534
+ Cancellation is graceful - running workflows will be cancelled at the next
535
+ interruptible point (before a step, during sleep, etc.). The workflow can
536
+ catch CancellationError to perform cleanup operations.
537
+
538
+ For suspended workflows (sleeping or waiting for hook), the status is
539
+ immediately updated to CANCELLED and a cancellation flag is set for when
540
+ the workflow resumes.
541
+
542
+ For running workflows, a cancellation flag is set that will be detected
543
+ at the next cancellation check point.
544
+
545
+ Note:
546
+ Cancellation does NOT interrupt a step that is already executing.
547
+ If a step takes a long time, cancellation will only be detected after
548
+ the step completes. For long-running steps that need mid-execution
549
+ cancellation, call ``ctx.check_cancellation()`` periodically within
550
+ the step function.
551
+
552
+ Args:
553
+ run_id: Workflow run identifier
554
+ reason: Optional reason for cancellation
555
+ wait: If True, wait for workflow to reach terminal status
556
+ timeout: Maximum seconds to wait (only used if wait=True)
557
+ storage: Storage backend (defaults to configured storage)
558
+
559
+ Returns:
560
+ True if cancellation was initiated, False if workflow is already terminal
561
+
562
+ Raises:
563
+ WorkflowNotFoundError: If workflow run doesn't exist
564
+ TimeoutError: If wait=True and timeout is exceeded
565
+
566
+ Examples:
567
+ # Request cancellation
568
+ cancelled = await cancel_workflow("run_abc123")
569
+
570
+ # Request with reason
571
+ cancelled = await cancel_workflow(
572
+ "run_abc123",
573
+ reason="User requested cancellation"
574
+ )
575
+
576
+ # Wait for cancellation to complete
577
+ cancelled = await cancel_workflow(
578
+ "run_abc123",
579
+ wait=True,
580
+ timeout=30
581
+ )
582
+ """
583
+ import asyncio
584
+
585
+ # Resolve storage
586
+ if storage is None:
587
+ from pyworkflow.config import get_config
588
+
589
+ config = get_config()
590
+ storage = config.storage
591
+
592
+ if storage is None:
593
+ from pyworkflow.storage.file import FileStorageBackend
594
+
595
+ storage = FileStorageBackend()
596
+
597
+ # Get workflow run
598
+ run = await storage.get_run(run_id)
599
+ if run is None:
600
+ raise WorkflowNotFoundError(run_id)
601
+
602
+ # Check if already in terminal state
603
+ terminal_statuses = {
604
+ RunStatus.COMPLETED,
605
+ RunStatus.FAILED,
606
+ RunStatus.CANCELLED,
607
+ RunStatus.CONTINUED_AS_NEW,
608
+ }
609
+ if run.status in terminal_statuses:
610
+ logger.info(
611
+ f"Workflow already in terminal state: {run.status.value}",
612
+ run_id=run_id,
613
+ status=run.status.value,
614
+ )
615
+ return False
616
+
617
+ # Record cancellation requested event
618
+ cancellation_event = create_cancellation_requested_event(
619
+ run_id=run_id,
620
+ reason=reason,
621
+ requested_by="cancel_workflow",
622
+ )
623
+ await storage.record_event(cancellation_event)
624
+
625
+ logger.info(
626
+ "Cancellation requested for workflow",
627
+ run_id=run_id,
628
+ reason=reason,
629
+ current_status=run.status.value,
630
+ )
631
+
632
+ # Handle based on current status
633
+ if run.status == RunStatus.SUSPENDED:
634
+ # For suspended workflows, update status to CANCELLED immediately
635
+ # The workflow will see cancellation when it tries to resume
636
+ cancelled_event = create_workflow_cancelled_event(
637
+ run_id=run_id,
638
+ reason=reason,
639
+ cleanup_completed=False,
640
+ )
641
+ await storage.record_event(cancelled_event)
642
+ await storage.update_run_status(run_id=run_id, status=RunStatus.CANCELLED)
643
+
644
+ logger.info(
645
+ "Suspended workflow cancelled",
646
+ run_id=run_id,
647
+ )
648
+
649
+ elif run.status in {RunStatus.RUNNING, RunStatus.PENDING}:
650
+ # For running/pending workflows, set cancellation flag
651
+ # The workflow will detect this at the next check point
652
+ await storage.set_cancellation_flag(run_id)
653
+
654
+ logger.info(
655
+ "Cancellation flag set for running workflow",
656
+ run_id=run_id,
657
+ )
658
+
659
+ # Wait for terminal status if requested
660
+ if wait:
661
+ poll_interval = 0.5
662
+ elapsed = 0.0
663
+ effective_timeout = timeout or 60.0
664
+
665
+ while elapsed < effective_timeout:
666
+ run = await storage.get_run(run_id)
667
+ if run and run.status in terminal_statuses:
668
+ logger.info(
669
+ f"Workflow reached terminal state: {run.status.value}",
670
+ run_id=run_id,
671
+ status=run.status.value,
672
+ )
673
+ return True
674
+
675
+ await asyncio.sleep(poll_interval)
676
+ elapsed += poll_interval
677
+
678
+ raise TimeoutError(
679
+ f"Workflow {run_id} did not reach terminal state within {effective_timeout}s"
680
+ )
681
+
682
+ return True