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,428 @@
1
+ """
2
+ Integration tests for continue-as-new feature.
3
+
4
+ Tests cover:
5
+ - Workflow continues as new and new run executes
6
+ - Chain of multiple continuations tracked correctly
7
+ - Cancellation prevents continuation
8
+ - Error handling during continuation
9
+ - get_workflow_chain() function
10
+ """
11
+
12
+ from datetime import UTC, datetime
13
+
14
+ import pytest
15
+
16
+ from pyworkflow import (
17
+ CancellationError,
18
+ RunStatus,
19
+ continue_as_new,
20
+ get_workflow_chain,
21
+ )
22
+ from pyworkflow.config import configure, reset_config
23
+ from pyworkflow.context import LocalContext, set_context
24
+ from pyworkflow.core.exceptions import ContinueAsNewSignal
25
+ from pyworkflow.engine.events import EventType
26
+ from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
27
+ from pyworkflow.storage.memory import InMemoryStorageBackend
28
+ from pyworkflow.storage.schemas import WorkflowRun
29
+
30
+
31
+ @pytest.fixture
32
+ def storage():
33
+ """Create in-memory storage for tests."""
34
+ return InMemoryStorageBackend()
35
+
36
+
37
+ @pytest.fixture(autouse=True)
38
+ def setup_config(storage):
39
+ """Configure pyworkflow with in-memory storage."""
40
+ configure(storage=storage, default_durable=True)
41
+ yield
42
+ reset_config()
43
+
44
+
45
+ class TestContinueAsNewExecution:
46
+ """Test continue_as_new execution flow."""
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_continue_as_new_raises_signal(self, storage):
50
+ """Test that continue_as_new raises ContinueAsNewSignal."""
51
+ # Execute workflow - it should raise ContinueAsNewSignal
52
+ ctx = LocalContext(
53
+ run_id="run_1",
54
+ workflow_name="counter_workflow",
55
+ storage=storage,
56
+ durable=True,
57
+ )
58
+ set_context(ctx)
59
+
60
+ try:
61
+ with pytest.raises(ContinueAsNewSignal) as exc_info:
62
+ continue_as_new(count=2)
63
+
64
+ # Check the signal has correct args
65
+ assert exc_info.value.workflow_kwargs == {"count": 2}
66
+ finally:
67
+ set_context(None)
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_storage_links_runs_correctly(self, storage):
71
+ """Test that storage properly links continuation runs."""
72
+ # Create initial run
73
+ run1 = WorkflowRun(
74
+ run_id="run_1",
75
+ workflow_name="my_workflow",
76
+ status=RunStatus.CONTINUED_AS_NEW,
77
+ )
78
+ await storage.create_run(run1)
79
+
80
+ # Create continuation run with link
81
+ run2 = WorkflowRun(
82
+ run_id="run_2",
83
+ workflow_name="my_workflow",
84
+ status=RunStatus.RUNNING,
85
+ continued_from_run_id="run_1",
86
+ )
87
+ await storage.create_run(run2)
88
+
89
+ # Update old run to point to new
90
+ await storage.update_run_continuation("run_1", "run_2")
91
+
92
+ # Check old run is updated
93
+ old_run = await storage.get_run("run_1")
94
+ assert old_run.status == RunStatus.CONTINUED_AS_NEW
95
+ assert old_run.continued_to_run_id == "run_2"
96
+
97
+ # Check new run is linked back
98
+ new_run = await storage.get_run("run_2")
99
+ assert new_run is not None
100
+ assert new_run.continued_from_run_id == "run_1"
101
+ assert new_run.workflow_name == "my_workflow"
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_continuation_event_recorded(self, storage):
105
+ """Test that WORKFLOW_CONTINUED_AS_NEW event is recorded."""
106
+ from pyworkflow.engine.events import create_workflow_continued_as_new_event
107
+
108
+ # Create initial run
109
+ run = WorkflowRun(
110
+ run_id="run_1",
111
+ workflow_name="my_workflow",
112
+ status=RunStatus.RUNNING,
113
+ )
114
+ await storage.create_run(run)
115
+
116
+ # Record continuation event manually (simulating what executor does)
117
+ continuation_event = create_workflow_continued_as_new_event(
118
+ run_id="run_1",
119
+ new_run_id="run_2",
120
+ args=serialize_args(42),
121
+ kwargs=serialize_kwargs(key="value"),
122
+ )
123
+ await storage.record_event(continuation_event)
124
+
125
+ # Check event was recorded
126
+ events = await storage.get_events("run_1")
127
+ continuation_events = [e for e in events if e.type == EventType.WORKFLOW_CONTINUED_AS_NEW]
128
+ assert len(continuation_events) == 1
129
+
130
+ event = continuation_events[0]
131
+ assert event.data["new_run_id"] == "run_2"
132
+ assert event.data["args"] == serialize_args(42)
133
+ assert event.data["kwargs"] == serialize_kwargs(key="value")
134
+
135
+
136
+ class TestWorkflowChain:
137
+ """Test workflow chain tracking."""
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_get_workflow_chain_returns_ordered_list(self, storage):
141
+ """Test get_workflow_chain returns runs in order."""
142
+ # Create a chain of runs: run_1 -> run_2 -> run_3
143
+ run1 = WorkflowRun(
144
+ run_id="run_1",
145
+ workflow_name="my_workflow",
146
+ status=RunStatus.CONTINUED_AS_NEW,
147
+ created_at=datetime.now(UTC),
148
+ )
149
+ await storage.create_run(run1)
150
+
151
+ run2 = WorkflowRun(
152
+ run_id="run_2",
153
+ workflow_name="my_workflow",
154
+ status=RunStatus.CONTINUED_AS_NEW,
155
+ continued_from_run_id="run_1",
156
+ created_at=datetime.now(UTC),
157
+ )
158
+ await storage.create_run(run2)
159
+
160
+ run3 = WorkflowRun(
161
+ run_id="run_3",
162
+ workflow_name="my_workflow",
163
+ status=RunStatus.COMPLETED,
164
+ continued_from_run_id="run_2",
165
+ created_at=datetime.now(UTC),
166
+ )
167
+ await storage.create_run(run3)
168
+
169
+ # Link runs
170
+ await storage.update_run_continuation("run_1", "run_2")
171
+ await storage.update_run_continuation("run_2", "run_3")
172
+
173
+ # Query chain from any run
174
+ chain = await get_workflow_chain("run_2", storage=storage)
175
+
176
+ assert len(chain) == 3
177
+ assert [r.run_id for r in chain] == ["run_1", "run_2", "run_3"]
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_chain_from_first_run(self, storage):
181
+ """Test getting chain from first run returns full chain."""
182
+ # Create chain
183
+ run1 = WorkflowRun(
184
+ run_id="first",
185
+ workflow_name="my_workflow",
186
+ status=RunStatus.CONTINUED_AS_NEW,
187
+ )
188
+ await storage.create_run(run1)
189
+
190
+ run2 = WorkflowRun(
191
+ run_id="second",
192
+ workflow_name="my_workflow",
193
+ status=RunStatus.RUNNING,
194
+ continued_from_run_id="first",
195
+ )
196
+ await storage.create_run(run2)
197
+
198
+ await storage.update_run_continuation("first", "second")
199
+
200
+ chain = await get_workflow_chain("first", storage=storage)
201
+
202
+ assert len(chain) == 2
203
+ assert chain[0].run_id == "first"
204
+ assert chain[1].run_id == "second"
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_chain_from_last_run(self, storage):
208
+ """Test getting chain from last run returns full chain."""
209
+ # Create chain
210
+ run1 = WorkflowRun(
211
+ run_id="first",
212
+ workflow_name="my_workflow",
213
+ status=RunStatus.CONTINUED_AS_NEW,
214
+ )
215
+ await storage.create_run(run1)
216
+
217
+ run2 = WorkflowRun(
218
+ run_id="last",
219
+ workflow_name="my_workflow",
220
+ status=RunStatus.RUNNING,
221
+ continued_from_run_id="first",
222
+ )
223
+ await storage.create_run(run2)
224
+
225
+ await storage.update_run_continuation("first", "last")
226
+
227
+ chain = await get_workflow_chain("last", storage=storage)
228
+
229
+ assert len(chain) == 2
230
+ assert chain[0].run_id == "first"
231
+ assert chain[1].run_id == "last"
232
+
233
+
234
+ class TestCancellationPreventsContination:
235
+ """Test that cancellation prevents continue_as_new."""
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_cancelled_workflow_cannot_continue_as_new(self):
239
+ """Test that continue_as_new raises CancellationError when cancelled."""
240
+ ctx = LocalContext(
241
+ run_id="test_run",
242
+ workflow_name="test_workflow",
243
+ storage=None,
244
+ durable=False,
245
+ )
246
+ ctx.request_cancellation(reason="User cancelled")
247
+ set_context(ctx)
248
+
249
+ try:
250
+ # Should raise CancellationError, not ContinueAsNewSignal
251
+ with pytest.raises(CancellationError):
252
+ continue_as_new("arg1")
253
+ finally:
254
+ set_context(None)
255
+
256
+
257
+ class TestContinueAsNewWithArgs:
258
+ """Test continue_as_new with various argument patterns."""
259
+
260
+ @pytest.mark.asyncio
261
+ async def test_continue_with_positional_args(self):
262
+ """Test continue_as_new with positional args."""
263
+ ctx = LocalContext(
264
+ run_id="test_run",
265
+ workflow_name="test_workflow",
266
+ storage=None,
267
+ durable=False,
268
+ )
269
+ set_context(ctx)
270
+
271
+ try:
272
+ with pytest.raises(ContinueAsNewSignal) as exc_info:
273
+ continue_as_new("a", "b", "c")
274
+
275
+ assert exc_info.value.workflow_args == ("a", "b", "c")
276
+ assert exc_info.value.workflow_kwargs == {}
277
+ finally:
278
+ set_context(None)
279
+
280
+ @pytest.mark.asyncio
281
+ async def test_continue_with_keyword_args(self):
282
+ """Test continue_as_new with keyword args."""
283
+ ctx = LocalContext(
284
+ run_id="test_run",
285
+ workflow_name="test_workflow",
286
+ storage=None,
287
+ durable=False,
288
+ )
289
+ set_context(ctx)
290
+
291
+ try:
292
+ with pytest.raises(ContinueAsNewSignal) as exc_info:
293
+ continue_as_new(cursor="abc", limit=100)
294
+
295
+ assert exc_info.value.workflow_args == ()
296
+ assert exc_info.value.workflow_kwargs == {"cursor": "abc", "limit": 100}
297
+ finally:
298
+ set_context(None)
299
+
300
+ @pytest.mark.asyncio
301
+ async def test_continue_with_complex_args(self):
302
+ """Test continue_as_new with complex types."""
303
+ ctx = LocalContext(
304
+ run_id="test_run",
305
+ workflow_name="test_workflow",
306
+ storage=None,
307
+ durable=False,
308
+ )
309
+ set_context(ctx)
310
+
311
+ try:
312
+ complex_data = {"items": [1, 2, 3], "metadata": {"key": "value"}}
313
+
314
+ with pytest.raises(ContinueAsNewSignal) as exc_info:
315
+ continue_as_new(data=complex_data)
316
+
317
+ assert exc_info.value.workflow_kwargs["data"] == complex_data
318
+ finally:
319
+ set_context(None)
320
+
321
+
322
+ class TestFileStorageChain:
323
+ """Test chain methods with FileStorageBackend."""
324
+
325
+ @pytest.mark.asyncio
326
+ async def test_file_storage_workflow_chain(self, tmp_path):
327
+ """Test get_workflow_chain with FileStorageBackend."""
328
+ from pyworkflow.storage.file import FileStorageBackend
329
+
330
+ storage = FileStorageBackend(base_path=str(tmp_path / "workflow_data"))
331
+
332
+ # Create chain
333
+ run1 = WorkflowRun(
334
+ run_id="run_1",
335
+ workflow_name="my_workflow",
336
+ status=RunStatus.CONTINUED_AS_NEW,
337
+ )
338
+ await storage.create_run(run1)
339
+
340
+ run2 = WorkflowRun(
341
+ run_id="run_2",
342
+ workflow_name="my_workflow",
343
+ status=RunStatus.RUNNING,
344
+ continued_from_run_id="run_1",
345
+ )
346
+ await storage.create_run(run2)
347
+
348
+ await storage.update_run_continuation("run_1", "run_2")
349
+
350
+ # Get chain
351
+ chain = await storage.get_workflow_chain("run_2")
352
+
353
+ assert len(chain) == 2
354
+ assert chain[0].run_id == "run_1"
355
+ assert chain[1].run_id == "run_2"
356
+
357
+ @pytest.mark.asyncio
358
+ async def test_file_storage_update_continuation(self, tmp_path):
359
+ """Test update_run_continuation with FileStorageBackend."""
360
+ from pyworkflow.storage.file import FileStorageBackend
361
+
362
+ storage = FileStorageBackend(base_path=str(tmp_path / "workflow_data"))
363
+
364
+ # Create run
365
+ run = WorkflowRun(
366
+ run_id="run_1",
367
+ workflow_name="my_workflow",
368
+ status=RunStatus.CONTINUED_AS_NEW,
369
+ )
370
+ await storage.create_run(run)
371
+
372
+ # Update continuation
373
+ await storage.update_run_continuation("run_1", "run_2")
374
+
375
+ # Verify
376
+ updated_run = await storage.get_run("run_1")
377
+ assert updated_run.continued_to_run_id == "run_2"
378
+
379
+
380
+ class TestContinuedAsNewStatus:
381
+ """Test CONTINUED_AS_NEW status handling."""
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_continued_as_new_is_terminal(self, storage):
385
+ """Test that CONTINUED_AS_NEW is treated as terminal status."""
386
+ from pyworkflow import cancel_workflow
387
+
388
+ run = WorkflowRun(
389
+ run_id="run_1",
390
+ workflow_name="my_workflow",
391
+ status=RunStatus.CONTINUED_AS_NEW,
392
+ )
393
+ await storage.create_run(run)
394
+
395
+ # Trying to cancel should return False (terminal state)
396
+ result = await cancel_workflow("run_1", storage=storage)
397
+ assert result is False
398
+
399
+ @pytest.mark.asyncio
400
+ async def test_list_runs_includes_continued_as_new(self, storage):
401
+ """Test that list_runs can filter by CONTINUED_AS_NEW status."""
402
+ # Create runs with different statuses
403
+ run1 = WorkflowRun(
404
+ run_id="run_1",
405
+ workflow_name="my_workflow",
406
+ status=RunStatus.COMPLETED,
407
+ )
408
+ await storage.create_run(run1)
409
+
410
+ run2 = WorkflowRun(
411
+ run_id="run_2",
412
+ workflow_name="my_workflow",
413
+ status=RunStatus.CONTINUED_AS_NEW,
414
+ )
415
+ await storage.create_run(run2)
416
+
417
+ run3 = WorkflowRun(
418
+ run_id="run_3",
419
+ workflow_name="my_workflow",
420
+ status=RunStatus.CONTINUED_AS_NEW,
421
+ )
422
+ await storage.create_run(run3)
423
+
424
+ # Filter by CONTINUED_AS_NEW
425
+ runs, _ = await storage.list_runs(status=RunStatus.CONTINUED_AS_NEW)
426
+
427
+ assert len(runs) == 2
428
+ assert all(r.status == RunStatus.CONTINUED_AS_NEW for r in runs)