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.
- planar/_version.py +1 -1
- planar/ai/agent.py +155 -283
- planar/ai/agent_base.py +170 -0
- planar/ai/agent_utils.py +7 -0
- planar/ai/pydantic_ai.py +638 -0
- planar/ai/test_agent_serialization.py +1 -1
- planar/app.py +64 -20
- planar/cli.py +39 -27
- planar/config.py +45 -36
- planar/db/db.py +2 -1
- planar/files/storage/azure_blob.py +343 -0
- planar/files/storage/base.py +7 -0
- planar/files/storage/config.py +70 -7
- planar/files/storage/s3.py +6 -6
- planar/files/storage/test_azure_blob.py +435 -0
- planar/logging/formatter.py +17 -4
- planar/logging/test_formatter.py +327 -0
- planar/registry_items.py +2 -1
- planar/routers/agents_router.py +3 -1
- planar/routers/files.py +11 -2
- planar/routers/models.py +14 -1
- planar/routers/test_agents_router.py +1 -1
- planar/routers/test_files_router.py +49 -0
- planar/routers/test_routes_security.py +5 -7
- planar/routers/test_workflow_router.py +270 -3
- planar/routers/workflow.py +95 -36
- planar/rules/models.py +36 -39
- planar/rules/test_data/account_dormancy_management.json +223 -0
- planar/rules/test_data/airline_loyalty_points_calculator.json +262 -0
- planar/rules/test_data/applicant_risk_assessment.json +435 -0
- planar/rules/test_data/booking_fraud_detection.json +407 -0
- planar/rules/test_data/cellular_data_rollover_system.json +258 -0
- planar/rules/test_data/clinical_trial_eligibility_screener.json +437 -0
- planar/rules/test_data/customer_lifetime_value.json +143 -0
- planar/rules/test_data/import_duties_calculator.json +289 -0
- planar/rules/test_data/insurance_prior_authorization.json +443 -0
- planar/rules/test_data/online_check_in_eligibility_system.json +254 -0
- planar/rules/test_data/order_consolidation_system.json +375 -0
- planar/rules/test_data/portfolio_risk_monitor.json +471 -0
- planar/rules/test_data/supply_chain_risk.json +253 -0
- planar/rules/test_data/warehouse_cross_docking.json +237 -0
- planar/rules/test_rules.py +750 -6
- planar/scaffold_templates/planar.dev.yaml.j2 +6 -6
- planar/scaffold_templates/planar.prod.yaml.j2 +9 -5
- planar/scaffold_templates/pyproject.toml.j2 +1 -1
- planar/security/auth_context.py +21 -0
- planar/security/{jwt_middleware.py → auth_middleware.py} +70 -17
- planar/security/authorization.py +9 -15
- planar/security/tests/test_auth_middleware.py +162 -0
- planar/sse/proxy.py +4 -9
- planar/test_app.py +92 -1
- planar/test_cli.py +81 -59
- planar/test_config.py +17 -14
- planar/testing/fixtures.py +325 -0
- planar/testing/planar_test_client.py +5 -2
- planar/utils.py +41 -1
- planar/workflows/execution.py +1 -1
- planar/workflows/orchestrator.py +5 -0
- planar/workflows/serialization.py +12 -6
- planar/workflows/step_core.py +3 -1
- planar/workflows/test_serialization.py +9 -1
- {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/METADATA +30 -5
- planar-0.8.0.dist-info/RECORD +166 -0
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/providers.py +0 -1088
- planar/ai/test_agent.py +0 -1298
- planar/ai/test_providers.py +0 -463
- planar/db/.db.py.un~ +0 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar-0.5.0.dist-info/RECORD +0 -289
- {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/WHEEL +0 -0
- {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
|
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"] ==
|
143
|
-
assert len(data["items"]) ==
|
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,
|
planar/routers/workflow.py
CHANGED
@@ -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
|
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
|
-
|
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
|
-
|
328
|
+
|
329
|
+
base_query = build_base_workflow_query(workflow_name)
|
330
|
+
|
331
|
+
workflow_info = (
|
287
332
|
await session.exec(
|
288
|
-
|
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
|
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
|
-
|
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=
|
358
|
+
id=workflow_id,
|
304
359
|
status=effective_status,
|
305
|
-
args=
|
306
|
-
kwargs=
|
307
|
-
result=
|
308
|
-
error=
|
309
|
-
created_at=
|
310
|
-
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,
|