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,549 @@
1
+ """
2
+ In-memory storage backend for testing and transient workflows.
3
+
4
+ This backend stores all data in memory and is ideal for:
5
+ - Unit testing
6
+ - Transient workflows that don't need persistence
7
+ - Development and prototyping
8
+ - Ephemeral containers
9
+
10
+ Note: All data is lost when the process exits.
11
+ """
12
+
13
+ import threading
14
+ from datetime import UTC, datetime
15
+
16
+ from pyworkflow.engine.events import Event
17
+ from pyworkflow.storage.base import StorageBackend
18
+ from pyworkflow.storage.schemas import (
19
+ Hook,
20
+ HookStatus,
21
+ RunStatus,
22
+ Schedule,
23
+ ScheduleStatus,
24
+ StepExecution,
25
+ WorkflowRun,
26
+ )
27
+
28
+
29
+ class InMemoryStorageBackend(StorageBackend):
30
+ """
31
+ Thread-safe in-memory storage backend.
32
+
33
+ All data is stored in dictionaries and protected by a reentrant lock
34
+ for thread safety.
35
+
36
+ Example:
37
+ >>> storage = InMemoryStorageBackend()
38
+ >>> pyworkflow.configure(storage=storage)
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ """Initialize empty storage."""
43
+ self._runs: dict[str, WorkflowRun] = {}
44
+ self._events: dict[str, list[Event]] = {}
45
+ self._steps: dict[str, StepExecution] = {}
46
+ self._hooks: dict[str, Hook] = {}
47
+ self._schedules: dict[str, Schedule] = {}
48
+ self._idempotency_index: dict[str, str] = {} # key -> run_id
49
+ self._token_index: dict[str, str] = {} # token -> hook_id
50
+ self._cancellation_flags: dict[str, bool] = {} # run_id -> cancelled
51
+ self._lock = threading.RLock()
52
+ self._event_sequences: dict[str, int] = {} # run_id -> next sequence
53
+
54
+ # Workflow Run Operations
55
+
56
+ async def create_run(self, run: WorkflowRun) -> None:
57
+ """Create a new workflow run record."""
58
+ with self._lock:
59
+ if run.run_id in self._runs:
60
+ raise ValueError(f"Run {run.run_id} already exists")
61
+ self._runs[run.run_id] = run
62
+ self._events[run.run_id] = []
63
+ self._event_sequences[run.run_id] = 0
64
+ if run.idempotency_key:
65
+ self._idempotency_index[run.idempotency_key] = run.run_id
66
+
67
+ async def get_run(self, run_id: str) -> WorkflowRun | None:
68
+ """Retrieve a workflow run by ID."""
69
+ with self._lock:
70
+ return self._runs.get(run_id)
71
+
72
+ async def get_run_by_idempotency_key(self, key: str) -> WorkflowRun | None:
73
+ """Retrieve a workflow run by idempotency key."""
74
+ with self._lock:
75
+ run_id = self._idempotency_index.get(key)
76
+ if run_id:
77
+ return self._runs.get(run_id)
78
+ return None
79
+
80
+ async def update_run_status(
81
+ self,
82
+ run_id: str,
83
+ status: RunStatus,
84
+ result: str | None = None,
85
+ error: str | None = None,
86
+ ) -> None:
87
+ """Update workflow run status and optionally result/error."""
88
+ with self._lock:
89
+ run = self._runs.get(run_id)
90
+ if run:
91
+ run.status = status
92
+ run.updated_at = datetime.now(UTC)
93
+ if result is not None:
94
+ run.result = result
95
+ if error is not None:
96
+ run.error = error
97
+ if status == RunStatus.COMPLETED or status == RunStatus.FAILED:
98
+ run.completed_at = datetime.now(UTC)
99
+
100
+ async def update_run_recovery_attempts(
101
+ self,
102
+ run_id: str,
103
+ recovery_attempts: int,
104
+ ) -> None:
105
+ """Update the recovery attempts counter for a workflow run."""
106
+ with self._lock:
107
+ run = self._runs.get(run_id)
108
+ if run:
109
+ run.recovery_attempts = recovery_attempts
110
+ run.updated_at = datetime.now(UTC)
111
+
112
+ async def list_runs(
113
+ self,
114
+ query: str | None = None,
115
+ status: RunStatus | None = None,
116
+ start_time: datetime | None = None,
117
+ end_time: datetime | None = None,
118
+ limit: int = 100,
119
+ cursor: str | None = None,
120
+ ) -> tuple[list[WorkflowRun], str | None]:
121
+ """List workflow runs with optional filtering and cursor-based pagination."""
122
+ import json
123
+
124
+ with self._lock:
125
+ runs = list(self._runs.values())
126
+
127
+ # Filter by query (case-insensitive substring in workflow_name or input_kwargs)
128
+ if query:
129
+ query_lower = query.lower()
130
+ filtered_runs = []
131
+ for r in runs:
132
+ workflow_name_match = query_lower in r.workflow_name.lower()
133
+ input_kwargs_str = json.dumps(r.input_kwargs or {}).lower()
134
+ input_kwargs_match = query_lower in input_kwargs_str
135
+ if workflow_name_match or input_kwargs_match:
136
+ filtered_runs.append(r)
137
+ runs = filtered_runs
138
+
139
+ # Filter by status
140
+ if status:
141
+ runs = [r for r in runs if r.status == status]
142
+
143
+ # Filter by time range (based on started_at)
144
+ if start_time or end_time:
145
+ filtered_runs = []
146
+ for r in runs:
147
+ if r.started_at is None:
148
+ continue # Skip runs that haven't started
149
+ if start_time and r.started_at < start_time:
150
+ continue
151
+ if end_time and r.started_at >= end_time:
152
+ continue
153
+ filtered_runs.append(r)
154
+ runs = filtered_runs
155
+
156
+ # Sort by (created_at DESC, run_id DESC) for deterministic ordering
157
+ runs.sort(key=lambda r: (r.created_at, r.run_id), reverse=True)
158
+
159
+ # Apply cursor-based pagination
160
+ if cursor:
161
+ cursor_found = False
162
+ filtered_runs = []
163
+ for run in runs:
164
+ if cursor_found:
165
+ filtered_runs.append(run)
166
+ elif run.run_id == cursor:
167
+ cursor_found = True
168
+ runs = filtered_runs
169
+
170
+ # Apply limit and determine next_cursor
171
+ if len(runs) > limit:
172
+ result_runs = runs[:limit]
173
+ next_cursor = result_runs[-1].run_id if result_runs else None
174
+ else:
175
+ result_runs = runs[:limit]
176
+ next_cursor = None
177
+
178
+ return result_runs, next_cursor
179
+
180
+ # Event Log Operations
181
+
182
+ async def record_event(self, event: Event) -> None:
183
+ """Record an event to the append-only event log."""
184
+ with self._lock:
185
+ run_id = event.run_id
186
+ if run_id not in self._events:
187
+ self._events[run_id] = []
188
+ self._event_sequences[run_id] = 0
189
+
190
+ # Assign sequence number
191
+ event.sequence = self._event_sequences[run_id]
192
+ self._event_sequences[run_id] += 1
193
+
194
+ self._events[run_id].append(event)
195
+
196
+ async def get_events(
197
+ self,
198
+ run_id: str,
199
+ event_types: list[str] | None = None,
200
+ ) -> list[Event]:
201
+ """Retrieve all events for a workflow run, ordered by sequence."""
202
+ with self._lock:
203
+ events = list(self._events.get(run_id, []))
204
+
205
+ # Filter by event types
206
+ if event_types:
207
+ events = [e for e in events if e.type in event_types]
208
+
209
+ # Sort by sequence
210
+ events.sort(key=lambda e: e.sequence or 0)
211
+
212
+ return events
213
+
214
+ async def get_latest_event(
215
+ self,
216
+ run_id: str,
217
+ event_type: str | None = None,
218
+ ) -> Event | None:
219
+ """Get the latest event for a run, optionally filtered by type."""
220
+ with self._lock:
221
+ events = self._events.get(run_id, [])
222
+ if not events:
223
+ return None
224
+
225
+ # Filter by event type
226
+ if event_type:
227
+ events = [e for e in events if e.type.value == event_type]
228
+
229
+ if not events:
230
+ return None
231
+
232
+ # Return event with highest sequence
233
+ return max(events, key=lambda e: e.sequence or 0)
234
+
235
+ # Step Operations
236
+
237
+ async def create_step(self, step: StepExecution) -> None:
238
+ """Create a step execution record."""
239
+ with self._lock:
240
+ self._steps[step.step_id] = step
241
+
242
+ async def get_step(self, step_id: str) -> StepExecution | None:
243
+ """Retrieve a step execution by ID."""
244
+ with self._lock:
245
+ return self._steps.get(step_id)
246
+
247
+ async def update_step_status(
248
+ self,
249
+ step_id: str,
250
+ status: str,
251
+ result: str | None = None,
252
+ error: str | None = None,
253
+ ) -> None:
254
+ """Update step execution status."""
255
+ with self._lock:
256
+ step = self._steps.get(step_id)
257
+ if step:
258
+ from pyworkflow.storage.schemas import StepStatus
259
+
260
+ step.status = StepStatus(status)
261
+ step.updated_at = datetime.now(UTC)
262
+ if result is not None:
263
+ step.result = result
264
+ if error is not None:
265
+ step.error = error
266
+
267
+ async def list_steps(self, run_id: str) -> list[StepExecution]:
268
+ """List all steps for a workflow run."""
269
+ with self._lock:
270
+ return [s for s in self._steps.values() if s.run_id == run_id]
271
+
272
+ # Hook Operations
273
+
274
+ async def create_hook(self, hook: Hook) -> None:
275
+ """Create a hook record."""
276
+ with self._lock:
277
+ self._hooks[hook.hook_id] = hook
278
+ self._token_index[hook.token] = hook.hook_id
279
+
280
+ async def get_hook(self, hook_id: str) -> Hook | None:
281
+ """Retrieve a hook by ID."""
282
+ with self._lock:
283
+ return self._hooks.get(hook_id)
284
+
285
+ async def get_hook_by_token(self, token: str) -> Hook | None:
286
+ """Retrieve a hook by its token."""
287
+ with self._lock:
288
+ hook_id = self._token_index.get(token)
289
+ if hook_id:
290
+ return self._hooks.get(hook_id)
291
+ return None
292
+
293
+ async def update_hook_status(
294
+ self,
295
+ hook_id: str,
296
+ status: HookStatus,
297
+ payload: str | None = None,
298
+ ) -> None:
299
+ """Update hook status and optionally payload."""
300
+ with self._lock:
301
+ hook = self._hooks.get(hook_id)
302
+ if hook:
303
+ hook.status = status
304
+ if payload is not None:
305
+ hook.payload = payload
306
+ if status == HookStatus.RECEIVED:
307
+ hook.received_at = datetime.now(UTC)
308
+
309
+ async def list_hooks(
310
+ self,
311
+ run_id: str | None = None,
312
+ status: HookStatus | None = None,
313
+ limit: int = 100,
314
+ offset: int = 0,
315
+ ) -> list[Hook]:
316
+ """List hooks with optional filtering."""
317
+ with self._lock:
318
+ hooks = list(self._hooks.values())
319
+
320
+ # Filter by run_id
321
+ if run_id:
322
+ hooks = [h for h in hooks if h.run_id == run_id]
323
+
324
+ # Filter by status
325
+ if status:
326
+ hooks = [h for h in hooks if h.status == status]
327
+
328
+ # Sort by created_at descending
329
+ hooks.sort(key=lambda h: h.created_at, reverse=True)
330
+
331
+ # Apply pagination
332
+ return hooks[offset : offset + limit]
333
+
334
+ # Cancellation Flag Operations
335
+
336
+ async def set_cancellation_flag(self, run_id: str) -> None:
337
+ """Set a cancellation flag for a workflow run."""
338
+ with self._lock:
339
+ self._cancellation_flags[run_id] = True
340
+
341
+ async def check_cancellation_flag(self, run_id: str) -> bool:
342
+ """Check if a cancellation flag is set for a workflow run."""
343
+ with self._lock:
344
+ return self._cancellation_flags.get(run_id, False)
345
+
346
+ async def clear_cancellation_flag(self, run_id: str) -> None:
347
+ """Clear the cancellation flag for a workflow run."""
348
+ with self._lock:
349
+ self._cancellation_flags.pop(run_id, None)
350
+
351
+ # Continue-As-New Chain Operations
352
+
353
+ async def update_run_continuation(
354
+ self,
355
+ run_id: str,
356
+ continued_to_run_id: str,
357
+ ) -> None:
358
+ """Update the continuation link for a workflow run."""
359
+ with self._lock:
360
+ run = self._runs.get(run_id)
361
+ if run:
362
+ run.continued_to_run_id = continued_to_run_id
363
+ run.updated_at = datetime.now(UTC)
364
+
365
+ async def get_workflow_chain(
366
+ self,
367
+ run_id: str,
368
+ ) -> list[WorkflowRun]:
369
+ """Get all runs in a continue-as-new chain."""
370
+ with self._lock:
371
+ run = self._runs.get(run_id)
372
+ if not run:
373
+ return []
374
+
375
+ # Walk backwards to find the start of the chain
376
+ current = run
377
+ while current.continued_from_run_id:
378
+ prev = self._runs.get(current.continued_from_run_id)
379
+ if not prev:
380
+ break
381
+ current = prev
382
+
383
+ # Build chain from start to end
384
+ chain = [current]
385
+ while current.continued_to_run_id:
386
+ next_run = self._runs.get(current.continued_to_run_id)
387
+ if not next_run:
388
+ break
389
+ chain.append(next_run)
390
+ current = next_run
391
+
392
+ return chain
393
+
394
+ # Child Workflow Operations
395
+
396
+ async def get_children(
397
+ self,
398
+ parent_run_id: str,
399
+ status: RunStatus | None = None,
400
+ ) -> list[WorkflowRun]:
401
+ """Get all child workflow runs for a parent workflow."""
402
+ with self._lock:
403
+ children = [run for run in self._runs.values() if run.parent_run_id == parent_run_id]
404
+
405
+ if status:
406
+ children = [c for c in children if c.status == status]
407
+
408
+ # Sort by created_at
409
+ children.sort(key=lambda r: r.created_at)
410
+
411
+ return children
412
+
413
+ async def get_parent(self, run_id: str) -> WorkflowRun | None:
414
+ """Get the parent workflow run for a child workflow."""
415
+ with self._lock:
416
+ run = self._runs.get(run_id)
417
+ if run and run.parent_run_id:
418
+ return self._runs.get(run.parent_run_id)
419
+ return None
420
+
421
+ async def get_nesting_depth(self, run_id: str) -> int:
422
+ """Get the nesting depth for a workflow."""
423
+ with self._lock:
424
+ run = self._runs.get(run_id)
425
+ return run.nesting_depth if run else 0
426
+
427
+ # Schedule Operations
428
+
429
+ async def create_schedule(self, schedule: Schedule) -> None:
430
+ """Create a new schedule record."""
431
+ with self._lock:
432
+ if schedule.schedule_id in self._schedules:
433
+ raise ValueError(f"Schedule {schedule.schedule_id} already exists")
434
+ self._schedules[schedule.schedule_id] = schedule
435
+
436
+ async def get_schedule(self, schedule_id: str) -> Schedule | None:
437
+ """Retrieve a schedule by ID."""
438
+ with self._lock:
439
+ return self._schedules.get(schedule_id)
440
+
441
+ async def update_schedule(self, schedule: Schedule) -> None:
442
+ """Update an existing schedule."""
443
+ with self._lock:
444
+ if schedule.schedule_id not in self._schedules:
445
+ raise ValueError(f"Schedule {schedule.schedule_id} does not exist")
446
+ self._schedules[schedule.schedule_id] = schedule
447
+
448
+ async def delete_schedule(self, schedule_id: str) -> None:
449
+ """Mark a schedule as deleted (soft delete)."""
450
+ with self._lock:
451
+ if schedule_id not in self._schedules:
452
+ raise ValueError(f"Schedule {schedule_id} does not exist")
453
+ schedule = self._schedules[schedule_id]
454
+ schedule.status = ScheduleStatus.DELETED
455
+ schedule.updated_at = datetime.now(UTC)
456
+
457
+ async def list_schedules(
458
+ self,
459
+ workflow_name: str | None = None,
460
+ status: ScheduleStatus | None = None,
461
+ limit: int = 100,
462
+ offset: int = 0,
463
+ ) -> list[Schedule]:
464
+ """List schedules with optional filtering."""
465
+ with self._lock:
466
+ schedules = list(self._schedules.values())
467
+
468
+ # Apply filters
469
+ if workflow_name:
470
+ schedules = [s for s in schedules if s.workflow_name == workflow_name]
471
+ if status:
472
+ schedules = [s for s in schedules if s.status == status]
473
+
474
+ # Sort by created_at descending
475
+ schedules.sort(key=lambda s: s.created_at, reverse=True)
476
+
477
+ # Apply pagination
478
+ return schedules[offset : offset + limit]
479
+
480
+ async def get_due_schedules(self, now: datetime) -> list[Schedule]:
481
+ """Get all schedules that are due to run."""
482
+ with self._lock:
483
+ due_schedules = [
484
+ s
485
+ for s in self._schedules.values()
486
+ if s.status == ScheduleStatus.ACTIVE
487
+ and s.next_run_time is not None
488
+ and s.next_run_time <= now
489
+ ]
490
+
491
+ # Sort by next_run_time ascending
492
+ due_schedules.sort(key=lambda s: s.next_run_time) # type: ignore
493
+ return due_schedules
494
+
495
+ async def add_running_run(self, schedule_id: str, run_id: str) -> None:
496
+ """Add a run_id to the schedule's running_run_ids list."""
497
+ with self._lock:
498
+ if schedule_id not in self._schedules:
499
+ raise ValueError(f"Schedule {schedule_id} does not exist")
500
+ schedule = self._schedules[schedule_id]
501
+ if run_id not in schedule.running_run_ids:
502
+ schedule.running_run_ids.append(run_id)
503
+ schedule.updated_at = datetime.now(UTC)
504
+
505
+ async def remove_running_run(self, schedule_id: str, run_id: str) -> None:
506
+ """Remove a run_id from the schedule's running_run_ids list."""
507
+ with self._lock:
508
+ if schedule_id not in self._schedules:
509
+ raise ValueError(f"Schedule {schedule_id} does not exist")
510
+ schedule = self._schedules[schedule_id]
511
+ if run_id in schedule.running_run_ids:
512
+ schedule.running_run_ids.remove(run_id)
513
+ schedule.updated_at = datetime.now(UTC)
514
+
515
+ # Utility methods
516
+
517
+ def clear(self) -> None:
518
+ """
519
+ Clear all data from storage.
520
+
521
+ Useful for testing to reset state between tests.
522
+ """
523
+ with self._lock:
524
+ self._runs.clear()
525
+ self._events.clear()
526
+ self._steps.clear()
527
+ self._hooks.clear()
528
+ self._schedules.clear()
529
+ self._idempotency_index.clear()
530
+ self._token_index.clear()
531
+ self._cancellation_flags.clear()
532
+ self._event_sequences.clear()
533
+
534
+ def __len__(self) -> int:
535
+ """Return total number of workflow runs."""
536
+ with self._lock:
537
+ return len(self._runs)
538
+
539
+ def __repr__(self) -> str:
540
+ """Return string representation."""
541
+ with self._lock:
542
+ return (
543
+ f"InMemoryStorageBackend("
544
+ f"runs={len(self._runs)}, "
545
+ f"events={sum(len(e) for e in self._events.values())}, "
546
+ f"steps={len(self._steps)}, "
547
+ f"hooks={len(self._hooks)}, "
548
+ f"schedules={len(self._schedules)})"
549
+ )