planar 0.5.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/.__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/__init__.py +26 -0
- planar/_version.py +1 -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/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -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/__init__.py +2 -0
- planar/files/models.py +162 -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/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -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/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -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/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -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/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -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/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -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/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,468 @@
|
|
1
|
+
from datetime import timedelta
|
2
|
+
from uuid import UUID
|
3
|
+
|
4
|
+
from fastapi import APIRouter, Body, Depends, HTTPException
|
5
|
+
from sqlmodel import select
|
6
|
+
|
7
|
+
from planar.modeling.orm.query_filter_builder import build_paginated_query
|
8
|
+
from planar.object_registry import ObjectRegistry
|
9
|
+
from planar.routers.event import create_workflow_event_routes
|
10
|
+
from planar.routers.models import (
|
11
|
+
SortDirection,
|
12
|
+
WorkflowDefinition,
|
13
|
+
WorkflowList,
|
14
|
+
WorkflowRun,
|
15
|
+
WorkflowRunList,
|
16
|
+
WorkflowRunStatusCounts,
|
17
|
+
WorkflowStartResponse,
|
18
|
+
WorkflowStatusResponse,
|
19
|
+
WorkflowStepInfo,
|
20
|
+
WorkflowStepList,
|
21
|
+
)
|
22
|
+
from planar.security.authorization import (
|
23
|
+
WorkflowAction,
|
24
|
+
WorkflowResource,
|
25
|
+
validate_authorization_for,
|
26
|
+
)
|
27
|
+
from planar.session import get_session
|
28
|
+
from planar.utils import utc_now
|
29
|
+
from planar.workflows import LockedResource, Workflow, WorkflowStep
|
30
|
+
from planar.workflows.models import (
|
31
|
+
StepStatus,
|
32
|
+
WorkflowStatus,
|
33
|
+
workflow_lock_join_cond,
|
34
|
+
)
|
35
|
+
from planar.workflows.query import (
|
36
|
+
build_effective_status_case,
|
37
|
+
calculate_bulk_workflow_duration_stats,
|
38
|
+
calculate_effective_status,
|
39
|
+
calculate_workflow_duration_stats,
|
40
|
+
get_bulk_workflow_run_statuses,
|
41
|
+
get_workflow_run_statuses,
|
42
|
+
)
|
43
|
+
from planar.workflows.step_metadata import get_steps_metadata
|
44
|
+
|
45
|
+
|
46
|
+
def create_workflow_router(
|
47
|
+
registry: ObjectRegistry,
|
48
|
+
) -> APIRouter:
|
49
|
+
router = APIRouter(tags=["Workflow Management"])
|
50
|
+
|
51
|
+
create_workflow_event_routes(router)
|
52
|
+
|
53
|
+
async def get_validated_body(workflow_name: str, body: dict = Body(...)):
|
54
|
+
workflow = next(
|
55
|
+
(wf for wf in registry.get_workflows() if wf.name == workflow_name), None
|
56
|
+
)
|
57
|
+
if not workflow:
|
58
|
+
raise HTTPException(
|
59
|
+
status_code=404, detail=f"Workflow '{workflow_name}' not found"
|
60
|
+
)
|
61
|
+
|
62
|
+
try:
|
63
|
+
# Validate the request body against the model
|
64
|
+
validated_body = workflow.pydantic_model(**body)
|
65
|
+
|
66
|
+
return {"body": validated_body, "start_fn": workflow.obj.start}
|
67
|
+
except Exception as e:
|
68
|
+
raise HTTPException(
|
69
|
+
status_code=422,
|
70
|
+
detail=f"Invalid request body for workflow '{workflow_name}': {str(e)}\nJSON Schema:\n{workflow.pydantic_model.model_json_schema()}",
|
71
|
+
)
|
72
|
+
|
73
|
+
@router.post("/{workflow_name}/start", response_model=WorkflowStartResponse)
|
74
|
+
async def start_workflow(workflow_name: str, wf_data=Depends(get_validated_body)):
|
75
|
+
validate_authorization_for(
|
76
|
+
WorkflowResource(function_name=workflow_name), WorkflowAction.WORKFLOW_RUN
|
77
|
+
)
|
78
|
+
workflow = await wf_data["start_fn"](**wf_data["body"].model_dump(mode="json"))
|
79
|
+
return WorkflowStartResponse(id=workflow.id)
|
80
|
+
|
81
|
+
@router.get("/{run_id}/status", response_model=WorkflowStatusResponse)
|
82
|
+
async def get_workflow_status(run_id: UUID):
|
83
|
+
session = get_session()
|
84
|
+
workflow = await session.get(Workflow, run_id)
|
85
|
+
if not workflow:
|
86
|
+
raise HTTPException(
|
87
|
+
status_code=404, detail=f"Workflow run with id {run_id} not found"
|
88
|
+
)
|
89
|
+
validate_authorization_for(
|
90
|
+
WorkflowResource(function_name=workflow.function_name),
|
91
|
+
WorkflowAction.WORKFLOW_VIEW_DETAILS,
|
92
|
+
)
|
93
|
+
return WorkflowStatusResponse(workflow=workflow)
|
94
|
+
|
95
|
+
@router.get("/", response_model=WorkflowList)
|
96
|
+
async def list_workflows(
|
97
|
+
seconds_ago: int | None = None,
|
98
|
+
offset: int | None = 0,
|
99
|
+
limit: int | None = 10,
|
100
|
+
):
|
101
|
+
"""
|
102
|
+
Note that workflows are registered with the app using the `.register_workflow` method.
|
103
|
+
|
104
|
+
Hence, we do not need to query the database to get the list of workflows.
|
105
|
+
|
106
|
+
The workflow database tables track workflow RUNS.
|
107
|
+
"""
|
108
|
+
# Check list permission on any workflow since we're listing all workflows
|
109
|
+
validate_authorization_for(WorkflowResource(), WorkflowAction.WORKFLOW_LIST)
|
110
|
+
|
111
|
+
session = get_session()
|
112
|
+
|
113
|
+
# Prepare filters
|
114
|
+
filters = []
|
115
|
+
|
116
|
+
if seconds_ago:
|
117
|
+
filters.append(
|
118
|
+
(
|
119
|
+
Workflow.created_at,
|
120
|
+
">=",
|
121
|
+
utc_now() - timedelta(seconds=seconds_ago),
|
122
|
+
)
|
123
|
+
)
|
124
|
+
|
125
|
+
# Get stats for each workflow
|
126
|
+
items = []
|
127
|
+
all_workflows = registry.get_workflows()
|
128
|
+
|
129
|
+
end_offset = limit
|
130
|
+
if offset is not None:
|
131
|
+
end_offset = offset + (limit or 0)
|
132
|
+
|
133
|
+
workflows = all_workflows[offset or 0 : end_offset]
|
134
|
+
|
135
|
+
# Bulk fetch all status counts and duration stats in 2 queries instead of 2*N queries
|
136
|
+
workflow_names = [wf.name for wf in workflows]
|
137
|
+
bulk_run_statuses = await get_bulk_workflow_run_statuses(
|
138
|
+
workflow_names, session, filters
|
139
|
+
)
|
140
|
+
bulk_duration_stats = await calculate_bulk_workflow_duration_stats(
|
141
|
+
workflow_names, session, filters
|
142
|
+
)
|
143
|
+
|
144
|
+
for workflow in workflows:
|
145
|
+
# Get docstring description if available
|
146
|
+
name = workflow.name.split(".")[-1] # Get last part of function name
|
147
|
+
description = workflow.description
|
148
|
+
|
149
|
+
run_statuses = bulk_run_statuses.get(workflow.name, {})
|
150
|
+
duration_stats = bulk_duration_stats.get(workflow.name)
|
151
|
+
|
152
|
+
items.append(
|
153
|
+
WorkflowDefinition(
|
154
|
+
fully_qualified_name=workflow.name,
|
155
|
+
name=name,
|
156
|
+
description=description,
|
157
|
+
input_schema=workflow.input_schema,
|
158
|
+
output_schema=workflow.output_schema,
|
159
|
+
total_runs=sum(run_statuses.values()),
|
160
|
+
run_statuses=WorkflowRunStatusCounts(
|
161
|
+
**{
|
162
|
+
status.value: count
|
163
|
+
for status, count in run_statuses.items()
|
164
|
+
}
|
165
|
+
),
|
166
|
+
durations=duration_stats,
|
167
|
+
)
|
168
|
+
)
|
169
|
+
|
170
|
+
return WorkflowList(items=items, total=len(items), offset=offset, limit=limit)
|
171
|
+
|
172
|
+
@router.get("/{workflow_name}", response_model=WorkflowDefinition)
|
173
|
+
async def get_workflow_stats(workflow_name: str):
|
174
|
+
validate_authorization_for(
|
175
|
+
WorkflowResource(function_name=workflow_name),
|
176
|
+
WorkflowAction.WORKFLOW_VIEW_DETAILS,
|
177
|
+
)
|
178
|
+
session = get_session()
|
179
|
+
|
180
|
+
wf = next(
|
181
|
+
(wf for wf in registry.get_workflows() if wf.name == workflow_name), None
|
182
|
+
)
|
183
|
+
|
184
|
+
# Check if workflow exists in registry
|
185
|
+
if not wf:
|
186
|
+
raise HTTPException(
|
187
|
+
status_code=404, detail=f"Workflow '{workflow_name}' not found"
|
188
|
+
)
|
189
|
+
|
190
|
+
description = wf.description
|
191
|
+
name = wf.name.split(".")[-1] # Get last part of function name
|
192
|
+
|
193
|
+
run_statuses = await get_workflow_run_statuses(workflow_name, session)
|
194
|
+
duration_stats = await calculate_workflow_duration_stats(session, workflow_name)
|
195
|
+
|
196
|
+
return WorkflowDefinition(
|
197
|
+
fully_qualified_name=workflow_name,
|
198
|
+
name=name,
|
199
|
+
description=description,
|
200
|
+
input_schema=wf.input_schema,
|
201
|
+
output_schema=wf.output_schema,
|
202
|
+
total_runs=sum(run_statuses.values()),
|
203
|
+
run_statuses=WorkflowRunStatusCounts(
|
204
|
+
**{status.value: count for status, count in run_statuses.items()}
|
205
|
+
),
|
206
|
+
durations=duration_stats,
|
207
|
+
)
|
208
|
+
|
209
|
+
@router.get("/{workflow_name}/runs", response_model=WorkflowRunList)
|
210
|
+
async def list_workflow_runs(
|
211
|
+
workflow_name: str,
|
212
|
+
status: WorkflowStatus | None = None,
|
213
|
+
offset: int | None = 0,
|
214
|
+
limit: int | None = 10,
|
215
|
+
):
|
216
|
+
validate_authorization_for(
|
217
|
+
WorkflowResource(function_name=workflow_name),
|
218
|
+
WorkflowAction.WORKFLOW_VIEW_DETAILS,
|
219
|
+
)
|
220
|
+
session = get_session()
|
221
|
+
|
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
|
+
)
|
239
|
+
|
240
|
+
# Prepare filters - can filter on effective status using SQL
|
241
|
+
filters = []
|
242
|
+
if status:
|
243
|
+
# Add a filter on the effective_status expression directly
|
244
|
+
effective_status_expr = build_effective_status_case()
|
245
|
+
filters.append((effective_status_expr, "==", status.value))
|
246
|
+
|
247
|
+
# Apply filtering, pagination and ordering
|
248
|
+
query, total_query = build_paginated_query(
|
249
|
+
base_query,
|
250
|
+
filters=filters,
|
251
|
+
offset=offset,
|
252
|
+
limit=limit,
|
253
|
+
order_by=Workflow.created_at,
|
254
|
+
order_direction=SortDirection.DESC,
|
255
|
+
)
|
256
|
+
|
257
|
+
# Calculate total count
|
258
|
+
total = (await session.exec(total_query)).one()
|
259
|
+
|
260
|
+
# Execute the query
|
261
|
+
results = (await session.exec(query)).all()
|
262
|
+
|
263
|
+
items = [
|
264
|
+
WorkflowRun(
|
265
|
+
id=row.id,
|
266
|
+
status=row.effective_status,
|
267
|
+
args=row.args,
|
268
|
+
kwargs=row.kwargs,
|
269
|
+
result=row.result,
|
270
|
+
error=row.error,
|
271
|
+
created_at=row.created_at,
|
272
|
+
updated_at=row.updated_at,
|
273
|
+
)
|
274
|
+
for row in results
|
275
|
+
]
|
276
|
+
|
277
|
+
return WorkflowRunList(items=items, total=total, offset=offset, limit=limit)
|
278
|
+
|
279
|
+
@router.get("/{workflow_name}/runs/{run_id}", response_model=WorkflowRun)
|
280
|
+
async def get_workflow_run(workflow_name: str, run_id: UUID):
|
281
|
+
validate_authorization_for(
|
282
|
+
WorkflowResource(function_name=workflow_name),
|
283
|
+
WorkflowAction.WORKFLOW_VIEW_DETAILS,
|
284
|
+
)
|
285
|
+
session = get_session()
|
286
|
+
workflow = (
|
287
|
+
await session.exec(
|
288
|
+
select(Workflow).where(
|
289
|
+
Workflow.function_name == workflow_name, Workflow.id == run_id
|
290
|
+
)
|
291
|
+
)
|
292
|
+
).first()
|
293
|
+
|
294
|
+
if not workflow:
|
295
|
+
raise HTTPException(
|
296
|
+
status_code=404,
|
297
|
+
detail=f"Workflow run with id {run_id} not found for workflow {workflow_name}",
|
298
|
+
)
|
299
|
+
|
300
|
+
effective_status = await calculate_effective_status(session, workflow)
|
301
|
+
|
302
|
+
return WorkflowRun(
|
303
|
+
id=workflow.id,
|
304
|
+
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,
|
311
|
+
)
|
312
|
+
|
313
|
+
@router.get("/{workflow_name}/runs/{run_id}/steps", response_model=WorkflowStepList)
|
314
|
+
async def list_workflow_steps(
|
315
|
+
workflow_name: str,
|
316
|
+
run_id: UUID,
|
317
|
+
status: StepStatus | None = None,
|
318
|
+
step_type: str | None = None,
|
319
|
+
offset: int | None = 0,
|
320
|
+
limit: int | None = 10,
|
321
|
+
):
|
322
|
+
validate_authorization_for(
|
323
|
+
WorkflowResource(function_name=workflow_name),
|
324
|
+
WorkflowAction.WORKFLOW_VIEW_DETAILS,
|
325
|
+
)
|
326
|
+
"""
|
327
|
+
List workflow steps with optional filtering.
|
328
|
+
|
329
|
+
Returns rich metadata for each step based on its type in the 'meta' field.
|
330
|
+
"""
|
331
|
+
session = get_session()
|
332
|
+
|
333
|
+
# First verify the workflow exists
|
334
|
+
workflow = (
|
335
|
+
await session.exec(
|
336
|
+
select(Workflow).where(
|
337
|
+
Workflow.function_name == workflow_name, Workflow.id == run_id
|
338
|
+
)
|
339
|
+
)
|
340
|
+
).first()
|
341
|
+
|
342
|
+
if not workflow:
|
343
|
+
raise HTTPException(
|
344
|
+
status_code=404,
|
345
|
+
detail=f"Workflow run with id {run_id} not found for workflow {workflow_name}",
|
346
|
+
)
|
347
|
+
|
348
|
+
# Build base query for steps
|
349
|
+
base_query = select(WorkflowStep).where(WorkflowStep.workflow_id == run_id)
|
350
|
+
|
351
|
+
# Prepare filters
|
352
|
+
filters = []
|
353
|
+
|
354
|
+
# Add status filter if provided
|
355
|
+
if status:
|
356
|
+
filters.append((WorkflowStep.status, "==", status))
|
357
|
+
|
358
|
+
# Add step type filter if provided
|
359
|
+
if step_type:
|
360
|
+
filters.append((WorkflowStep.step_type, "==", step_type))
|
361
|
+
|
362
|
+
# Apply filtering, pagination and ordering
|
363
|
+
query, total_query = build_paginated_query(
|
364
|
+
base_query,
|
365
|
+
filters=filters,
|
366
|
+
offset=offset,
|
367
|
+
limit=limit,
|
368
|
+
order_by=WorkflowStep.step_id,
|
369
|
+
order_direction=SortDirection.ASC,
|
370
|
+
)
|
371
|
+
|
372
|
+
# Calculate total count
|
373
|
+
total = (await session.exec(total_query)).one()
|
374
|
+
|
375
|
+
steps: list[WorkflowStep] = (await session.exec(query)).all()
|
376
|
+
|
377
|
+
# Create step info objects with metadata
|
378
|
+
items = []
|
379
|
+
for step in steps:
|
380
|
+
# Create the base step info object
|
381
|
+
step_info = WorkflowStepInfo(
|
382
|
+
step_id=step.step_id,
|
383
|
+
parent_step_id=step.parent_step_id,
|
384
|
+
workflow_id=step.workflow_id,
|
385
|
+
function_name=step.function_name,
|
386
|
+
display_name=WorkflowStepInfo.get_display_name(
|
387
|
+
step.display_name, step.function_name
|
388
|
+
),
|
389
|
+
description=None, # get_function_docs(step.function_name),
|
390
|
+
status=step.status,
|
391
|
+
step_type=step.step_type,
|
392
|
+
args=step.args,
|
393
|
+
kwargs=step.kwargs,
|
394
|
+
result=step.result,
|
395
|
+
error=step.error,
|
396
|
+
retry_count=step.retry_count,
|
397
|
+
created_at=step.created_at,
|
398
|
+
updated_at=step.updated_at,
|
399
|
+
meta=None,
|
400
|
+
)
|
401
|
+
|
402
|
+
items.append(step_info)
|
403
|
+
|
404
|
+
return WorkflowStepList(items=items, total=total, offset=offset, limit=limit)
|
405
|
+
|
406
|
+
@router.get(
|
407
|
+
"/{workflow_name}/runs/{run_id}/steps/{step_id}",
|
408
|
+
response_model=WorkflowStepInfo,
|
409
|
+
)
|
410
|
+
async def get_workflow_step(
|
411
|
+
workflow_name: str,
|
412
|
+
run_id: UUID,
|
413
|
+
step_id: int,
|
414
|
+
):
|
415
|
+
validate_authorization_for(
|
416
|
+
WorkflowResource(function_name=workflow_name),
|
417
|
+
WorkflowAction.WORKFLOW_VIEW_DETAILS,
|
418
|
+
)
|
419
|
+
"""
|
420
|
+
Get metadata for a specific workflow step.
|
421
|
+
"""
|
422
|
+
session = get_session()
|
423
|
+
|
424
|
+
# Build base query for steps
|
425
|
+
async with session.begin_read():
|
426
|
+
step = (
|
427
|
+
await session.exec(
|
428
|
+
select(WorkflowStep).where(
|
429
|
+
WorkflowStep.workflow_id == run_id,
|
430
|
+
WorkflowStep.step_id == step_id,
|
431
|
+
)
|
432
|
+
)
|
433
|
+
).first()
|
434
|
+
|
435
|
+
if not step:
|
436
|
+
raise HTTPException(
|
437
|
+
status_code=404,
|
438
|
+
detail=f"Workflow step with id {step_id} not found for workflow run {run_id}",
|
439
|
+
)
|
440
|
+
|
441
|
+
metadata = await get_steps_metadata([step], registry)
|
442
|
+
|
443
|
+
# Create step info objects with metadata
|
444
|
+
step_info = WorkflowStepInfo(
|
445
|
+
step_id=step.step_id,
|
446
|
+
parent_step_id=step.parent_step_id,
|
447
|
+
workflow_id=step.workflow_id,
|
448
|
+
function_name=step.function_name,
|
449
|
+
display_name=WorkflowStepInfo.get_display_name(
|
450
|
+
step.display_name, step.function_name
|
451
|
+
),
|
452
|
+
description=None, # get_function_docs(step.function_name),
|
453
|
+
status=step.status,
|
454
|
+
step_type=step.step_type,
|
455
|
+
args=step.args,
|
456
|
+
kwargs=step.kwargs,
|
457
|
+
result=step.result,
|
458
|
+
error=step.error,
|
459
|
+
retry_count=step.retry_count,
|
460
|
+
created_at=step.created_at,
|
461
|
+
updated_at=step.updated_at,
|
462
|
+
# Compute steps do not produce metadata, so use .get to avoid KeyError
|
463
|
+
meta=metadata.get(step.step_id),
|
464
|
+
)
|
465
|
+
|
466
|
+
return step_info
|
467
|
+
|
468
|
+
return router
|
Binary file
|
Binary file
|
Binary file
|
planar/rules/__init__.py
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
import importlib
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
_DEFERRED_IMPORTS = {
|
5
|
+
"rule": ".decorator",
|
6
|
+
"Rule": ".models",
|
7
|
+
"RuleSerializeable": ".models",
|
8
|
+
}
|
9
|
+
|
10
|
+
|
11
|
+
def __getattr__(name: str) -> Any:
|
12
|
+
"""
|
13
|
+
Lazily import modules to avoid circular dependencies.
|
14
|
+
This is called by the Python interpreter when a module attribute is accessed
|
15
|
+
that cannot be found in the module's __dict__.
|
16
|
+
PEP 562
|
17
|
+
"""
|
18
|
+
if name in _DEFERRED_IMPORTS:
|
19
|
+
module_path = _DEFERRED_IMPORTS[name]
|
20
|
+
module = importlib.import_module(module_path, __name__)
|
21
|
+
return getattr(module, name)
|
22
|
+
|
23
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
@@ -0,0 +1,184 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import inspect
|
4
|
+
from datetime import datetime
|
5
|
+
from functools import wraps
|
6
|
+
from typing import Any, Callable, Coroutine, Type, TypeVar, cast
|
7
|
+
from uuid import UUID
|
8
|
+
|
9
|
+
from pydantic import BaseModel
|
10
|
+
|
11
|
+
from planar.logging import get_logger
|
12
|
+
from planar.rules.models import Rule
|
13
|
+
from planar.rules.rule_configuration import rule_configuration
|
14
|
+
from planar.rules.runner import EvaluateResponse, evaluate_rule
|
15
|
+
from planar.workflows.decorators import step
|
16
|
+
from planar.workflows.models import StepType
|
17
|
+
|
18
|
+
logger = get_logger(__name__)
|
19
|
+
|
20
|
+
RULE_REGISTRY = {}
|
21
|
+
|
22
|
+
# Define type variables for input and output BaseModel types
|
23
|
+
T = TypeVar("T", bound=BaseModel)
|
24
|
+
U = TypeVar("U", bound=BaseModel)
|
25
|
+
|
26
|
+
|
27
|
+
def serialize_for_rule_evaluation(obj: Any) -> Any:
|
28
|
+
"""
|
29
|
+
Custom serializer that converts Pydantic model_dump() to a format that can be
|
30
|
+
interpreted by the rule engine.
|
31
|
+
"""
|
32
|
+
if isinstance(obj, UUID):
|
33
|
+
return str(obj)
|
34
|
+
if isinstance(obj, datetime):
|
35
|
+
# Zen rule engine throws an error if the datetime does not include timezone
|
36
|
+
# ie. `"2025-05-27T00:21:44.802433" is not a "date-time"`
|
37
|
+
return obj.isoformat() + "Z" if obj.tzinfo is None else obj.isoformat()
|
38
|
+
elif isinstance(obj, dict):
|
39
|
+
return {key: serialize_for_rule_evaluation(value) for key, value in obj.items()}
|
40
|
+
elif isinstance(obj, (list, tuple)):
|
41
|
+
return [serialize_for_rule_evaluation(item) for item in obj]
|
42
|
+
else:
|
43
|
+
return obj
|
44
|
+
|
45
|
+
|
46
|
+
#### Decorator
|
47
|
+
def rule(*, description: str):
|
48
|
+
def _get_input_and_return_types(
|
49
|
+
func: Callable,
|
50
|
+
) -> tuple[Type[BaseModel], Type[BaseModel]]:
|
51
|
+
"""
|
52
|
+
Validates that a rule method has proper type annotations.
|
53
|
+
Returns a tuple of (input_type, return_type).
|
54
|
+
"""
|
55
|
+
|
56
|
+
# Get function parameters using inspect module
|
57
|
+
signature = inspect.signature(func)
|
58
|
+
params = list(signature.parameters.keys())
|
59
|
+
|
60
|
+
if len(params) != 1 or "self" in params:
|
61
|
+
err_msg = (
|
62
|
+
"@rule method must have exactly one input argument (and cannot be self)"
|
63
|
+
)
|
64
|
+
logger.warning(
|
65
|
+
"rule definition error", function_name=func.__name__, error=err_msg
|
66
|
+
)
|
67
|
+
raise ValueError(err_msg)
|
68
|
+
|
69
|
+
# Check for missing annotations using signature
|
70
|
+
missing_annotations = [
|
71
|
+
p
|
72
|
+
for p in params
|
73
|
+
if signature.parameters[p].annotation == inspect.Parameter.empty
|
74
|
+
]
|
75
|
+
if missing_annotations:
|
76
|
+
err_msg = (
|
77
|
+
f"Missing annotations for parameters: {', '.join(missing_annotations)}"
|
78
|
+
)
|
79
|
+
logger.warning(
|
80
|
+
"rule definition error", function_name=func.__name__, error=err_msg
|
81
|
+
)
|
82
|
+
raise ValueError(err_msg)
|
83
|
+
|
84
|
+
if signature.return_annotation == inspect.Signature.empty:
|
85
|
+
err_msg = "@rule method must have a return type annotation"
|
86
|
+
logger.warning(
|
87
|
+
"rule definition error", function_name=func.__name__, error=err_msg
|
88
|
+
)
|
89
|
+
raise ValueError(err_msg)
|
90
|
+
|
91
|
+
param_name = params[0]
|
92
|
+
input_type = signature.parameters[param_name].annotation
|
93
|
+
return_type = signature.return_annotation
|
94
|
+
|
95
|
+
# Ensure both input and return types are pydantic BaseModels
|
96
|
+
if not issubclass(input_type, BaseModel):
|
97
|
+
err_msg = f"Input type {input_type.__name__} must be a pydantic BaseModel"
|
98
|
+
logger.warning(
|
99
|
+
"rule definition error", function_name=func.__name__, error=err_msg
|
100
|
+
)
|
101
|
+
raise ValueError(err_msg)
|
102
|
+
if not issubclass(return_type, BaseModel):
|
103
|
+
err_msg = f"Return type {return_type.__name__} must be a pydantic BaseModel"
|
104
|
+
logger.warning(
|
105
|
+
"rule definition error", function_name=func.__name__, error=err_msg
|
106
|
+
)
|
107
|
+
raise ValueError(err_msg)
|
108
|
+
|
109
|
+
return input_type, return_type
|
110
|
+
|
111
|
+
def decorator(func: Callable[[T], U]) -> Callable[[T], Coroutine[Any, Any, U]]:
|
112
|
+
input_type, return_type = _get_input_and_return_types(func)
|
113
|
+
|
114
|
+
rule = Rule(
|
115
|
+
name=func.__name__,
|
116
|
+
description=description,
|
117
|
+
input=input_type,
|
118
|
+
output=return_type,
|
119
|
+
)
|
120
|
+
|
121
|
+
RULE_REGISTRY[func.__name__] = rule
|
122
|
+
logger.debug("registered rule", rule_name=func.__name__)
|
123
|
+
|
124
|
+
@step(step_type=StepType.RULE)
|
125
|
+
@wraps(func)
|
126
|
+
async def wrapper(input: T) -> U:
|
127
|
+
logger.debug(
|
128
|
+
"executing rule", rule_name=func.__name__, input_type=type(input)
|
129
|
+
)
|
130
|
+
# Look up any existing decision override for this function name
|
131
|
+
override_result = await rule_configuration.read_configs_with_default(
|
132
|
+
func.__name__, rule.to_config()
|
133
|
+
)
|
134
|
+
|
135
|
+
active_config = next(
|
136
|
+
(config for config in override_result if config.active), None
|
137
|
+
)
|
138
|
+
|
139
|
+
if not active_config:
|
140
|
+
raise ValueError(
|
141
|
+
f"No active configuration found for rule {func.__name__}"
|
142
|
+
)
|
143
|
+
|
144
|
+
logger.debug(
|
145
|
+
"active config for rule",
|
146
|
+
rule_name=func.__name__,
|
147
|
+
version=active_config.version,
|
148
|
+
)
|
149
|
+
|
150
|
+
if active_config.version == 0:
|
151
|
+
logger.info(
|
152
|
+
"using default python implementation for rule",
|
153
|
+
rule_name=func.__name__,
|
154
|
+
)
|
155
|
+
# default implementation
|
156
|
+
return func(input)
|
157
|
+
else:
|
158
|
+
logger.info(
|
159
|
+
"using jdm override for rule",
|
160
|
+
version=active_config.version,
|
161
|
+
rule_name=func.__name__,
|
162
|
+
)
|
163
|
+
serialized_input = serialize_for_rule_evaluation(input.model_dump())
|
164
|
+
evaluation_response = evaluate_rule(
|
165
|
+
active_config.data.jdm, serialized_input
|
166
|
+
)
|
167
|
+
if isinstance(evaluation_response, EvaluateResponse):
|
168
|
+
result_model = return_type.model_validate(
|
169
|
+
evaluation_response.result
|
170
|
+
)
|
171
|
+
return cast(U, result_model)
|
172
|
+
else:
|
173
|
+
logger.warning(
|
174
|
+
"rule evaluation error",
|
175
|
+
rule_name=func.__name__,
|
176
|
+
message=evaluation_response.message,
|
177
|
+
)
|
178
|
+
raise Exception(evaluation_response.message)
|
179
|
+
|
180
|
+
wrapper.__rule__ = rule # type: ignore
|
181
|
+
|
182
|
+
return wrapper
|
183
|
+
|
184
|
+
return decorator
|