planar 0.5.0__py3-none-any.whl → 0.8.0__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 (211) hide show
  1. planar/_version.py +1 -1
  2. planar/ai/agent.py +155 -283
  3. planar/ai/agent_base.py +170 -0
  4. planar/ai/agent_utils.py +7 -0
  5. planar/ai/pydantic_ai.py +638 -0
  6. planar/ai/test_agent_serialization.py +1 -1
  7. planar/app.py +64 -20
  8. planar/cli.py +39 -27
  9. planar/config.py +45 -36
  10. planar/db/db.py +2 -1
  11. planar/files/storage/azure_blob.py +343 -0
  12. planar/files/storage/base.py +7 -0
  13. planar/files/storage/config.py +70 -7
  14. planar/files/storage/s3.py +6 -6
  15. planar/files/storage/test_azure_blob.py +435 -0
  16. planar/logging/formatter.py +17 -4
  17. planar/logging/test_formatter.py +327 -0
  18. planar/registry_items.py +2 -1
  19. planar/routers/agents_router.py +3 -1
  20. planar/routers/files.py +11 -2
  21. planar/routers/models.py +14 -1
  22. planar/routers/test_agents_router.py +1 -1
  23. planar/routers/test_files_router.py +49 -0
  24. planar/routers/test_routes_security.py +5 -7
  25. planar/routers/test_workflow_router.py +270 -3
  26. planar/routers/workflow.py +95 -36
  27. planar/rules/models.py +36 -39
  28. planar/rules/test_data/account_dormancy_management.json +223 -0
  29. planar/rules/test_data/airline_loyalty_points_calculator.json +262 -0
  30. planar/rules/test_data/applicant_risk_assessment.json +435 -0
  31. planar/rules/test_data/booking_fraud_detection.json +407 -0
  32. planar/rules/test_data/cellular_data_rollover_system.json +258 -0
  33. planar/rules/test_data/clinical_trial_eligibility_screener.json +437 -0
  34. planar/rules/test_data/customer_lifetime_value.json +143 -0
  35. planar/rules/test_data/import_duties_calculator.json +289 -0
  36. planar/rules/test_data/insurance_prior_authorization.json +443 -0
  37. planar/rules/test_data/online_check_in_eligibility_system.json +254 -0
  38. planar/rules/test_data/order_consolidation_system.json +375 -0
  39. planar/rules/test_data/portfolio_risk_monitor.json +471 -0
  40. planar/rules/test_data/supply_chain_risk.json +253 -0
  41. planar/rules/test_data/warehouse_cross_docking.json +237 -0
  42. planar/rules/test_rules.py +750 -6
  43. planar/scaffold_templates/planar.dev.yaml.j2 +6 -6
  44. planar/scaffold_templates/planar.prod.yaml.j2 +9 -5
  45. planar/scaffold_templates/pyproject.toml.j2 +1 -1
  46. planar/security/auth_context.py +21 -0
  47. planar/security/{jwt_middleware.py → auth_middleware.py} +70 -17
  48. planar/security/authorization.py +9 -15
  49. planar/security/tests/test_auth_middleware.py +162 -0
  50. planar/sse/proxy.py +4 -9
  51. planar/test_app.py +92 -1
  52. planar/test_cli.py +81 -59
  53. planar/test_config.py +17 -14
  54. planar/testing/fixtures.py +325 -0
  55. planar/testing/planar_test_client.py +5 -2
  56. planar/utils.py +41 -1
  57. planar/workflows/execution.py +1 -1
  58. planar/workflows/orchestrator.py +5 -0
  59. planar/workflows/serialization.py +12 -6
  60. planar/workflows/step_core.py +3 -1
  61. planar/workflows/test_serialization.py +9 -1
  62. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/METADATA +30 -5
  63. planar-0.8.0.dist-info/RECORD +166 -0
  64. planar/.__init__.py.un~ +0 -0
  65. planar/._version.py.un~ +0 -0
  66. planar/.app.py.un~ +0 -0
  67. planar/.cli.py.un~ +0 -0
  68. planar/.config.py.un~ +0 -0
  69. planar/.context.py.un~ +0 -0
  70. planar/.db.py.un~ +0 -0
  71. planar/.di.py.un~ +0 -0
  72. planar/.engine.py.un~ +0 -0
  73. planar/.files.py.un~ +0 -0
  74. planar/.log_context.py.un~ +0 -0
  75. planar/.log_metadata.py.un~ +0 -0
  76. planar/.logging.py.un~ +0 -0
  77. planar/.object_registry.py.un~ +0 -0
  78. planar/.otel.py.un~ +0 -0
  79. planar/.server.py.un~ +0 -0
  80. planar/.session.py.un~ +0 -0
  81. planar/.sqlalchemy.py.un~ +0 -0
  82. planar/.task_local.py.un~ +0 -0
  83. planar/.test_app.py.un~ +0 -0
  84. planar/.test_config.py.un~ +0 -0
  85. planar/.test_object_config.py.un~ +0 -0
  86. planar/.test_sqlalchemy.py.un~ +0 -0
  87. planar/.test_utils.py.un~ +0 -0
  88. planar/.util.py.un~ +0 -0
  89. planar/.utils.py.un~ +0 -0
  90. planar/ai/.__init__.py.un~ +0 -0
  91. planar/ai/._models.py.un~ +0 -0
  92. planar/ai/.agent.py.un~ +0 -0
  93. planar/ai/.agent_utils.py.un~ +0 -0
  94. planar/ai/.events.py.un~ +0 -0
  95. planar/ai/.files.py.un~ +0 -0
  96. planar/ai/.models.py.un~ +0 -0
  97. planar/ai/.providers.py.un~ +0 -0
  98. planar/ai/.pydantic_ai.py.un~ +0 -0
  99. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  100. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  101. planar/ai/.step.py.un~ +0 -0
  102. planar/ai/.test_agent.py.un~ +0 -0
  103. planar/ai/.test_agent_serialization.py.un~ +0 -0
  104. planar/ai/.test_providers.py.un~ +0 -0
  105. planar/ai/.utils.py.un~ +0 -0
  106. planar/ai/providers.py +0 -1088
  107. planar/ai/test_agent.py +0 -1298
  108. planar/ai/test_providers.py +0 -463
  109. planar/db/.db.py.un~ +0 -0
  110. planar/files/.config.py.un~ +0 -0
  111. planar/files/.local.py.un~ +0 -0
  112. planar/files/.local_filesystem.py.un~ +0 -0
  113. planar/files/.model.py.un~ +0 -0
  114. planar/files/.models.py.un~ +0 -0
  115. planar/files/.s3.py.un~ +0 -0
  116. planar/files/.storage.py.un~ +0 -0
  117. planar/files/.test_files.py.un~ +0 -0
  118. planar/files/storage/.__init__.py.un~ +0 -0
  119. planar/files/storage/.base.py.un~ +0 -0
  120. planar/files/storage/.config.py.un~ +0 -0
  121. planar/files/storage/.context.py.un~ +0 -0
  122. planar/files/storage/.local_directory.py.un~ +0 -0
  123. planar/files/storage/.test_local_directory.py.un~ +0 -0
  124. planar/files/storage/.test_s3.py.un~ +0 -0
  125. planar/human/.human.py.un~ +0 -0
  126. planar/human/.test_human.py.un~ +0 -0
  127. planar/logging/.__init__.py.un~ +0 -0
  128. planar/logging/.attributes.py.un~ +0 -0
  129. planar/logging/.formatter.py.un~ +0 -0
  130. planar/logging/.logger.py.un~ +0 -0
  131. planar/logging/.otel.py.un~ +0 -0
  132. planar/logging/.tracer.py.un~ +0 -0
  133. planar/modeling/.mixin.py.un~ +0 -0
  134. planar/modeling/.storage.py.un~ +0 -0
  135. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  136. planar/object_config/.object_config.py.un~ +0 -0
  137. planar/routers/.__init__.py.un~ +0 -0
  138. planar/routers/.agents_router.py.un~ +0 -0
  139. planar/routers/.crud.py.un~ +0 -0
  140. planar/routers/.decision.py.un~ +0 -0
  141. planar/routers/.event.py.un~ +0 -0
  142. planar/routers/.file_attachment.py.un~ +0 -0
  143. planar/routers/.files.py.un~ +0 -0
  144. planar/routers/.files_router.py.un~ +0 -0
  145. planar/routers/.human.py.un~ +0 -0
  146. planar/routers/.info.py.un~ +0 -0
  147. planar/routers/.models.py.un~ +0 -0
  148. planar/routers/.object_config_router.py.un~ +0 -0
  149. planar/routers/.rule.py.un~ +0 -0
  150. planar/routers/.test_object_config_router.py.un~ +0 -0
  151. planar/routers/.test_workflow_router.py.un~ +0 -0
  152. planar/routers/.workflow.py.un~ +0 -0
  153. planar/rules/.decorator.py.un~ +0 -0
  154. planar/rules/.runner.py.un~ +0 -0
  155. planar/rules/.test_rules.py.un~ +0 -0
  156. planar/security/.jwt_middleware.py.un~ +0 -0
  157. planar/sse/.constants.py.un~ +0 -0
  158. planar/sse/.example.html.un~ +0 -0
  159. planar/sse/.hub.py.un~ +0 -0
  160. planar/sse/.model.py.un~ +0 -0
  161. planar/sse/.proxy.py.un~ +0 -0
  162. planar/testing/.client.py.un~ +0 -0
  163. planar/testing/.memory_storage.py.un~ +0 -0
  164. planar/testing/.planar_test_client.py.un~ +0 -0
  165. planar/testing/.predictable_tracer.py.un~ +0 -0
  166. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  167. planar/testing/.test_memory_storage.py.un~ +0 -0
  168. planar/testing/.workflow_observer.py.un~ +0 -0
  169. planar/workflows/.__init__.py.un~ +0 -0
  170. planar/workflows/.builtin_steps.py.un~ +0 -0
  171. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  172. planar/workflows/.context.py.un~ +0 -0
  173. planar/workflows/.contrib.py.un~ +0 -0
  174. planar/workflows/.decorators.py.un~ +0 -0
  175. planar/workflows/.durable_test.py.un~ +0 -0
  176. planar/workflows/.errors.py.un~ +0 -0
  177. planar/workflows/.events.py.un~ +0 -0
  178. planar/workflows/.exceptions.py.un~ +0 -0
  179. planar/workflows/.execution.py.un~ +0 -0
  180. planar/workflows/.human.py.un~ +0 -0
  181. planar/workflows/.lock.py.un~ +0 -0
  182. planar/workflows/.misc.py.un~ +0 -0
  183. planar/workflows/.model.py.un~ +0 -0
  184. planar/workflows/.models.py.un~ +0 -0
  185. planar/workflows/.notifications.py.un~ +0 -0
  186. planar/workflows/.orchestrator.py.un~ +0 -0
  187. planar/workflows/.runtime.py.un~ +0 -0
  188. planar/workflows/.serialization.py.un~ +0 -0
  189. planar/workflows/.step.py.un~ +0 -0
  190. planar/workflows/.step_core.py.un~ +0 -0
  191. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  192. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  193. planar/workflows/.test_concurrency.py.un~ +0 -0
  194. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  195. planar/workflows/.test_human.py.un~ +0 -0
  196. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  197. planar/workflows/.test_orchestrator.py.un~ +0 -0
  198. planar/workflows/.test_race_conditions.py.un~ +0 -0
  199. planar/workflows/.test_serialization.py.un~ +0 -0
  200. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  201. planar/workflows/.test_workflow.py.un~ +0 -0
  202. planar/workflows/.tracing.py.un~ +0 -0
  203. planar/workflows/.types.py.un~ +0 -0
  204. planar/workflows/.util.py.un~ +0 -0
  205. planar/workflows/.utils.py.un~ +0 -0
  206. planar/workflows/.workflow.py.un~ +0 -0
  207. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  208. planar/workflows/.wrappers.py.un~ +0 -0
  209. planar-0.5.0.dist-info/RECORD +0 -289
  210. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/WHEEL +0 -0
  211. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,9 @@
