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