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.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -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
@@ -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
+ )