1
+ import asyncio
1
2
  from uuid import UUID, uuid4
2
3
 
3
4
  import pytest
4
5
  from pydantic import BaseModel, Field
6
+ from sqlalchemy.ext.asyncio import AsyncEngine
5
7
  from sqlmodel import select
6
8
  from sqlmodel.ext.asyncio.session import AsyncSession
7
9
 
@@ -10,12 +12,19 @@ from examples.expense_approval_workflow.models import (
10
12
  ExpenseStatus,
11
13
  )
12
14
  from planar import PlanarApp, get_session, sqlite_config
15
+ from planar.db import new_session
13
16
  from planar.files.models import PlanarFile, PlanarFileMetadata
14
17
  from planar.files.storage.base import Storage
15
18
  from planar.testing.planar_test_client import PlanarTestClient
16
19
  from planar.testing.workflow_observer import WorkflowObserver
17
20
  from planar.workflows import step, workflow
18
- from planar.workflows.models import StepType, Workflow, WorkflowStatus, WorkflowStep
21
+ from planar.workflows.models import (
22
+ StepStatus,
23
+ StepType,
24
+ Workflow,
25
+ WorkflowStatus,
26
+ WorkflowStep,
27
+ )
19
28
 
20
29
  # ------ SETUP ------
21
30
 
