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,1136 @@
1
+ """
2
+ SQLite storage backend using aiosqlite.
3
+
4
+ This backend stores workflow data in a single SQLite database file, suitable for:
5
+ - Development and testing
6
+ - Single-machine deployments
7
+ - Small to medium production workloads
8
+
9
+ Provides ACID guarantees and efficient querying with SQL indexes.
10
+ """
11
+
12
+ import json
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import aiosqlite
18
+
19
+ from pyworkflow.engine.events import Event, EventType
20
+ from pyworkflow.storage.base import StorageBackend
21
+ from pyworkflow.storage.schemas import (
22
+ Hook,
23
+ HookStatus,
24
+ OverlapPolicy,
25
+ RunStatus,
26
+ Schedule,
27
+ ScheduleSpec,
28
+ ScheduleStatus,
29
+ StepExecution,
30
+ WorkflowRun,
31
+ )
32
+
33
+
34
+ class SQLiteStorageBackend(StorageBackend):
35
+ """
36
+ SQLite storage backend using aiosqlite for async operations.
37
+
38
+ All workflow data is stored in a single SQLite database file with proper
39
+ indexes for efficient querying.
40
+ """
41
+
42
+ def __init__(self, db_path: str = "./pyworkflow_data/pyworkflow.db"):
43
+ """
44
+ Initialize SQLite storage backend.
45
+
46
+ Args:
47
+ db_path: Path to SQLite database file (will be created if doesn't exist)
48
+ """
49
+ self.db_path = Path(db_path)
50
+ # Ensure parent directory exists
51
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
52
+ self._db: aiosqlite.Connection | None = None
53
+ self._initialized = False
54
+
55
+ async def connect(self) -> None:
56
+ """Initialize connection and create tables if needed."""
57
+ if self._db is None:
58
+ self._db = await aiosqlite.connect(str(self.db_path))
59
+ # Enable foreign keys
60
+ await self._db.execute("PRAGMA foreign_keys = ON")
61
+ await self._db.commit()
62
+
63
+ if not self._initialized:
64
+ await self._initialize_schema()
65
+ self._initialized = True
66
+
67
+ async def disconnect(self) -> None:
68
+ """Close database connection."""
69
+ if self._db:
70
+ await self._db.close()
71
+ self._db = None
72
+ self._initialized = False
73
+
74
+ async def _initialize_schema(self) -> None:
75
+ """Create database tables if they don't exist."""
76
+ if not self._db:
77
+ await self.connect()
78
+
79
+ # At this point self._db is guaranteed to be set
80
+ assert self._db is not None
81
+ db = self._db
82
+
83
+ # Workflow runs table
84
+ await db.execute("""
85
+ CREATE TABLE IF NOT EXISTS workflow_runs (
86
+ run_id TEXT PRIMARY KEY,
87
+ workflow_name TEXT NOT NULL,
88
+ status TEXT NOT NULL,
89
+ created_at TIMESTAMP NOT NULL,
90
+ updated_at TIMESTAMP NOT NULL,
91
+ started_at TIMESTAMP,
92
+ completed_at TIMESTAMP,
93
+ input_args TEXT NOT NULL DEFAULT '[]',
94
+ input_kwargs TEXT NOT NULL DEFAULT '{}',
95
+ result TEXT,
96
+ error TEXT,
97
+ idempotency_key TEXT,
98
+ max_duration TEXT,
99
+ metadata TEXT DEFAULT '{}',
100
+ recovery_attempts INTEGER DEFAULT 0,
101
+ max_recovery_attempts INTEGER DEFAULT 3,
102
+ recover_on_worker_loss INTEGER DEFAULT 1,
103
+ parent_run_id TEXT,
104
+ nesting_depth INTEGER DEFAULT 0,
105
+ continued_from_run_id TEXT,
106
+ continued_to_run_id TEXT,
107
+ FOREIGN KEY (parent_run_id) REFERENCES workflow_runs(run_id)
108
+ )
109
+ """)
110
+
111
+ # Indexes for workflow_runs
112
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_runs_status ON workflow_runs(status)")
113
+ await db.execute(
114
+ "CREATE INDEX IF NOT EXISTS idx_runs_workflow_name ON workflow_runs(workflow_name)"
115
+ )
116
+ await db.execute(
117
+ "CREATE INDEX IF NOT EXISTS idx_runs_created_at ON workflow_runs(created_at DESC)"
118
+ )
119
+ await db.execute(
120
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_idempotency_key ON workflow_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
121
+ )
122
+ await db.execute(
123
+ "CREATE INDEX IF NOT EXISTS idx_runs_parent_run_id ON workflow_runs(parent_run_id)"
124
+ )
125
+
126
+ # Events table
127
+ await db.execute("""
128
+ CREATE TABLE IF NOT EXISTS events (
129
+ event_id TEXT PRIMARY KEY,
130
+ run_id TEXT NOT NULL,
131
+ sequence INTEGER NOT NULL,
132
+ type TEXT NOT NULL,
133
+ timestamp TIMESTAMP NOT NULL,
134
+ data TEXT NOT NULL DEFAULT '{}',
135
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) ON DELETE CASCADE
136
+ )
137
+ """)
138
+
139
+ # Indexes for events
140
+ await db.execute(
141
+ "CREATE INDEX IF NOT EXISTS idx_events_run_id_sequence ON events(run_id, sequence)"
142
+ )
143
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)")
144
+
145
+ # Steps table
146
+ await db.execute("""
147
+ CREATE TABLE IF NOT EXISTS steps (
148
+ step_id TEXT PRIMARY KEY,
149
+ run_id TEXT NOT NULL,
150
+ step_name TEXT NOT NULL,
151
+ status TEXT NOT NULL,
152
+ created_at TIMESTAMP NOT NULL,
153
+ started_at TIMESTAMP,
154
+ completed_at TIMESTAMP,
155
+ input_args TEXT NOT NULL DEFAULT '[]',
156
+ input_kwargs TEXT NOT NULL DEFAULT '{}',
157
+ result TEXT,
158
+ error TEXT,
159
+ retry_count INTEGER DEFAULT 0,
160
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) ON DELETE CASCADE
161
+ )
162
+ """)
163
+
164
+ # Indexes for steps
165
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_steps_run_id ON steps(run_id)")
166
+
167
+ # Hooks table
168
+ await db.execute("""
169
+ CREATE TABLE IF NOT EXISTS hooks (
170
+ hook_id TEXT PRIMARY KEY,
171
+ run_id TEXT NOT NULL,
172
+ token TEXT UNIQUE NOT NULL,
173
+ created_at TIMESTAMP NOT NULL,
174
+ received_at TIMESTAMP,
175
+ expires_at TIMESTAMP,
176
+ status TEXT NOT NULL,
177
+ payload TEXT,
178
+ metadata TEXT DEFAULT '{}',
179
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) ON DELETE CASCADE
180
+ )
181
+ """)
182
+
183
+ # Indexes for hooks
184
+ await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_hooks_token ON hooks(token)")
185
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_hooks_run_id ON hooks(run_id)")
186
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status)")
187
+
188
+ # Schedules table
189
+ await db.execute("""
190
+ CREATE TABLE IF NOT EXISTS schedules (
191
+ schedule_id TEXT PRIMARY KEY,
192
+ workflow_name TEXT NOT NULL,
193
+ spec TEXT NOT NULL,
194
+ spec_type TEXT NOT NULL,
195
+ timezone TEXT,
196
+ input_args TEXT NOT NULL DEFAULT '[]',
197
+ input_kwargs TEXT NOT NULL DEFAULT '{}',
198
+ status TEXT NOT NULL,
199
+ overlap_policy TEXT NOT NULL,
200
+ next_run_time TIMESTAMP,
201
+ last_run_time TIMESTAMP,
202
+ running_run_ids TEXT DEFAULT '[]',
203
+ metadata TEXT DEFAULT '{}',
204
+ created_at TIMESTAMP NOT NULL,
205
+ updated_at TIMESTAMP NOT NULL,
206
+ paused_at TIMESTAMP,
207
+ deleted_at TIMESTAMP
208
+ )
209
+ """)
210
+
211
+ # Indexes for schedules
212
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_schedules_status ON schedules(status)")
213
+ await db.execute(
214
+ "CREATE INDEX IF NOT EXISTS idx_schedules_next_run_time ON schedules(next_run_time)"
215
+ )
216
+ await db.execute(
217
+ "CREATE INDEX IF NOT EXISTS idx_schedules_workflow_name ON schedules(workflow_name)"
218
+ )
219
+
220
+ # Cancellation flags table (simple key-value for run cancellation)
221
+ await db.execute("""
222
+ CREATE TABLE IF NOT EXISTS cancellation_flags (
223
+ run_id TEXT PRIMARY KEY,
224
+ created_at TIMESTAMP NOT NULL,
225
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) ON DELETE CASCADE
226
+ )
227
+ """)
228
+
229
+ await db.commit()
230
+
231
+ def _ensure_connected(self) -> aiosqlite.Connection:
232
+ """Ensure database is connected."""
233
+ if not self._db:
234
+ raise RuntimeError("Database not connected. Call connect() first.")
235
+ return self._db
236
+
237
+ # Workflow Run Operations
238
+
239
+ async def create_run(self, run: WorkflowRun) -> None:
240
+ """Create a new workflow run record."""
241
+ db = self._ensure_connected()
242
+
243
+ await db.execute(
244
+ """
245
+ INSERT INTO workflow_runs (
246
+ run_id, workflow_name, status, created_at, updated_at, started_at,
247
+ completed_at, input_args, input_kwargs, result, error, idempotency_key,
248
+ max_duration, metadata, recovery_attempts, max_recovery_attempts,
249
+ recover_on_worker_loss, parent_run_id, nesting_depth,
250
+ continued_from_run_id, continued_to_run_id
251
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
252
+ """,
253
+ (
254
+ run.run_id,
255
+ run.workflow_name,
256
+ run.status.value,
257
+ run.created_at.isoformat(),
258
+ run.updated_at.isoformat(),
259
+ run.started_at.isoformat() if run.started_at else None,
260
+ run.completed_at.isoformat() if run.completed_at else None,
261
+ run.input_args,
262
+ run.input_kwargs,
263
+ run.result,
264
+ run.error,
265
+ run.idempotency_key,
266
+ run.max_duration,
267
+ json.dumps(run.metadata),
268
+ run.recovery_attempts,
269
+ run.max_recovery_attempts,
270
+ 1 if run.recover_on_worker_loss else 0,
271
+ run.parent_run_id,
272
+ run.nesting_depth,
273
+ run.continued_from_run_id,
274
+ run.continued_to_run_id,
275
+ ),
276
+ )
277
+ await db.commit()
278
+
279
+ async def get_run(self, run_id: str) -> WorkflowRun | None:
280
+ """Retrieve a workflow run by ID."""
281
+ db = self._ensure_connected()
282
+
283
+ async with db.execute("SELECT * FROM workflow_runs WHERE run_id = ?", (run_id,)) as cursor:
284
+ row = await cursor.fetchone()
285
+
286
+ if not row:
287
+ return None
288
+
289
+ return self._row_to_workflow_run(row)
290
+
291
+ async def get_run_by_idempotency_key(self, key: str) -> WorkflowRun | None:
292
+ """Retrieve a workflow run by idempotency key."""
293
+ db = self._ensure_connected()
294
+
295
+ async with db.execute(
296
+ "SELECT * FROM workflow_runs WHERE idempotency_key = ?", (key,)
297
+ ) as cursor:
298
+ row = await cursor.fetchone()
299
+
300
+ if not row:
301
+ return None
302
+
303
+ return self._row_to_workflow_run(row)
304
+
305
+ async def update_run_status(
306
+ self,
307
+ run_id: str,
308
+ status: RunStatus,
309
+ result: str | None = None,
310
+ error: str | None = None,
311
+ ) -> None:
312
+ """Update workflow run status."""
313
+ db = self._ensure_connected()
314
+
315
+ now = datetime.now(UTC).isoformat()
316
+ completed_at = now if status == RunStatus.COMPLETED else None
317
+
318
+ # Build dynamic query
319
+ updates = ["status = ?", "updated_at = ?"]
320
+ params: list[Any] = [status.value, now]
321
+
322
+ if result is not None:
323
+ updates.append("result = ?")
324
+ params.append(result)
325
+
326
+ if error is not None:
327
+ updates.append("error = ?")
328
+ params.append(error)
329
+
330
+ if completed_at:
331
+ updates.append("completed_at = ?")
332
+ params.append(completed_at)
333
+
334
+ params.append(run_id)
335
+
336
+ await db.execute(
337
+ f"UPDATE workflow_runs SET {', '.join(updates)} WHERE run_id = ?",
338
+ tuple(params),
339
+ )
340
+ await db.commit()
341
+
342
+ async def update_run_recovery_attempts(
343
+ self,
344
+ run_id: str,
345
+ recovery_attempts: int,
346
+ ) -> None:
347
+ """Update the recovery attempts counter for a workflow run."""
348
+ db = self._ensure_connected()
349
+
350
+ await db.execute(
351
+ """
352
+ UPDATE workflow_runs
353
+ SET recovery_attempts = ?, updated_at = ?
354
+ WHERE run_id = ?
355
+ """,
356
+ (recovery_attempts, datetime.now(UTC).isoformat(), run_id),
357
+ )
358
+ await db.commit()
359
+
360
+ async def list_runs(
361
+ self,
362
+ query: str | None = None,
363
+ status: RunStatus | None = None,
364
+ start_time: datetime | None = None,
365
+ end_time: datetime | None = None,
366
+ limit: int = 100,
367
+ cursor: str | None = None,
368
+ ) -> tuple[list[WorkflowRun], str | None]:
369
+ """List workflow runs with optional filtering and pagination."""
370
+ db = self._ensure_connected()
371
+
372
+ conditions = []
373
+ params: list[Any] = []
374
+
375
+ if cursor:
376
+ conditions.append(
377
+ "created_at < (SELECT created_at FROM workflow_runs WHERE run_id = ?)"
378
+ )
379
+ params.append(cursor)
380
+
381
+ if query:
382
+ conditions.append("(workflow_name LIKE ? OR input_kwargs LIKE ?)")
383
+ search_param = f"%{query}%"
384
+ params.extend([search_param, search_param])
385
+
386
+ if status:
387
+ conditions.append("status = ?")
388
+ params.append(status.value)
389
+
390
+ if start_time:
391
+ conditions.append("created_at >= ?")
392
+ params.append(start_time.isoformat())
393
+
394
+ if end_time:
395
+ conditions.append("created_at < ?")
396
+ params.append(end_time.isoformat())
397
+
398
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
399
+ params.append(limit + 1) # Fetch one extra to determine if there are more
400
+
401
+ sql = f"""
402
+ SELECT * FROM workflow_runs
403
+ {where_clause}
404
+ ORDER BY created_at DESC
405
+ LIMIT ?
406
+ """
407
+
408
+ async with db.execute(sql, tuple(params)) as db_cursor:
409
+ rows = list(await db_cursor.fetchall())
410
+
411
+ has_more = len(rows) > limit
412
+ if has_more:
413
+ rows = rows[:limit]
414
+
415
+ runs = [self._row_to_workflow_run(row) for row in rows]
416
+ next_cursor = runs[-1].run_id if runs and has_more else None
417
+
418
+ return runs, next_cursor
419
+
420
+ # Event Log Operations
421
+
422
+ async def record_event(self, event: Event) -> None:
423
+ """Record an event to the append-only event log."""
424
+ db = self._ensure_connected()
425
+
426
+ # Get next sequence number
427
+ async with db.execute(
428
+ "SELECT COALESCE(MAX(sequence), -1) + 1 FROM events WHERE run_id = ?",
429
+ (event.run_id,),
430
+ ) as cursor:
431
+ row = await cursor.fetchone()
432
+ sequence = row[0] if row else 0
433
+
434
+ await db.execute(
435
+ """
436
+ INSERT INTO events (event_id, run_id, sequence, type, timestamp, data)
437
+ VALUES (?, ?, ?, ?, ?, ?)
438
+ """,
439
+ (
440
+ event.event_id,
441
+ event.run_id,
442
+ sequence,
443
+ event.type.value,
444
+ event.timestamp.isoformat(),
445
+ json.dumps(event.data),
446
+ ),
447
+ )
448
+ await db.commit()
449
+
450
+ async def get_events(
451
+ self,
452
+ run_id: str,
453
+ event_types: list[str] | None = None,
454
+ ) -> list[Event]:
455
+ """Retrieve all events for a workflow run, ordered by sequence."""
456
+ db = self._ensure_connected()
457
+
458
+ if event_types:
459
+ placeholders = ",".join("?" * len(event_types))
460
+ sql = f"""
461
+ SELECT * FROM events
462
+ WHERE run_id = ? AND type IN ({placeholders})
463
+ ORDER BY sequence ASC
464
+ """
465
+ params = [run_id] + event_types
466
+ else:
467
+ sql = "SELECT * FROM events WHERE run_id = ? ORDER BY sequence ASC"
468
+ params = [run_id]
469
+
470
+ async with db.execute(sql, tuple(params)) as cursor:
471
+ rows = await cursor.fetchall()
472
+
473
+ return [self._row_to_event(row) for row in rows]
474
+
475
+ async def get_latest_event(
476
+ self,
477
+ run_id: str,
478
+ event_type: str | None = None,
479
+ ) -> Event | None:
480
+ """Get the latest event for a run, optionally filtered by type."""
481
+ db = self._ensure_connected()
482
+
483
+ if event_type:
484
+ sql = """
485
+ SELECT * FROM events
486
+ WHERE run_id = ? AND type = ?
487
+ ORDER BY sequence DESC
488
+ LIMIT 1
489
+ """
490
+ params: tuple[str, ...] = (run_id, event_type)
491
+ else:
492
+ sql = """
493
+ SELECT * FROM events
494
+ WHERE run_id = ?
495
+ ORDER BY sequence DESC
496
+ LIMIT 1
497
+ """
498
+ params = (run_id,)
499
+
500
+ async with db.execute(sql, params) as cursor:
501
+ row = await cursor.fetchone()
502
+
503
+ if not row:
504
+ return None
505
+
506
+ return self._row_to_event(row)
507
+
508
+ # Step Operations
509
+
510
+ async def create_step(self, step: StepExecution) -> None:
511
+ """Create a step execution record."""
512
+ db = self._ensure_connected()
513
+
514
+ # Convert schema attempt (1-based) to DB retry_count (0-based)
515
+ retry_count = step.attempt - 1 if step.attempt > 0 else 0
516
+
517
+ await db.execute(
518
+ """
519
+ INSERT INTO steps (
520
+ step_id, run_id, step_name, status, created_at, started_at,
521
+ completed_at, input_args, input_kwargs, result, error, retry_count
522
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
523
+ """,
524
+ (
525
+ step.step_id,
526
+ step.run_id,
527
+ step.step_name,
528
+ step.status.value,
529
+ step.created_at.isoformat(),
530
+ step.started_at.isoformat() if step.started_at else None,
531
+ step.completed_at.isoformat() if step.completed_at else None,
532
+ step.input_args,
533
+ step.input_kwargs,
534
+ step.result,
535
+ step.error,
536
+ retry_count,
537
+ ),
538
+ )
539
+ await db.commit()
540
+
541
+ async def get_step(self, step_id: str) -> StepExecution | None:
542
+ """Retrieve a step execution by ID."""
543
+ db = self._ensure_connected()
544
+
545
+ async with db.execute("SELECT * FROM steps WHERE step_id = ?", (step_id,)) as cursor:
546
+ row = await cursor.fetchone()
547
+
548
+ if not row:
549
+ return None
550
+
551
+ return self._row_to_step_execution(row)
552
+
553
+ async def update_step_status(
554
+ self,
555
+ step_id: str,
556
+ status: str,
557
+ result: str | None = None,
558
+ error: str | None = None,
559
+ ) -> None:
560
+ """Update step execution status."""
561
+ db = self._ensure_connected()
562
+
563
+ updates = ["status = ?"]
564
+ params: list[Any] = [status]
565
+
566
+ if result is not None:
567
+ updates.append("result = ?")
568
+ params.append(result)
569
+
570
+ if error is not None:
571
+ updates.append("error = ?")
572
+ params.append(error)
573
+
574
+ if status == "completed":
575
+ updates.append("completed_at = ?")
576
+ params.append(datetime.now(UTC).isoformat())
577
+
578
+ params.append(step_id)
579
+
580
+ await db.execute(
581
+ f"UPDATE steps SET {', '.join(updates)} WHERE step_id = ?",
582
+ tuple(params),
583
+ )
584
+ await db.commit()
585
+
586
+ async def list_steps(self, run_id: str) -> list[StepExecution]:
587
+ """List all steps for a workflow run."""
588
+ db = self._ensure_connected()
589
+
590
+ async with db.execute(
591
+ "SELECT * FROM steps WHERE run_id = ? ORDER BY created_at ASC",
592
+ (run_id,),
593
+ ) as cursor:
594
+ rows = await cursor.fetchall()
595
+
596
+ return [self._row_to_step_execution(row) for row in rows]
597
+
598
+ # Hook Operations
599
+
600
+ async def create_hook(self, hook: Hook) -> None:
601
+ """Create a hook record."""
602
+ db = self._ensure_connected()
603
+
604
+ await db.execute(
605
+ """
606
+ INSERT INTO hooks (
607
+ hook_id, run_id, token, created_at, received_at, expires_at,
608
+ status, payload, metadata
609
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
610
+ """,
611
+ (
612
+ hook.hook_id,
613
+ hook.run_id,
614
+ hook.token,
615
+ hook.created_at.isoformat(),
616
+ hook.received_at.isoformat() if hook.received_at else None,
617
+ hook.expires_at.isoformat() if hook.expires_at else None,
618
+ hook.status.value,
619
+ hook.payload,
620
+ json.dumps(hook.metadata),
621
+ ),
622
+ )
623
+ await db.commit()
624
+
625
+ async def get_hook(self, hook_id: str) -> Hook | None:
626
+ """Retrieve a hook by ID."""
627
+ db = self._ensure_connected()
628
+
629
+ async with db.execute("SELECT * FROM hooks WHERE hook_id = ?", (hook_id,)) as cursor:
630
+ row = await cursor.fetchone()
631
+
632
+ if not row:
633
+ return None
634
+
635
+ return self._row_to_hook(row)
636
+
637
+ async def get_hook_by_token(self, token: str) -> Hook | None:
638
+ """Retrieve a hook by its token."""
639
+ db = self._ensure_connected()
640
+
641
+ async with db.execute("SELECT * FROM hooks WHERE token = ?", (token,)) as cursor:
642
+ row = await cursor.fetchone()
643
+
644
+ if not row:
645
+ return None
646
+
647
+ return self._row_to_hook(row)
648
+
649
+ async def update_hook_status(
650
+ self,
651
+ hook_id: str,
652
+ status: HookStatus,
653
+ payload: str | None = None,
654
+ ) -> None:
655
+ """Update hook status and optionally payload."""
656
+ db = self._ensure_connected()
657
+
658
+ updates = ["status = ?"]
659
+ params: list[Any] = [status.value]
660
+
661
+ if payload is not None:
662
+ updates.append("payload = ?")
663
+ params.append(payload)
664
+
665
+ if status == HookStatus.RECEIVED:
666
+ updates.append("received_at = ?")
667
+ params.append(datetime.now(UTC).isoformat())
668
+
669
+ params.append(hook_id)
670
+
671
+ await db.execute(
672
+ f"UPDATE hooks SET {', '.join(updates)} WHERE hook_id = ?",
673
+ tuple(params),
674
+ )
675
+ await db.commit()
676
+
677
+ async def list_hooks(
678
+ self,
679
+ run_id: str | None = None,
680
+ status: HookStatus | None = None,
681
+ limit: int = 100,
682
+ offset: int = 0,
683
+ ) -> list[Hook]:
684
+ """List hooks with optional filtering."""
685
+ db = self._ensure_connected()
686
+
687
+ conditions = []
688
+ params: list[Any] = []
689
+
690
+ if run_id:
691
+ conditions.append("run_id = ?")
692
+ params.append(run_id)
693
+
694
+ if status:
695
+ conditions.append("status = ?")
696
+ params.append(status.value)
697
+
698
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
699
+ params.extend([limit, offset])
700
+
701
+ sql = f"""
702
+ SELECT * FROM hooks
703
+ {where_clause}
704
+ ORDER BY created_at DESC
705
+ LIMIT ? OFFSET ?
706
+ """
707
+
708
+ async with db.execute(sql, tuple(params)) as cursor:
709
+ rows = await cursor.fetchall()
710
+
711
+ return [self._row_to_hook(row) for row in rows]
712
+
713
+ # Cancellation Flag Operations
714
+
715
+ async def set_cancellation_flag(self, run_id: str) -> None:
716
+ """Set a cancellation flag for a workflow run."""
717
+ db = self._ensure_connected()
718
+
719
+ await db.execute(
720
+ """
721
+ INSERT OR IGNORE INTO cancellation_flags (run_id, created_at)
722
+ VALUES (?, ?)
723
+ """,
724
+ (run_id, datetime.now(UTC).isoformat()),
725
+ )
726
+ await db.commit()
727
+
728
+ async def check_cancellation_flag(self, run_id: str) -> bool:
729
+ """Check if a cancellation flag is set for a workflow run."""
730
+ db = self._ensure_connected()
731
+
732
+ async with db.execute(
733
+ "SELECT 1 FROM cancellation_flags WHERE run_id = ?", (run_id,)
734
+ ) as cursor:
735
+ row = await cursor.fetchone()
736
+
737
+ return row is not None
738
+
739
+ async def clear_cancellation_flag(self, run_id: str) -> None:
740
+ """Clear the cancellation flag for a workflow run."""
741
+ db = self._ensure_connected()
742
+
743
+ await db.execute("DELETE FROM cancellation_flags WHERE run_id = ?", (run_id,))
744
+ await db.commit()
745
+
746
+ # Continue-As-New Chain Operations
747
+
748
+ async def update_run_continuation(
749
+ self,
750
+ run_id: str,
751
+ continued_to_run_id: str,
752
+ ) -> None:
753
+ """Update the continuation link for a workflow run."""
754
+ db = self._ensure_connected()
755
+
756
+ await db.execute(
757
+ """
758
+ UPDATE workflow_runs
759
+ SET continued_to_run_id = ?, updated_at = ?
760
+ WHERE run_id = ?
761
+ """,
762
+ (continued_to_run_id, datetime.now(UTC).isoformat(), run_id),
763
+ )
764
+ await db.commit()
765
+
766
+ async def get_workflow_chain(
767
+ self,
768
+ run_id: str,
769
+ ) -> list[WorkflowRun]:
770
+ """Get all runs in a continue-as-new chain."""
771
+ db = self._ensure_connected()
772
+
773
+ # Find the first run in the chain
774
+ current_id: str | None = run_id
775
+ while True:
776
+ async with db.execute(
777
+ "SELECT continued_from_run_id FROM workflow_runs WHERE run_id = ?",
778
+ (current_id,),
779
+ ) as cursor:
780
+ row = await cursor.fetchone()
781
+
782
+ if not row or not row[0]:
783
+ break
784
+
785
+ current_id = row[0]
786
+
787
+ # Now collect all runs in the chain from first to last
788
+ runs = []
789
+ while current_id:
790
+ run = await self.get_run(current_id)
791
+ if not run:
792
+ break
793
+ runs.append(run)
794
+ current_id = run.continued_to_run_id
795
+
796
+ return runs
797
+
798
+ # Child Workflow Operations
799
+
800
+ async def get_children(
801
+ self,
802
+ parent_run_id: str,
803
+ status: RunStatus | None = None,
804
+ ) -> list[WorkflowRun]:
805
+ """Get all child workflow runs for a parent workflow."""
806
+ db = self._ensure_connected()
807
+
808
+ if status:
809
+ sql = """
810
+ SELECT * FROM workflow_runs
811
+ WHERE parent_run_id = ? AND status = ?
812
+ ORDER BY created_at ASC
813
+ """
814
+ params: tuple[str, ...] = (parent_run_id, status.value)
815
+ else:
816
+ sql = """
817
+ SELECT * FROM workflow_runs
818
+ WHERE parent_run_id = ?
819
+ ORDER BY created_at ASC
820
+ """
821
+ params = (parent_run_id,)
822
+
823
+ async with db.execute(sql, params) as cursor:
824
+ rows = await cursor.fetchall()
825
+
826
+ return [self._row_to_workflow_run(row) for row in rows]
827
+
828
+ async def get_parent(self, run_id: str) -> WorkflowRun | None:
829
+ """Get the parent workflow run for a child workflow."""
830
+ run = await self.get_run(run_id)
831
+ if not run or not run.parent_run_id:
832
+ return None
833
+
834
+ return await self.get_run(run.parent_run_id)
835
+
836
+ async def get_nesting_depth(self, run_id: str) -> int:
837
+ """Get the nesting depth for a workflow."""
838
+ run = await self.get_run(run_id)
839
+ return run.nesting_depth if run else 0
840
+
841
+ # Schedule Operations
842
+
843
+ async def create_schedule(self, schedule: Schedule) -> None:
844
+ """Create a new schedule record."""
845
+ db = self._ensure_connected()
846
+
847
+ # Extract spec components from the nested ScheduleSpec
848
+ spec_value = schedule.spec.cron or schedule.spec.interval or ""
849
+ spec_type = "cron" if schedule.spec.cron else "interval"
850
+ timezone = schedule.spec.timezone
851
+
852
+ await db.execute(
853
+ """
854
+ INSERT INTO schedules (
855
+ schedule_id, workflow_name, spec, spec_type, timezone,
856
+ input_args, input_kwargs, status, overlap_policy,
857
+ next_run_time, last_run_time, running_run_ids, metadata,
858
+ created_at, updated_at, paused_at, deleted_at
859
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
860
+ """,
861
+ (
862
+ schedule.schedule_id,
863
+ schedule.workflow_name,
864
+ spec_value,
865
+ spec_type,
866
+ timezone,
867
+ schedule.args,
868
+ schedule.kwargs,
869
+ schedule.status.value,
870
+ schedule.overlap_policy.value,
871
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None,
872
+ schedule.last_run_at.isoformat() if schedule.last_run_at else None,
873
+ json.dumps(schedule.running_run_ids),
874
+ json.dumps({}), # metadata not in schema, store empty
875
+ schedule.created_at.isoformat(),
876
+ schedule.updated_at.isoformat()
877
+ if schedule.updated_at
878
+ else datetime.now(UTC).isoformat(),
879
+ None, # paused_at - derived from status
880
+ None, # deleted_at - derived from status
881
+ ),
882
+ )
883
+ await db.commit()
884
+
885
+ async def get_schedule(self, schedule_id: str) -> Schedule | None:
886
+ """Retrieve a schedule by ID."""
887
+ db = self._ensure_connected()
888
+
889
+ async with db.execute(
890
+ "SELECT * FROM schedules WHERE schedule_id = ?", (schedule_id,)
891
+ ) as cursor:
892
+ row = await cursor.fetchone()
893
+
894
+ if not row:
895
+ return None
896
+
897
+ return self._row_to_schedule(row)
898
+
899
+ async def update_schedule(self, schedule: Schedule) -> None:
900
+ """Update an existing schedule."""
901
+ db = self._ensure_connected()
902
+
903
+ # Extract spec components from the nested ScheduleSpec
904
+ spec_value = schedule.spec.cron or schedule.spec.interval or ""
905
+ spec_type = "cron" if schedule.spec.cron else "interval"
906
+ timezone = schedule.spec.timezone
907
+
908
+ # Determine paused_at and deleted_at from status
909
+ now = datetime.now(UTC)
910
+ paused_at = now if schedule.status == ScheduleStatus.PAUSED else None
911
+ deleted_at = now if schedule.status == ScheduleStatus.DELETED else None
912
+
913
+ await db.execute(
914
+ """
915
+ UPDATE schedules SET
916
+ workflow_name = ?, spec = ?, spec_type = ?, timezone = ?,
917
+ input_args = ?, input_kwargs = ?, status = ?, overlap_policy = ?,
918
+ next_run_time = ?, last_run_time = ?, running_run_ids = ?,
919
+ metadata = ?, updated_at = ?, paused_at = ?, deleted_at = ?
920
+ WHERE schedule_id = ?
921
+ """,
922
+ (
923
+ schedule.workflow_name,
924
+ spec_value,
925
+ spec_type,
926
+ timezone,
927
+ schedule.args,
928
+ schedule.kwargs,
929
+ schedule.status.value,
930
+ schedule.overlap_policy.value,
931
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None,
932
+ schedule.last_run_at.isoformat() if schedule.last_run_at else None,
933
+ json.dumps(schedule.running_run_ids),
934
+ json.dumps({}), # metadata not in schema, store empty
935
+ schedule.updated_at.isoformat() if schedule.updated_at else now.isoformat(),
936
+ paused_at.isoformat() if paused_at else None,
937
+ deleted_at.isoformat() if deleted_at else None,
938
+ schedule.schedule_id,
939
+ ),
940
+ )
941
+ await db.commit()
942
+
943
+ async def delete_schedule(self, schedule_id: str) -> None:
944
+ """Mark a schedule as deleted (soft delete)."""
945
+ db = self._ensure_connected()
946
+
947
+ now = datetime.now(UTC)
948
+ await db.execute(
949
+ """
950
+ UPDATE schedules
951
+ SET status = ?, deleted_at = ?, updated_at = ?
952
+ WHERE schedule_id = ?
953
+ """,
954
+ (ScheduleStatus.DELETED.value, now.isoformat(), now.isoformat(), schedule_id),
955
+ )
956
+ await db.commit()
957
+
958
+ async def list_schedules(
959
+ self,
960
+ workflow_name: str | None = None,
961
+ status: ScheduleStatus | None = None,
962
+ limit: int = 100,
963
+ offset: int = 0,
964
+ ) -> list[Schedule]:
965
+ """List schedules with optional filtering."""
966
+ db = self._ensure_connected()
967
+
968
+ conditions = []
969
+ params: list[Any] = []
970
+
971
+ if workflow_name:
972
+ conditions.append("workflow_name = ?")
973
+ params.append(workflow_name)
974
+
975
+ if status:
976
+ conditions.append("status = ?")
977
+ params.append(status.value)
978
+
979
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
980
+ params.extend([limit, offset])
981
+
982
+ sql = f"""
983
+ SELECT * FROM schedules
984
+ {where_clause}
985
+ ORDER BY created_at DESC
986
+ LIMIT ? OFFSET ?
987
+ """
988
+
989
+ async with db.execute(sql, tuple(params)) as cursor:
990
+ rows = await cursor.fetchall()
991
+
992
+ return [self._row_to_schedule(row) for row in rows]
993
+
994
+ async def get_due_schedules(self, now: datetime) -> list[Schedule]:
995
+ """Get all schedules that are due to run."""
996
+ db = self._ensure_connected()
997
+
998
+ async with db.execute(
999
+ """
1000
+ SELECT * FROM schedules
1001
+ WHERE status = ? AND next_run_time IS NOT NULL AND next_run_time <= ?
1002
+ ORDER BY next_run_time ASC
1003
+ """,
1004
+ (ScheduleStatus.ACTIVE.value, now.isoformat()),
1005
+ ) as cursor:
1006
+ rows = await cursor.fetchall()
1007
+
1008
+ return [self._row_to_schedule(row) for row in rows]
1009
+
1010
+ async def add_running_run(self, schedule_id: str, run_id: str) -> None:
1011
+ """Add a run_id to the schedule's running_run_ids list."""
1012
+ schedule = await self.get_schedule(schedule_id)
1013
+ if not schedule:
1014
+ raise ValueError(f"Schedule {schedule_id} not found")
1015
+
1016
+ if run_id not in schedule.running_run_ids:
1017
+ schedule.running_run_ids.append(run_id)
1018
+ schedule.updated_at = datetime.now(UTC)
1019
+ await self.update_schedule(schedule)
1020
+
1021
+ async def remove_running_run(self, schedule_id: str, run_id: str) -> None:
1022
+ """Remove a run_id from the schedule's running_run_ids list."""
1023
+ schedule = await self.get_schedule(schedule_id)
1024
+ if not schedule:
1025
+ raise ValueError(f"Schedule {schedule_id} not found")
1026
+
1027
+ if run_id in schedule.running_run_ids:
1028
+ schedule.running_run_ids.remove(run_id)
1029
+ schedule.updated_at = datetime.now(UTC)
1030
+ await self.update_schedule(schedule)
1031
+
1032
+ # Helper methods for converting database rows to domain objects
1033
+
1034
+ def _row_to_workflow_run(self, row: Any) -> WorkflowRun:
1035
+ """Convert database row to WorkflowRun object."""
1036
+ return WorkflowRun(
1037
+ run_id=row[0],
1038
+ workflow_name=row[1],
1039
+ status=RunStatus(row[2]),
1040
+ created_at=datetime.fromisoformat(row[3]),
1041
+ updated_at=datetime.fromisoformat(row[4]),
1042
+ started_at=datetime.fromisoformat(row[5]) if row[5] else None,
1043
+ completed_at=datetime.fromisoformat(row[6]) if row[6] else None,
1044
+ input_args=row[7],
1045
+ input_kwargs=row[8],
1046
+ result=row[9],
1047
+ error=row[10],
1048
+ idempotency_key=row[11],
1049
+ max_duration=row[12],
1050
+ metadata=json.loads(row[13]) if row[13] else {},
1051
+ recovery_attempts=row[14],
1052
+ max_recovery_attempts=row[15],
1053
+ recover_on_worker_loss=bool(row[16]),
1054
+ parent_run_id=row[17],
1055
+ nesting_depth=row[18],
1056
+ continued_from_run_id=row[19],
1057
+ continued_to_run_id=row[20],
1058
+ )
1059
+
1060
+ def _row_to_event(self, row: Any) -> Event:
1061
+ """Convert database row to Event object."""
1062
+ return Event(
1063
+ event_id=row[0],
1064
+ run_id=row[1],
1065
+ sequence=row[2],
1066
+ type=EventType(row[3]),
1067
+ timestamp=datetime.fromisoformat(row[4]),
1068
+ data=json.loads(row[5]) if row[5] else {},
1069
+ )
1070
+
1071
+ def _row_to_step_execution(self, row: Any) -> StepExecution:
1072
+ """Convert database row to StepExecution object."""
1073
+ from pyworkflow.storage.schemas import StepStatus
1074
+
1075
+ # Map DB retry_count (0-based) to schema attempt (1-based)
1076
+ retry_count = row[11] if row[11] is not None else 0
1077
+ return StepExecution(
1078
+ step_id=row[0],
1079
+ run_id=row[1],
1080
+ step_name=row[2],
1081
+ status=StepStatus(row[3]),
1082
+ created_at=datetime.fromisoformat(row[4]),
1083
+ started_at=datetime.fromisoformat(row[5]) if row[5] else None,
1084
+ completed_at=datetime.fromisoformat(row[6]) if row[6] else None,
1085
+ input_args=row[7],
1086
+ input_kwargs=row[8],
1087
+ result=row[9],
1088
+ error=row[10],
1089
+ attempt=retry_count + 1,
1090
+ )
1091
+
1092
+ def _row_to_hook(self, row: Any) -> Hook:
1093
+ """Convert database row to Hook object."""
1094
+ return Hook(
1095
+ hook_id=row[0],
1096
+ run_id=row[1],
1097
+ token=row[2],
1098
+ created_at=datetime.fromisoformat(row[3]),
1099
+ received_at=datetime.fromisoformat(row[4]) if row[4] else None,
1100
+ expires_at=datetime.fromisoformat(row[5]) if row[5] else None,
1101
+ status=HookStatus(row[6]),
1102
+ payload=row[7],
1103
+ metadata=json.loads(row[8]) if row[8] else {},
1104
+ )
1105
+
1106
+ def _row_to_schedule(self, row: Any) -> Schedule:
1107
+ """Convert database row to Schedule object."""
1108
+ # DB columns: schedule_id[0], workflow_name[1], spec[2], spec_type[3], timezone[4],
1109
+ # input_args[5], input_kwargs[6], status[7], overlap_policy[8], next_run_time[9],
1110
+ # last_run_time[10], running_run_ids[11], metadata[12], created_at[13], updated_at[14],
1111
+ # paused_at[15], deleted_at[16]
1112
+
1113
+ # Reconstruct ScheduleSpec from flattened DB columns
1114
+ spec_value = row[2]
1115
+ spec_type = row[3]
1116
+ timezone = row[4] or "UTC"
1117
+
1118
+ if spec_type == "cron":
1119
+ spec = ScheduleSpec(cron=spec_value, timezone=timezone)
1120
+ else:
1121
+ spec = ScheduleSpec(interval=spec_value, timezone=timezone)
1122
+
1123
+ return Schedule(
1124
+ schedule_id=row[0],
1125
+ workflow_name=row[1],
1126
+ spec=spec,
1127
+ status=ScheduleStatus(row[7]),
1128
+ args=row[5] or "[]",
1129
+ kwargs=row[6] or "{}",
1130
+ overlap_policy=OverlapPolicy(row[8]),
1131
+ created_at=datetime.fromisoformat(row[13]),
1132
+ updated_at=datetime.fromisoformat(row[14]) if row[14] else None,
1133
+ last_run_at=datetime.fromisoformat(row[10]) if row[10] else None,
1134
+ next_run_time=datetime.fromisoformat(row[9]) if row[9] else None,
1135
+ running_run_ids=json.loads(row[11]) if row[11] else [],
1136
+ )