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
planar/routers/human.py
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
"""
|
2
|
+
Human tasks API router for Planar workflows.
|
3
|
+
|
4
|
+
This module provides API routes for managing human task instances,
|
5
|
+
including task listing, completion, cancellation, and retrieval.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Any, Dict, List, Optional
|
9
|
+
from uuid import UUID
|
10
|
+
|
11
|
+
from fastapi import APIRouter, Body, HTTPException, Query
|
12
|
+
from pydantic import BaseModel
|
13
|
+
|
14
|
+
from planar.human.human import (
|
15
|
+
HumanTask,
|
16
|
+
HumanTaskStatus,
|
17
|
+
cancel_human_task,
|
18
|
+
complete_human_task,
|
19
|
+
get_human_task,
|
20
|
+
get_human_tasks,
|
21
|
+
)
|
22
|
+
from planar.logging import get_logger
|
23
|
+
|
24
|
+
logger = get_logger(__name__)
|
25
|
+
|
26
|
+
|
27
|
+
class CompleteTaskRequest(BaseModel):
|
28
|
+
"""Request model for completing a human task."""
|
29
|
+
|
30
|
+
output_data: Dict[str, Any]
|
31
|
+
completed_by: Optional[str] = None
|
32
|
+
|
33
|
+
|
34
|
+
class CancelTaskRequest(BaseModel):
|
35
|
+
"""Request model for cancelling a human task."""
|
36
|
+
|
37
|
+
reason: str = "cancelled"
|
38
|
+
cancelled_by: Optional[str] = None
|
39
|
+
|
40
|
+
|
41
|
+
def create_human_task_routes() -> APIRouter:
|
42
|
+
router = APIRouter(tags=["Human Tasks"])
|
43
|
+
|
44
|
+
"""Register human task routes on the provided router and return it."""
|
45
|
+
|
46
|
+
@router.get("/", response_model=List[HumanTask])
|
47
|
+
async def list_human_tasks(
|
48
|
+
status: Optional[HumanTaskStatus] = None,
|
49
|
+
workflow_id: Optional[UUID] = None,
|
50
|
+
limit: int = Query(100, ge=1, le=1000),
|
51
|
+
offset: int = Query(0, ge=0),
|
52
|
+
):
|
53
|
+
"""
|
54
|
+
List human tasks with optional filtering.
|
55
|
+
|
56
|
+
Args:
|
57
|
+
status: Filter by task status
|
58
|
+
workflow_id: Filter by workflow ID
|
59
|
+
limit: Maximum number of tasks to return
|
60
|
+
offset: Pagination offset
|
61
|
+
"""
|
62
|
+
try:
|
63
|
+
tasks = await get_human_tasks(
|
64
|
+
status=status,
|
65
|
+
workflow_id=workflow_id,
|
66
|
+
limit=limit,
|
67
|
+
offset=offset,
|
68
|
+
)
|
69
|
+
return tasks
|
70
|
+
except Exception as e:
|
71
|
+
logger.exception("error listing human tasks")
|
72
|
+
raise HTTPException(status_code=500, detail=str(e))
|
73
|
+
|
74
|
+
@router.get("/{task_id}", response_model=HumanTask)
|
75
|
+
async def get_task(task_id: UUID):
|
76
|
+
"""
|
77
|
+
Get a human task by its ID.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
task_id: The ID of the task to retrieve
|
81
|
+
"""
|
82
|
+
task = await get_human_task(task_id)
|
83
|
+
if not task:
|
84
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
85
|
+
|
86
|
+
return task
|
87
|
+
|
88
|
+
@router.post("/{task_id}/complete", response_model=HumanTask)
|
89
|
+
async def complete_task(task_id: UUID, request: CompleteTaskRequest = Body(...)):
|
90
|
+
"""
|
91
|
+
Complete a human task with the provided output data.
|
92
|
+
|
93
|
+
Args:
|
94
|
+
task_id: The ID of the task to complete
|
95
|
+
request: The completion data
|
96
|
+
"""
|
97
|
+
try:
|
98
|
+
await complete_human_task(
|
99
|
+
task_id=task_id,
|
100
|
+
output_data=request.output_data,
|
101
|
+
completed_by=request.completed_by,
|
102
|
+
)
|
103
|
+
|
104
|
+
# Fetch the updated task to return
|
105
|
+
task = await get_human_task(task_id)
|
106
|
+
if not task: # Should not happen if complete_human_task succeeded
|
107
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
108
|
+
|
109
|
+
logger.info("human task completed successfully", task_id=task_id)
|
110
|
+
return task
|
111
|
+
except ValueError as e:
|
112
|
+
logger.exception("valueerror completing task", task_id=task_id)
|
113
|
+
raise HTTPException(status_code=400, detail=str(e))
|
114
|
+
except Exception as e:
|
115
|
+
logger.exception("exception completing task", task_id=task_id)
|
116
|
+
raise HTTPException(status_code=500, detail=str(e))
|
117
|
+
|
118
|
+
@router.post("/{task_id}/cancel", response_model=HumanTask)
|
119
|
+
async def cancel_task(task_id: UUID, request: CancelTaskRequest = Body(...)):
|
120
|
+
"""
|
121
|
+
Cancel a pending human task.
|
122
|
+
|
123
|
+
Args:
|
124
|
+
task_id: The ID of the task to cancel
|
125
|
+
request: The cancellation details
|
126
|
+
"""
|
127
|
+
try:
|
128
|
+
await cancel_human_task(
|
129
|
+
task_id=task_id,
|
130
|
+
reason=request.reason,
|
131
|
+
cancelled_by=request.cancelled_by,
|
132
|
+
)
|
133
|
+
|
134
|
+
# Fetch the updated task to return
|
135
|
+
task = await get_human_task(task_id)
|
136
|
+
if not task: # Should not happen if cancel_human_task succeeded
|
137
|
+
logger.warning(
|
138
|
+
"human task not found after cancellation attempt", task_id=task_id
|
139
|
+
)
|
140
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
141
|
+
|
142
|
+
logger.info("human task cancelled successfully", task_id=task_id)
|
143
|
+
return task
|
144
|
+
except ValueError as e:
|
145
|
+
logger.exception("valueerror cancelling task", task_id=task_id)
|
146
|
+
raise HTTPException(status_code=400, detail=str(e))
|
147
|
+
except Exception as e:
|
148
|
+
logger.exception("exception cancelling task", task_id=task_id)
|
149
|
+
raise HTTPException(status_code=500, detail=str(e))
|
150
|
+
|
151
|
+
return router
|
planar/routers/info.py
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
from fastapi import APIRouter, Depends
|
2
|
+
from pydantic import BaseModel
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
4
|
+
from sqlmodel import col, distinct, func, select
|
5
|
+
|
6
|
+
from planar.human.models import HumanTask, HumanTaskStatus
|
7
|
+
from planar.logging import get_logger
|
8
|
+
from planar.object_config import ConfigurableObjectType, ObjectConfiguration
|
9
|
+
from planar.session import get_session
|
10
|
+
from planar.workflows.models import Workflow, WorkflowStatus
|
11
|
+
|
12
|
+
logger = get_logger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
class SystemInfo(BaseModel):
|
16
|
+
"""Combined application information and system statistics"""
|
17
|
+
|
18
|
+
# App info
|
19
|
+
title: str
|
20
|
+
description: str
|
21
|
+
|
22
|
+
# System stats
|
23
|
+
total_workflow_runs: int = 0
|
24
|
+
completed_runs: int = 0
|
25
|
+
in_progress_runs: int = 0
|
26
|
+
pending_human_tasks: int = 0
|
27
|
+
active_agents: int = 0
|
28
|
+
|
29
|
+
|
30
|
+
async def get_system_stats(session: AsyncSession = Depends(get_session)) -> dict:
|
31
|
+
"""
|
32
|
+
Get system-wide statistics directly from the database.
|
33
|
+
|
34
|
+
This optimizes the calculation by doing aggregations at the database level
|
35
|
+
rather than fetching all records and calculating in the application.
|
36
|
+
"""
|
37
|
+
try:
|
38
|
+
# Get workflow run counts
|
39
|
+
workflow_stats = await session.execute(
|
40
|
+
select(
|
41
|
+
func.count().label("total_runs"),
|
42
|
+
func.count(col(Workflow.id))
|
43
|
+
.filter(col(Workflow.status) == WorkflowStatus.SUCCEEDED)
|
44
|
+
.label("completed_runs"),
|
45
|
+
func.count(col(Workflow.id))
|
46
|
+
.filter(col(Workflow.status) == WorkflowStatus.PENDING)
|
47
|
+
.label("in_progress_runs"),
|
48
|
+
).select_from(Workflow)
|
49
|
+
)
|
50
|
+
workflow_row = workflow_stats.one()
|
51
|
+
|
52
|
+
# Get pending human task count
|
53
|
+
human_task_query = await session.execute(
|
54
|
+
select(func.count())
|
55
|
+
.select_from(HumanTask)
|
56
|
+
.where(HumanTask.status == HumanTaskStatus.PENDING)
|
57
|
+
)
|
58
|
+
pending_tasks = human_task_query.scalar() or 0
|
59
|
+
|
60
|
+
# Get agent count from the registry or count distinct agent configs
|
61
|
+
agent_count = 0
|
62
|
+
try:
|
63
|
+
# Count distinct agent names in the AgentConfig table
|
64
|
+
agent_query = await session.execute(
|
65
|
+
select(
|
66
|
+
func.count(distinct(ObjectConfiguration.object_name))
|
67
|
+
).select_from(
|
68
|
+
select(ObjectConfiguration)
|
69
|
+
.where(
|
70
|
+
ObjectConfiguration.object_type == ConfigurableObjectType.AGENT
|
71
|
+
)
|
72
|
+
.subquery()
|
73
|
+
)
|
74
|
+
)
|
75
|
+
agent_count = agent_query.scalar() or 0
|
76
|
+
except Exception:
|
77
|
+
logger.exception("error counting agents")
|
78
|
+
# Fallback to 0
|
79
|
+
agent_count = 0
|
80
|
+
|
81
|
+
# Return stats dict
|
82
|
+
return {
|
83
|
+
"total_workflow_runs": workflow_row.total_runs or 0,
|
84
|
+
"completed_runs": workflow_row.completed_runs or 0,
|
85
|
+
"in_progress_runs": workflow_row.in_progress_runs or 0,
|
86
|
+
"pending_human_tasks": pending_tasks,
|
87
|
+
"active_agents": agent_count,
|
88
|
+
}
|
89
|
+
except Exception:
|
90
|
+
logger.exception("error fetching system stats")
|
91
|
+
# Return default stats if there's an error
|
92
|
+
return {
|
93
|
+
"total_workflow_runs": 0,
|
94
|
+
"completed_runs": 0,
|
95
|
+
"in_progress_runs": 0,
|
96
|
+
"pending_human_tasks": 0,
|
97
|
+
"active_agents": 0,
|
98
|
+
}
|
99
|
+
|
100
|
+
|
101
|
+
def create_info_router(title: str, description: str) -> APIRouter:
|
102
|
+
"""
|
103
|
+
Create a router for serving combined application information and system statistics.
|
104
|
+
|
105
|
+
This router provides a single endpoint to retrieve the application's title,
|
106
|
+
description, and system-wide statistics on workflow runs, human tasks,
|
107
|
+
and registered agents.
|
108
|
+
|
109
|
+
Args:
|
110
|
+
title: The application title
|
111
|
+
description: The application description
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
An APIRouter instance with a combined info route
|
115
|
+
"""
|
116
|
+
router = APIRouter()
|
117
|
+
|
118
|
+
@router.get("/system-info", response_model=SystemInfo)
|
119
|
+
async def get_system_info(
|
120
|
+
session: AsyncSession = Depends(get_session),
|
121
|
+
) -> SystemInfo:
|
122
|
+
"""
|
123
|
+
Get combined application information and system statistics.
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
SystemInfo object containing app details and system stats
|
127
|
+
"""
|
128
|
+
stats = await get_system_stats(session)
|
129
|
+
return SystemInfo(title=title, description=description, **stats)
|
130
|
+
|
131
|
+
return router
|
planar/routers/models.py
ADDED
@@ -0,0 +1,170 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from enum import Enum
|
3
|
+
from typing import Any, Dict, List
|
4
|
+
from uuid import UUID
|
5
|
+
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
7
|
+
|
8
|
+
from planar.modeling.field_helpers import JsonSchema
|
9
|
+
from planar.workflows import Workflow
|
10
|
+
from planar.workflows.models import (
|
11
|
+
StepStatus,
|
12
|
+
StepType,
|
13
|
+
WorkflowStatus,
|
14
|
+
)
|
15
|
+
from planar.workflows.step_metadata import StepMetadata
|
16
|
+
|
17
|
+
|
18
|
+
class EntityMetadata(BaseModel):
|
19
|
+
name: str
|
20
|
+
description: str | None = None
|
21
|
+
json_schema: JsonSchema
|
22
|
+
instance_count: int = 0
|
23
|
+
|
24
|
+
|
25
|
+
class EntityInstance(BaseModel):
|
26
|
+
id: str
|
27
|
+
entity_name: str
|
28
|
+
data: dict[str, Any]
|
29
|
+
|
30
|
+
|
31
|
+
class EntityInstanceList(BaseModel):
|
32
|
+
items: List[EntityInstance]
|
33
|
+
total: int
|
34
|
+
offset: int
|
35
|
+
limit: int
|
36
|
+
|
37
|
+
|
38
|
+
class SortDirection(str, Enum):
|
39
|
+
"""Enum for sort direction options."""
|
40
|
+
|
41
|
+
ASC = "asc"
|
42
|
+
DESC = "desc"
|
43
|
+
|
44
|
+
|
45
|
+
# Models related to the workflow management REST API
|
46
|
+
class WorkflowStartResponse(BaseModel):
|
47
|
+
id: UUID
|
48
|
+
|
49
|
+
|
50
|
+
class WorkflowStatusResponse(BaseModel):
|
51
|
+
workflow: Workflow
|
52
|
+
|
53
|
+
|
54
|
+
class DurationStats(BaseModel):
|
55
|
+
min_seconds: int | None = None
|
56
|
+
avg_seconds: int | None = None
|
57
|
+
max_seconds: int | None = None
|
58
|
+
|
59
|
+
|
60
|
+
class WorkflowRunStatusCounts(BaseModel):
|
61
|
+
"""Type-safe representation of workflow run status counts."""
|
62
|
+
|
63
|
+
# Virtual statuses (computed, never persisted)
|
64
|
+
running: int = 0
|
65
|
+
suspended: int = 0
|
66
|
+
|
67
|
+
# Persisted statuses (stored in database)
|
68
|
+
pending: int = 0
|
69
|
+
succeeded: int = 0
|
70
|
+
failed: int = 0
|
71
|
+
|
72
|
+
|
73
|
+
class WorkflowDefinition(BaseModel):
|
74
|
+
fully_qualified_name: str
|
75
|
+
name: str
|
76
|
+
description: str | None = None
|
77
|
+
input_schema: JsonSchema | None = None
|
78
|
+
output_schema: JsonSchema | None = None
|
79
|
+
total_runs: int
|
80
|
+
run_statuses: WorkflowRunStatusCounts
|
81
|
+
durations: DurationStats | None = None
|
82
|
+
|
83
|
+
|
84
|
+
class WorkflowRun(BaseModel):
|
85
|
+
id: UUID
|
86
|
+
status: WorkflowStatus
|
87
|
+
args: List[Any] | None = None
|
88
|
+
kwargs: Dict[str, Any] | None = None
|
89
|
+
result: Any | None = None
|
90
|
+
error: Dict[str, Any] | None = None
|
91
|
+
created_at: datetime
|
92
|
+
updated_at: datetime
|
93
|
+
|
94
|
+
|
95
|
+
class WorkflowStepInfo(BaseModel):
|
96
|
+
step_id: int
|
97
|
+
is_internal_step: bool = Field(
|
98
|
+
default=False,
|
99
|
+
description="Whether the step is an internal Planar step, or a user-defined step",
|
100
|
+
)
|
101
|
+
parent_step_id: int | None = None
|
102
|
+
workflow_id: UUID
|
103
|
+
function_name: str
|
104
|
+
display_name: str
|
105
|
+
description: str | None = None
|
106
|
+
step_type: StepType
|
107
|
+
status: StepStatus
|
108
|
+
args: List[Any] | None = None
|
109
|
+
kwargs: Dict[str, Any] | None = None
|
110
|
+
result: Any | None = None
|
111
|
+
error: Dict[str, Any] | None = None
|
112
|
+
retry_count: int
|
113
|
+
created_at: datetime
|
114
|
+
updated_at: datetime
|
115
|
+
meta: StepMetadata | None = Field(
|
116
|
+
default=None,
|
117
|
+
description="Step type-specific rich data (e.g., human task details for human_in_the_loop steps)",
|
118
|
+
)
|
119
|
+
|
120
|
+
def model_post_init(self, __context: Any) -> None:
|
121
|
+
if self.function_name.startswith("planar."):
|
122
|
+
self.is_internal_step = True
|
123
|
+
|
124
|
+
@staticmethod
|
125
|
+
def get_display_name(custom_name: str | None, function_name: str) -> str:
|
126
|
+
"""
|
127
|
+
If provided, use custom name, otherwise extract function name from a "fully qualified" function name.
|
128
|
+
|
129
|
+
For example, 'module.directory.fn_name' becomes 'fn_name'.
|
130
|
+
If there are no periods in the string, returns the original string.
|
131
|
+
"""
|
132
|
+
return custom_name or function_name.split(".")[-1]
|
133
|
+
|
134
|
+
@model_validator(mode="after")
|
135
|
+
def validate_meta_step_type(self):
|
136
|
+
"""
|
137
|
+
Make sure the outer step_type agrees with whatever subtype was
|
138
|
+
chosen for `meta`. This runs *after* normal field validation,
|
139
|
+
so `self.meta` is already an instantiated metadata object.
|
140
|
+
"""
|
141
|
+
if self.meta is None:
|
142
|
+
return self # nothing to compare
|
143
|
+
|
144
|
+
if self.step_type != self.meta.step_type:
|
145
|
+
raise ValueError(
|
146
|
+
f"meta.step_type={self.meta.step_type!r} does not match "
|
147
|
+
f"outer step_type={self.step_type!r}"
|
148
|
+
)
|
149
|
+
return self
|
150
|
+
|
151
|
+
|
152
|
+
class WorkflowRunList(BaseModel):
|
153
|
+
items: List[WorkflowRun]
|
154
|
+
total: int
|
155
|
+
offset: int | None
|
156
|
+
limit: int | None
|
157
|
+
|
158
|
+
|
159
|
+
class WorkflowStepList(BaseModel):
|
160
|
+
items: List[WorkflowStepInfo]
|
161
|
+
total: int
|
162
|
+
offset: int | None
|
163
|
+
limit: int | None
|
164
|
+
|
165
|
+
|
166
|
+
class WorkflowList(BaseModel):
|
167
|
+
items: List[WorkflowDefinition]
|
168
|
+
total: int
|
169
|
+
offset: int | None
|
170
|
+
limit: int | None
|
@@ -0,0 +1,133 @@
|
|
1
|
+
"""
|
2
|
+
Router for object configuration operations.
|
3
|
+
|
4
|
+
This module contains endpoints for managing object configurations across
|
5
|
+
different object types (agents, rules, etc.).
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Generic, TypeVar, cast
|
9
|
+
from uuid import UUID
|
10
|
+
|
11
|
+
from fastapi import APIRouter, Body, HTTPException
|
12
|
+
from pydantic import BaseModel
|
13
|
+
|
14
|
+
from planar.ai.agent_utils import agent_configuration
|
15
|
+
from planar.logging import get_logger
|
16
|
+
from planar.object_config import (
|
17
|
+
DEFAULT_UUID,
|
18
|
+
ConfigNotFoundError,
|
19
|
+
ConfigurableObjectType,
|
20
|
+
ObjectConfigurationBase,
|
21
|
+
)
|
22
|
+
from planar.object_config.object_config import ConfigValidationError
|
23
|
+
from planar.object_registry import ObjectRegistry
|
24
|
+
from planar.rules.rule_configuration import rule_configuration
|
25
|
+
|
26
|
+
T = TypeVar("T", bound=BaseModel)
|
27
|
+
|
28
|
+
logger = get_logger(__name__)
|
29
|
+
|
30
|
+
|
31
|
+
class PromoteConfigRequest(BaseModel):
|
32
|
+
"""Request model for promoting a configuration."""
|
33
|
+
|
34
|
+
object_type: ConfigurableObjectType
|
35
|
+
config_id: UUID
|
36
|
+
object_name: str
|
37
|
+
|
38
|
+
|
39
|
+
class ObjectConfigurationResponse(BaseModel, Generic[T]):
|
40
|
+
"""Response model for object configuration endpoints that includes schema warnings."""
|
41
|
+
|
42
|
+
configs: list[T]
|
43
|
+
|
44
|
+
|
45
|
+
def create_object_config_router(object_registry: ObjectRegistry) -> APIRouter:
|
46
|
+
"""Create the object configuration router with all endpoints."""
|
47
|
+
router = APIRouter(tags=["Object Configuration"])
|
48
|
+
|
49
|
+
@router.post("/promote", response_model=ObjectConfigurationResponse)
|
50
|
+
async def promote_config(request: PromoteConfigRequest = Body(...)):
|
51
|
+
"""Promote a specific configuration to be the active one.
|
52
|
+
|
53
|
+
Use config_id '00000000-0000-0000-0000-000000000000' to revert to default implementation.
|
54
|
+
Supports both rule and agent configurations.
|
55
|
+
"""
|
56
|
+
# Handle special case for default UUID (all zeros)
|
57
|
+
entity = None
|
58
|
+
if request.object_type == ConfigurableObjectType.RULE:
|
59
|
+
# Validate that the rule exists
|
60
|
+
rules = object_registry.get_rules()
|
61
|
+
entity = next(
|
62
|
+
(d for d in rules if d.name == request.object_name),
|
63
|
+
None,
|
64
|
+
)
|
65
|
+
if not entity:
|
66
|
+
raise HTTPException(status_code=404, detail="Rule not found")
|
67
|
+
|
68
|
+
if request.object_type == ConfigurableObjectType.AGENT:
|
69
|
+
# Validate that the agent exists
|
70
|
+
agents = object_registry.get_agents()
|
71
|
+
entity = next(
|
72
|
+
(a for a in agents if a.name == request.object_name),
|
73
|
+
None,
|
74
|
+
)
|
75
|
+
if not entity:
|
76
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
77
|
+
|
78
|
+
config_manager = (
|
79
|
+
rule_configuration
|
80
|
+
if request.object_type == ConfigurableObjectType.RULE
|
81
|
+
else agent_configuration
|
82
|
+
)
|
83
|
+
|
84
|
+
try:
|
85
|
+
if request.config_id == DEFAULT_UUID:
|
86
|
+
logger.info(
|
87
|
+
"reverting to default configuration",
|
88
|
+
object_type=request.object_type,
|
89
|
+
object_name=request.object_name,
|
90
|
+
)
|
91
|
+
await config_manager.promote_config(
|
92
|
+
request.config_id, object_name=request.object_name
|
93
|
+
)
|
94
|
+
else:
|
95
|
+
logger.info(
|
96
|
+
"promoting configuration",
|
97
|
+
config_id=request.config_id,
|
98
|
+
object_type=request.object_type,
|
99
|
+
object_name=request.object_name,
|
100
|
+
)
|
101
|
+
await config_manager.promote_config(request.config_id)
|
102
|
+
except ConfigNotFoundError as e:
|
103
|
+
logger.exception("configuration not found during promotion")
|
104
|
+
raise HTTPException(
|
105
|
+
status_code=404,
|
106
|
+
detail=f"Configuration with ID {e.invalid_id} and object_type {e.object_type} not found",
|
107
|
+
)
|
108
|
+
except ConfigValidationError as e:
|
109
|
+
logger.exception("configuration validation failed during promotion")
|
110
|
+
raise HTTPException(
|
111
|
+
status_code=400,
|
112
|
+
detail=e.to_api_response().model_dump(mode="json", by_alias=True),
|
113
|
+
)
|
114
|
+
|
115
|
+
if entity is None:
|
116
|
+
# This case should ideally be caught by earlier checks
|
117
|
+
logger.warning(
|
118
|
+
"object not found after validation for promotion",
|
119
|
+
object_type=request.object_type,
|
120
|
+
object_name=request.object_name,
|
121
|
+
)
|
122
|
+
raise HTTPException(status_code=404, detail="Object not found")
|
123
|
+
|
124
|
+
configs_list = await config_manager.read_configs_with_default(
|
125
|
+
request.object_name,
|
126
|
+
entity.to_config(), # type: ignore
|
127
|
+
)
|
128
|
+
|
129
|
+
return ObjectConfigurationResponse(
|
130
|
+
configs=cast(list[ObjectConfigurationBase], configs_list),
|
131
|
+
)
|
132
|
+
|
133
|
+
return router
|
planar/routers/rule.py
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
from fastapi import APIRouter, Body, HTTPException
|
2
|
+
from pydantic import BaseModel
|
3
|
+
|
4
|
+
from planar.logging import get_logger
|
5
|
+
from planar.object_config.object_config import (
|
6
|
+
ConfigValidationError,
|
7
|
+
ConfigValidationErrorResponse,
|
8
|
+
)
|
9
|
+
from planar.object_registry import ObjectRegistry
|
10
|
+
from planar.rules import RuleSerializeable
|
11
|
+
from planar.rules.models import JDMGraph, Rule, RuleEngineConfig
|
12
|
+
from planar.rules.rule_configuration import rule_configuration
|
13
|
+
from planar.rules.runner import (
|
14
|
+
EvaluateError,
|
15
|
+
EvaluateResponse,
|
16
|
+
evaluate_rule,
|
17
|
+
)
|
18
|
+
from planar.security.authorization import (
|
19
|
+
RuleAction,
|
20
|
+
RuleResource,
|
21
|
+
validate_authorization_for,
|
22
|
+
)
|
23
|
+
|
24
|
+
logger = get_logger(__name__)
|
25
|
+
|
26
|
+
|
27
|
+
class EvaluateRuleRequest(BaseModel):
|
28
|
+
input: dict
|
29
|
+
graph: JDMGraph
|
30
|
+
|
31
|
+
|
32
|
+
def create_rule_router(object_registry: ObjectRegistry) -> APIRouter:
|
33
|
+
router = APIRouter(tags=["Rules"])
|
34
|
+
|
35
|
+
@router.get("/", response_model=list[RuleSerializeable])
|
36
|
+
async def get_rules():
|
37
|
+
validate_authorization_for(RuleResource(), RuleAction.RULE_LIST)
|
38
|
+
rules = object_registry.get_rules()
|
39
|
+
|
40
|
+
return [await into_rule_serializeable(rule) for rule in rules]
|
41
|
+
|
42
|
+
@router.get("/{rule_name}", response_model=RuleSerializeable)
|
43
|
+
async def get_rule(rule_name: str):
|
44
|
+
validate_authorization_for(
|
45
|
+
RuleResource(rule_name), RuleAction.RULE_VIEW_DETAILS
|
46
|
+
)
|
47
|
+
rules = object_registry.get_rules()
|
48
|
+
rule = next((d for d in rules if d.name == rule_name), None)
|
49
|
+
|
50
|
+
if not rule:
|
51
|
+
raise HTTPException(status_code=404, detail="rule not found")
|
52
|
+
|
53
|
+
return await into_rule_serializeable(rule)
|
54
|
+
|
55
|
+
@router.post("/simulate", response_model=EvaluateResponse | EvaluateError)
|
56
|
+
async def simulate_rule(request: EvaluateRuleRequest = Body(...)):
|
57
|
+
validate_authorization_for(RuleResource(), RuleAction.RULE_SIMULATE)
|
58
|
+
return evaluate_rule(request.graph, request.input)
|
59
|
+
|
60
|
+
@router.post(
|
61
|
+
"/{rule_name}",
|
62
|
+
response_model=RuleSerializeable,
|
63
|
+
responses={
|
64
|
+
400: {
|
65
|
+
"model": ConfigValidationErrorResponse,
|
66
|
+
"description": "Configuration validation failed",
|
67
|
+
},
|
68
|
+
404: {"description": "Rule not found"},
|
69
|
+
},
|
70
|
+
)
|
71
|
+
async def save_rule_override(rule_name: str, jdm: JDMGraph = Body(...)):
|
72
|
+
validate_authorization_for(RuleResource(rule_name), RuleAction.RULE_UPDATE)
|
73
|
+
rules = object_registry.get_rules()
|
74
|
+
rule = next((d for d in rules if d.name == rule_name), None)
|
75
|
+
|
76
|
+
if not rule:
|
77
|
+
raise HTTPException(status_code=404, detail="rule not found")
|
78
|
+
|
79
|
+
# Create the rule configuration
|
80
|
+
rule_config = RuleEngineConfig(jdm=jdm)
|
81
|
+
|
82
|
+
try:
|
83
|
+
await rule_configuration.write_config(rule_name, rule_config)
|
84
|
+
except ConfigValidationError as e:
|
85
|
+
raise HTTPException(
|
86
|
+
status_code=400,
|
87
|
+
detail=e.to_api_response().model_dump(mode="json", by_alias=True),
|
88
|
+
)
|
89
|
+
|
90
|
+
logger.info("rule override saved", rule_name=rule_name)
|
91
|
+
|
92
|
+
return await into_rule_serializeable(rule)
|
93
|
+
|
94
|
+
return router
|
95
|
+
|
96
|
+
|
97
|
+
async def into_rule_serializeable(rule: Rule) -> RuleSerializeable:
|
98
|
+
config_list = await rule_configuration.read_configs_with_default(
|
99
|
+
rule.name, rule.to_config()
|
100
|
+
)
|
101
|
+
|
102
|
+
return RuleSerializeable(
|
103
|
+
input_schema=rule.input.model_json_schema(),
|
104
|
+
output_schema=rule.output.model_json_schema(),
|
105
|
+
name=rule.name,
|
106
|
+
description=rule.description,
|
107
|
+
configs=config_list,
|
108
|
+
)
|