@@ -50,6 +59,40 @@ async def validate_expense(expense_id: str):
50
59
  raise ValueError(f"Expense {expense_id} is not in SUBMITTED status")
51
60
 
52
61
 
62
+ @step()
63
+ async def dummy_step_1():
64
+ pass
65
+
66
+
67
+ @step()
68
+ async def dummy_step_2():
69
+ pass
70
+
71
+
72
+ @step()
73
+ async def dummy_step_3():
74
+ pass
75
+
76
+
77
+ @step(display_name="failing_step")
78
+ async def failing_step():
79
+ raise ValueError("This step is designed to fail")
80
+
81
+
82
+ @workflow(name="successful_workflow_3_steps")
83
+ async def successful_workflow_3_steps():
84
+ await dummy_step_1()
85
+ await dummy_step_2()
86
+ await dummy_step_3()
87
+
88
+
89
+ @workflow(name="failing_workflow_3_steps")
90
+ async def failing_workflow_3_steps():
91
+ await dummy_step_1()
92
+ await failing_step()
93
+ await dummy_step_3()
94
+
95
+
53
96
  class FileProcessingResult(BaseModel):
54
97
  """Result of processing a text file."""
55
98
 
@@ -92,6 +135,8 @@ def app_fixture():
92
135
  # Re-register workflows since ObjectRegistry gets reset before each test
93
136
  app.register_workflow(expense_approval_workflow)
94
137
  app.register_workflow(file_processing_workflow)
138
+ app.register_workflow(successful_workflow_3_steps)
139
+ app.register_workflow(failing_workflow_3_steps)
95
140
  yield app
96
141
 
97
142
 
@@ -125,6 +170,72 @@ async def planar_file(storage: Storage) -> PlanarFile:
125
170
  )
126
171
 
127
172
 
173
+ async def create_test_workflow_run(
174
+ engine: AsyncEngine,
175
+ workflow_name: str,
176
+ status: WorkflowStatus,
177
+ completed_steps: int = 0,
178
+ total_steps: int = 0,
179
+ error: dict | None = None,
180
+ ) -> Workflow:
181
+ """Helper to directly create a workflow run and its steps in the DB."""
182
+ async with new_session(engine) as session:
183
+ workflow = Workflow(
184
+ function_name=workflow_name,
185
+ status=status,
186
+ error=error,
187
+ args=[],
188
+ kwargs={},
189
+ )
190
+ session.add(workflow)
191
+
192
+ failed_steps = 0
193
+ if status == WorkflowStatus.FAILED:
194
+ failed_steps = 1
195
+
196
+ running_steps = total_steps - completed_steps - failed_steps
197
+
198
+ for i in range(completed_steps):
199
+ step = WorkflowStep(
200
+ workflow_id=workflow.id,
201
+ step_id=i + 1,
202
+ status=StepStatus.SUCCEEDED,
203
+ function_name=f"dummy_step_{i + 1}",
204
+ step_type=StepType.COMPUTE,
205
+ args=[],
206
+ kwargs={},
207
+ )
208
+ session.add(step)
209
+
210
+ for i in range(failed_steps):
211
+ step = WorkflowStep(
212
+ workflow_id=workflow.id,
213
+ step_id=completed_steps + i + 1,
214
+ status=StepStatus.FAILED,
215
+ function_name=f"dummy_step_{completed_steps + i + 1}",
216
+ step_type=StepType.COMPUTE,
217
+ args=[],
218
+ kwargs={},
219
+ )
220
+ session.add(step)
221
+
222
+ for i in range(running_steps):
223
+ step = WorkflowStep(
224
+ workflow_id=workflow.id,
225
+ step_id=completed_steps + failed_steps + i + 1,
226
+ status=StepStatus.RUNNING,
227
+ function_name=f"dummy_step_{completed_steps + failed_steps + i + 1}",
228
+ step_type=StepType.COMPUTE,
229
+ args=[],
230
+ kwargs={},
231
+ )
232
+ session.add(step)
233
+
234
+ await session.commit()
235
+ await session.refresh(workflow)
236
+ return workflow
237
+
238
+
128
239
  async def test_list_workflows(client: PlanarTestClient):
129
240
  """
130
241
  Test that the workflow management router correctly lists registered workflows.
@@ -139,8 +250,8 @@ async def test_list_workflows(client: PlanarTestClient):
139
250
  data = response.json()
140
251
 
141
252
  # Verify that two workflows are returned
142
- assert data["total"] == 2
143
- assert len(data["items"]) == 2
253
+ assert data["total"] == 4
254
+ assert len(data["items"]) == 4
144
255
 
145
256
  assert data["offset"] == 0
146
257
  assert data["limit"] == 10
@@ -182,6 +293,162 @@ async def test_list_workflows(client: PlanarTestClient):
182
293
  assert "run_statuses" in file_workflow
183
294
 
184
295
 
296
+ async def test_list_workflow_runs_no_runs(client: PlanarTestClient):
297
+ """Test listing runs for a workflow that has not been run."""
298
+ response = await client.get(
299
+ "/planar/v1/workflows/test_expense_approval_workflow/runs"
300
+ )
301
+ assert response.status_code == 200
302
+ data = response.json()
303
+ assert data["total"] == 0
304
+ assert len(data["items"]) == 0
305
+
306
+
307
+ async def test_list_workflow_runs_multiple_runs(
308
+ client: PlanarTestClient, tmp_db_engine: AsyncEngine
309
+ ):
310
+ """Test listing runs for a workflow with a mix of succeeded and failed runs."""
311
+ await asyncio.gather(
312
+ # Run 1: Successful
313
+ create_test_workflow_run(
314
+ tmp_db_engine,
315
+ workflow_name="test_expense_approval_workflow",
316
+ status=WorkflowStatus.SUCCEEDED,
317
+ completed_steps=1,
318
+ total_steps=1,
319
+ ),
320
+ create_test_workflow_run(
321
+ tmp_db_engine,
322
+ workflow_name="test_expense_approval_workflow",
323
+ status=WorkflowStatus.SUCCEEDED,
324
+ completed_steps=1,
325
+ total_steps=1,
326
+ ),
327
+ # Run 3: Failed
328
+ create_test_workflow_run(
329
+ tmp_db_engine,
330
+ workflow_name="test_expense_approval_workflow",
331
+ status=WorkflowStatus.FAILED,
332
+ completed_steps=0,
333
+ total_steps=1,
334
+ error={"type": "ValueError", "message": "Forced failure for test"},
335
+ ),
336
+ )
337
+
338
+ # List runs
339
+ response = await client.get(
340
+ "/planar/v1/workflows/test_expense_approval_workflow/runs"
341
+ )
342
+ assert response.status_code == 200
343
+ data = response.json()
344
+
345
+ assert data["total"] == 3
346
+ assert len(data["items"]) == 3
347
+
348
+ succeeded_runs = [r for r in data["items"] if r["status"] == "succeeded"]
349
+ failed_runs = [r for r in data["items"] if r["status"] == "failed"]
350
+
351
+ assert len(succeeded_runs) == 2
352
+ assert len(failed_runs) == 1
353
+
354
+ # Assert succeeded run details
355
+ assert succeeded_runs[0]["step_stats"]["completed"] == 1
356
+ assert succeeded_runs[1]["step_stats"]["completed"] == 1
357
+ assert succeeded_runs[0]["step_stats"]["failed"] == 0
358
+ assert succeeded_runs[1]["step_stats"]["failed"] == 0
359
+ assert succeeded_runs[0]["step_stats"]["running"] == 0
360
+ assert succeeded_runs[1]["step_stats"]["running"] == 0
361
+
362
+ # Assert failed run details
363
+ assert failed_runs[0]["step_stats"]["completed"] == 0
364
+ assert failed_runs[0]["step_stats"]["failed"] == 1
365
+ assert "ValueError" in failed_runs[0]["error"]["type"]
366
+
367
+
368
+ async def test_get_workflow_run_succeeded(
369
+ client: PlanarTestClient, tmp_db_engine: AsyncEngine
370
+ ):
371
+ """Test getting a single succeeded workflow run."""
372
+ workflow = await create_test_workflow_run(
373
+ tmp_db_engine,
374
+ workflow_name="successful_workflow_3_steps",
375
+ status=WorkflowStatus.SUCCEEDED,
376
+ completed_steps=3,
377
+ total_steps=3,
378
+ )
379
+
380
+ run_resp = await client.get(
381
+ f"/planar/v1/workflows/successful_workflow_3_steps/runs/{workflow.id}"
382
+ )
383
+ assert run_resp.status_code == 200
384
+ run_data = run_resp.json()
385
+
386
+ assert run_data["id"] == str(workflow.id)
387
+ assert run_data["status"] == "succeeded"
388
+ assert run_data["step_stats"]["completed"] == 3
389
+ assert run_data["step_stats"]["failed"] == 0
390
+ assert run_data["step_stats"]["running"] == 0
391
+ assert run_data["error"] is None
392
+
393
+
394
+ async def test_get_workflow_run_failed(
395
+ client: PlanarTestClient, tmp_db_engine: AsyncEngine
396
+ ):
397
+ """Test getting a single failed workflow run."""
398
+ workflow = await create_test_workflow_run(
399
+ tmp_db_engine,
400
+ workflow_name="failing_workflow_3_steps",
401
+ status=WorkflowStatus.FAILED,
402
+ completed_steps=1,
403
+ total_steps=2, # 1 succeeded, 1 failed
404
+ error={
405
+ "type": "ValueError",
406
+ "message": "This step is designed to fail",
407
+ },
408
+ )
409
+
410
+ run_resp = await client.get(
411
+ f"/planar/v1/workflows/failing_workflow_3_steps/runs/{workflow.id}"
412
+ )
413
+ assert run_resp.status_code == 200
414
+ run_data = run_resp.json()
415
+
416
+ assert run_data["id"] == str(workflow.id)
417
+ assert run_data["status"] == "failed"
418
+ assert run_data["step_stats"]["completed"] == 1
419
+ assert run_data["step_stats"]["failed"] == 1
420
+ assert run_data["step_stats"]["running"] == 0
421
+ assert run_data["error"] is not None
422
+ assert "ValueError" in run_data["error"]["type"]
423
+ assert "This step is designed to fail" in run_data["error"]["message"]
424
+
425
+
426
+ async def test_get_workflow_run_pending_with_running_step(
427
+ client: PlanarTestClient, tmp_db_engine: AsyncEngine
428
+ ):
429
+ """Test getting a pending workflow with completed and running steps."""
430
+ workflow = await create_test_workflow_run(
431
+ tmp_db_engine,
432
+ workflow_name="pending_workflow_with_running_steps",
433
+ status=WorkflowStatus.PENDING,
434
+ completed_steps=3,
435
+ total_steps=4, # 3 completed, 1 running
436
+ )
437
+
438
+ run_resp = await client.get(
439
+ f"/planar/v1/workflows/pending_workflow_with_running_steps/runs/{workflow.id}"
440
+ )
441
+ assert run_resp.status_code == 200
442
+ run_data = run_resp.json()
443
+
444
+ assert run_data["id"] == str(workflow.id)
445
+ assert run_data["status"] == "pending"
446
+ assert run_data["step_stats"]["completed"] == 3
447
+ assert run_data["step_stats"]["running"] == 1
448
+ assert run_data["step_stats"]["failed"] == 0
449
+ assert run_data["error"] is None
450
+
451
+
185
452
  async def test_start_file_workflow(
186
453
  client: PlanarTestClient,
187
454
  planar_file: PlanarFile,
@@ -1,14 +1,18 @@
1
- from datetime import timedelta
1
+ from datetime import datetime, timedelta
2
+ from typing import Any, Dict
2
3
  from uuid import UUID
3
4
 
4
5
  from fastapi import APIRouter, Body, Depends, HTTPException
5
- from sqlmodel import select
6
+ from sqlalchemy import Select
7
+ from sqlmodel import case, col, func, select
6
8
 
7
9
  from planar.modeling.orm.query_filter_builder import build_paginated_query
8
10
  from planar.object_registry import ObjectRegistry
9
11
  from planar.routers.event import create_workflow_event_routes
10
12
  from planar.routers.models import (
11
13
  SortDirection,
14
+ StepRunError,
15
+ StepStats,
12
16
  WorkflowDefinition,
13
17
  WorkflowList,
14
18
  WorkflowRun,
@@ -35,7 +39,6 @@ from planar.workflows.models import (
35
39
  from planar.workflows.query import (
36
40
  build_effective_status_case,
37
41
  calculate_bulk_workflow_duration_stats,
38
- calculate_effective_status,
39
42
  calculate_workflow_duration_stats,
40
43
  get_bulk_workflow_run_statuses,
41
44
  get_workflow_run_statuses,
@@ -43,6 +46,56 @@ from planar.workflows.query import (
43
46
  from planar.workflows.step_metadata import get_steps_metadata
44
47
 
45
48
 
49
+ def build_base_workflow_query(
50
+ workflow_name: str,
51
+ ) -> Select[
52
+ tuple[
53
+ UUID, # Workflow.id
54
+ list[Any] | None, # Workflow.args
55
+ Dict[str, Any] | None, # Workflow.kwargs
56
+ Any | None, # Workflow.result
57
+ Dict[str, Any] | None, # Workflow.error
58
+ datetime, # Workflow.created_at
59
+ datetime, # Workflow.updated_at
60
+ str, # effective_status_expr
61
+ int, # succeeded_step_count
62
+ int, # failed_steps_count
63
+ int, # running_steps_count
64
+ ]
65
+ ]:
66
+ effective_status_expr = build_effective_status_case().label("effective_status")
67
+
68
+ return (
69
+ select( # type: ignore[call-overload]
70
+ Workflow.id,
71
+ Workflow.args,
72
+ Workflow.kwargs,
73
+ Workflow.result,
74
+ Workflow.error,
75
+ Workflow.created_at,
76
+ Workflow.updated_at,
77
+ effective_status_expr,
78
+ func.count(
79
+ case((col(WorkflowStep.status) == StepStatus.SUCCEEDED, 1))
80
+ ).label("succeeded_step_count"),
81
+ func.count(case((col(WorkflowStep.status) == StepStatus.FAILED, 1))).label(
82
+ "failed_steps_count"
83
+ ),
84
+ func.count(case((col(WorkflowStep.status) == StepStatus.RUNNING, 1))).label(
85
+ "running_steps_count"
86
+ ),
87
+ )
88
+ .select_from(Workflow)
89
+ .outerjoin(LockedResource, workflow_lock_join_cond())
90
+ .outerjoin(WorkflowStep, col(Workflow.id) == col(WorkflowStep.workflow_id))
91
+ .where(Workflow.function_name == workflow_name)
92
+ .group_by(
93
+ col(Workflow.id),
94
+ col(LockedResource.lock_until),
95
+ )
96
+ )
97
+
98
+
46
99
  def create_workflow_router(
47
100
  registry: ObjectRegistry,
48
101
  ) -> APIRouter:
@@ -219,23 +272,7 @@ def create_workflow_router(
219
272
  )
220
273
  session = get_session()
221
274
 
222
- # Build query with virtual status calculation
223
- effective_status_expr = build_effective_status_case().label("effective_status")
224
- base_query = (
225
- select( # type: ignore[misc]
226
- Workflow.id,
227
- Workflow.args,
228
- Workflow.kwargs,
229
- Workflow.result,
230
- Workflow.error,
231
- Workflow.created_at,
232
- Workflow.updated_at,
233
- effective_status_expr,
234
- )
235
- .select_from(Workflow)
236
- .outerjoin(LockedResource, workflow_lock_join_cond())
237
- .where(Workflow.function_name == workflow_name)
238
- )
275
+ base_query = build_base_workflow_query(workflow_name)
239
276
 
240
277
  # Prepare filters - can filter on effective status using SQL
241
278
  filters = []
@@ -270,6 +307,11 @@ def create_workflow_router(
270
307
  error=row.error,
271
308
  created_at=row.created_at,
272
309
  updated_at=row.updated_at,
310
+ step_stats=StepStats(
311
+ completed=row.succeeded_step_count,
312
+ failed=row.failed_steps_count,
313
+ running=row.running_steps_count,
314
+ ),
273
315
  )
274
316
  for row in results
275
317
  ]
@@ -283,31 +325,49 @@ def create_workflow_router(
283
325
  WorkflowAction.WORKFLOW_VIEW_DETAILS,
284
326
  )
285
327
  session = get_session()
286
- workflow = (
328
+
329
+ base_query = build_base_workflow_query(workflow_name)
330
+
331
+ workflow_info = (
287
332
  await session.exec(
288
- select(Workflow).where(
289
- Workflow.function_name == workflow_name, Workflow.id == run_id
290
- )
333
+ base_query.where(col(Workflow.id) == run_id) # type: ignore[arg-type]
291
334
  )
292
335
  ).first()
293
336
 
294
- if not workflow:
337
+ if not workflow_info:
295
338
  raise HTTPException(
296
339
  status_code=404,
297
340
  detail=f"Workflow run with id {run_id} not found for workflow {workflow_name}",
298
341
  )
299
342
 
300
- effective_status = await calculate_effective_status(session, workflow)
343
+ (
344
+ workflow_id,
345
+ args,
346
+ kwargs,
347
+ result,
348
+ error,
349
+ created_at,
350
+ updated_at,
351
+ effective_status,
352
+ step_count,
353
+ failed_steps_count,
354
+ running_steps_count,
355
+ ) = workflow_info
301
356
 
302
357
  return WorkflowRun(
303
- id=workflow.id,
358
+ id=workflow_id,
304
359
  status=effective_status,
305
- args=workflow.args,
306
- kwargs=workflow.kwargs,
307
- result=workflow.result,
308
- error=workflow.error,
309
- created_at=workflow.created_at,
310
- updated_at=workflow.updated_at,
360
+ args=args,
361
+ kwargs=kwargs,
362
+ result=result,
363
+ error=error,
364
+ created_at=created_at,
365
+ updated_at=updated_at,
366
+ step_stats=StepStats(
367
+ completed=step_count,
368
+ failed=failed_steps_count,
369
+ running=running_steps_count,
370
+ ),
311
371
  )
312
372
 
313
373
  @router.get("/{workflow_name}/runs/{run_id}/steps", response_model=WorkflowStepList)
@@ -377,7 +437,6 @@ def create_workflow_router(
377
437
  # Create step info objects with metadata
378
438
  items = []
379
439
  for step in steps:
380
- # Create the base step info object
381
440
  step_info = WorkflowStepInfo(
382
441
  step_id=step.step_id,
383
442
  parent_step_id=step.parent_step_id,
@@ -392,7 +451,7 @@ def create_workflow_router(
392
451
  args=step.args,
393
452
  kwargs=step.kwargs,
394
453
  result=step.result,
395
- error=step.error,
454
+ error=StepRunError.model_validate(step.error) if step.error else None,
396
455
  retry_count=step.retry_count,
397
456
  created_at=step.created_at,
398
457
  updated_at=step.updated_at,
@@ -455,7 +514,7 @@ def create_workflow_router(
455
514
  args=step.args,
456
515
  kwargs=step.kwargs,
457
516
  result=step.result,
458
- error=step.error,
517
+ error=StepRunError.model_validate(step.error) if step.error else None,
459
518
  retry_count=step.retry_count,
460
519
  created_at=step.created_at,
461
520
  updated_at=step.updated_at,