kubiya-control-plane-api 0.9.15__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 (479) hide show
  1. control_plane_api/LICENSE +676 -0
  2. control_plane_api/README.md +350 -0
  3. control_plane_api/__init__.py +4 -0
  4. control_plane_api/__version__.py +8 -0
  5. control_plane_api/alembic/README +1 -0
  6. control_plane_api/alembic/env.py +121 -0
  7. control_plane_api/alembic/script.py.mako +28 -0
  8. control_plane_api/alembic/versions/2613c65c3dbe_initial_database_setup.py +32 -0
  9. control_plane_api/alembic/versions/2df520d4927d_merge_heads.py +28 -0
  10. control_plane_api/alembic/versions/43abf98d6a01_add_paused_status_to_executions.py +73 -0
  11. control_plane_api/alembic/versions/6289854264cb_merge_multiple_heads.py +28 -0
  12. control_plane_api/alembic/versions/6a4d4dc3d8dc_generate_execution_transitions.py +50 -0
  13. control_plane_api/alembic/versions/87d11cf0a783_add_disconnected_status_to_worker_.py +44 -0
  14. control_plane_api/alembic/versions/add_ephemeral_queue_support.py +85 -0
  15. control_plane_api/alembic/versions/add_model_type_to_llm_models.py +31 -0
  16. control_plane_api/alembic/versions/add_plan_executions_table.py +114 -0
  17. control_plane_api/alembic/versions/add_trace_span_tables.py +154 -0
  18. control_plane_api/alembic/versions/add_user_info_to_traces.py +36 -0
  19. control_plane_api/alembic/versions/adjusting_foreign_keys.py +32 -0
  20. control_plane_api/alembic/versions/b4983d976db2_initial_tables.py +1128 -0
  21. control_plane_api/alembic/versions/d181a3b40e71_rename_custom_metadata_to_metadata_in_.py +50 -0
  22. control_plane_api/alembic/versions/df9117888e82_add_missing_columns.py +82 -0
  23. control_plane_api/alembic/versions/f25de6ad895a_missing_migrations.py +34 -0
  24. control_plane_api/alembic/versions/f71305fb69b9_fix_ephemeral_queue_deletion_foreign_key.py +54 -0
  25. control_plane_api/alembic/versions/mark_local_exec_queues_as_ephemeral.py +68 -0
  26. control_plane_api/alembic.ini +148 -0
  27. control_plane_api/api/index.py +12 -0
  28. control_plane_api/app/__init__.py +11 -0
  29. control_plane_api/app/activities/__init__.py +20 -0
  30. control_plane_api/app/activities/agent_activities.py +384 -0
  31. control_plane_api/app/activities/plan_generation_activities.py +499 -0
  32. control_plane_api/app/activities/team_activities.py +424 -0
  33. control_plane_api/app/activities/temporal_cloud_activities.py +588 -0
  34. control_plane_api/app/config/__init__.py +35 -0
  35. control_plane_api/app/config/api_config.py +469 -0
  36. control_plane_api/app/config/config_loader.py +224 -0
  37. control_plane_api/app/config/model_pricing.py +323 -0
  38. control_plane_api/app/config/storage_config.py +159 -0
  39. control_plane_api/app/config.py +115 -0
  40. control_plane_api/app/controllers/__init__.py +0 -0
  41. control_plane_api/app/controllers/execution_environment_controller.py +1315 -0
  42. control_plane_api/app/database.py +135 -0
  43. control_plane_api/app/exceptions.py +408 -0
  44. control_plane_api/app/lib/__init__.py +11 -0
  45. control_plane_api/app/lib/environment.py +65 -0
  46. control_plane_api/app/lib/event_bus/__init__.py +17 -0
  47. control_plane_api/app/lib/event_bus/base.py +136 -0
  48. control_plane_api/app/lib/event_bus/manager.py +335 -0
  49. control_plane_api/app/lib/event_bus/providers/__init__.py +6 -0
  50. control_plane_api/app/lib/event_bus/providers/http_provider.py +166 -0
  51. control_plane_api/app/lib/event_bus/providers/nats_provider.py +324 -0
  52. control_plane_api/app/lib/event_bus/providers/redis_provider.py +233 -0
  53. control_plane_api/app/lib/event_bus/providers/websocket_provider.py +497 -0
  54. control_plane_api/app/lib/job_executor.py +330 -0
  55. control_plane_api/app/lib/kubiya_client.py +293 -0
  56. control_plane_api/app/lib/litellm_pricing.py +166 -0
  57. control_plane_api/app/lib/mcp_validation.py +163 -0
  58. control_plane_api/app/lib/nats/__init__.py +13 -0
  59. control_plane_api/app/lib/nats/credentials_manager.py +288 -0
  60. control_plane_api/app/lib/nats/listener.py +374 -0
  61. control_plane_api/app/lib/planning_prompt_builder.py +153 -0
  62. control_plane_api/app/lib/planning_tools/__init__.py +41 -0
  63. control_plane_api/app/lib/planning_tools/agents.py +409 -0
  64. control_plane_api/app/lib/planning_tools/agno_toolkit.py +836 -0
  65. control_plane_api/app/lib/planning_tools/base.py +119 -0
  66. control_plane_api/app/lib/planning_tools/cognitive_memory_tools.py +403 -0
  67. control_plane_api/app/lib/planning_tools/context_graph_tools.py +545 -0
  68. control_plane_api/app/lib/planning_tools/environments.py +218 -0
  69. control_plane_api/app/lib/planning_tools/knowledge.py +204 -0
  70. control_plane_api/app/lib/planning_tools/models.py +93 -0
  71. control_plane_api/app/lib/planning_tools/planning_service.py +646 -0
  72. control_plane_api/app/lib/planning_tools/resources.py +242 -0
  73. control_plane_api/app/lib/planning_tools/teams.py +334 -0
  74. control_plane_api/app/lib/policy_enforcer_client.py +1016 -0
  75. control_plane_api/app/lib/redis_client.py +803 -0
  76. control_plane_api/app/lib/sqlalchemy_utils.py +486 -0
  77. control_plane_api/app/lib/state_transition_tools/__init__.py +7 -0
  78. control_plane_api/app/lib/state_transition_tools/execution_context.py +388 -0
  79. control_plane_api/app/lib/storage/__init__.py +20 -0
  80. control_plane_api/app/lib/storage/base_provider.py +274 -0
  81. control_plane_api/app/lib/storage/provider_factory.py +157 -0
  82. control_plane_api/app/lib/storage/vercel_blob_provider.py +468 -0
  83. control_plane_api/app/lib/supabase.py +71 -0
  84. control_plane_api/app/lib/supabase_utils.py +138 -0
  85. control_plane_api/app/lib/task_planning/__init__.py +138 -0
  86. control_plane_api/app/lib/task_planning/agent_factory.py +308 -0
  87. control_plane_api/app/lib/task_planning/agents.py +389 -0
  88. control_plane_api/app/lib/task_planning/cache.py +218 -0
  89. control_plane_api/app/lib/task_planning/entity_resolver.py +273 -0
  90. control_plane_api/app/lib/task_planning/helpers.py +293 -0
  91. control_plane_api/app/lib/task_planning/hooks.py +474 -0
  92. control_plane_api/app/lib/task_planning/models.py +503 -0
  93. control_plane_api/app/lib/task_planning/plan_validator.py +166 -0
  94. control_plane_api/app/lib/task_planning/planning_workflow.py +2911 -0
  95. control_plane_api/app/lib/task_planning/runner.py +656 -0
  96. control_plane_api/app/lib/task_planning/streaming_hook.py +213 -0
  97. control_plane_api/app/lib/task_planning/workflow.py +424 -0
  98. control_plane_api/app/lib/templating/__init__.py +88 -0
  99. control_plane_api/app/lib/templating/compiler.py +278 -0
  100. control_plane_api/app/lib/templating/engine.py +178 -0
  101. control_plane_api/app/lib/templating/parsers/__init__.py +29 -0
  102. control_plane_api/app/lib/templating/parsers/base.py +96 -0
  103. control_plane_api/app/lib/templating/parsers/env.py +85 -0
  104. control_plane_api/app/lib/templating/parsers/graph.py +112 -0
  105. control_plane_api/app/lib/templating/parsers/secret.py +87 -0
  106. control_plane_api/app/lib/templating/parsers/simple.py +81 -0
  107. control_plane_api/app/lib/templating/resolver.py +366 -0
  108. control_plane_api/app/lib/templating/types.py +214 -0
  109. control_plane_api/app/lib/templating/validator.py +201 -0
  110. control_plane_api/app/lib/temporal_client.py +232 -0
  111. control_plane_api/app/lib/temporal_credentials_cache.py +178 -0
  112. control_plane_api/app/lib/temporal_credentials_service.py +203 -0
  113. control_plane_api/app/lib/validation/__init__.py +24 -0
  114. control_plane_api/app/lib/validation/runtime_validation.py +388 -0
  115. control_plane_api/app/main.py +531 -0
  116. control_plane_api/app/middleware/__init__.py +10 -0
  117. control_plane_api/app/middleware/auth.py +645 -0
  118. control_plane_api/app/middleware/exception_handler.py +267 -0
  119. control_plane_api/app/middleware/prometheus_middleware.py +173 -0
  120. control_plane_api/app/middleware/rate_limiting.py +384 -0
  121. control_plane_api/app/middleware/request_id.py +202 -0
  122. control_plane_api/app/models/__init__.py +40 -0
  123. control_plane_api/app/models/agent.py +90 -0
  124. control_plane_api/app/models/analytics.py +206 -0
  125. control_plane_api/app/models/associations.py +107 -0
  126. control_plane_api/app/models/auth_user.py +73 -0
  127. control_plane_api/app/models/context.py +161 -0
  128. control_plane_api/app/models/custom_integration.py +99 -0
  129. control_plane_api/app/models/environment.py +64 -0
  130. control_plane_api/app/models/execution.py +125 -0
  131. control_plane_api/app/models/execution_transition.py +50 -0
  132. control_plane_api/app/models/job.py +159 -0
  133. control_plane_api/app/models/llm_model.py +78 -0
  134. control_plane_api/app/models/orchestration.py +66 -0
  135. control_plane_api/app/models/plan_execution.py +102 -0
  136. control_plane_api/app/models/presence.py +49 -0
  137. control_plane_api/app/models/project.py +61 -0
  138. control_plane_api/app/models/project_management.py +85 -0
  139. control_plane_api/app/models/session.py +29 -0
  140. control_plane_api/app/models/skill.py +155 -0
  141. control_plane_api/app/models/system_tables.py +43 -0
  142. control_plane_api/app/models/task_planning.py +372 -0
  143. control_plane_api/app/models/team.py +86 -0
  144. control_plane_api/app/models/trace.py +257 -0
  145. control_plane_api/app/models/user_profile.py +54 -0
  146. control_plane_api/app/models/worker.py +221 -0
  147. control_plane_api/app/models/workflow.py +161 -0
  148. control_plane_api/app/models/workspace.py +50 -0
  149. control_plane_api/app/observability/__init__.py +177 -0
  150. control_plane_api/app/observability/context_logging.py +475 -0
  151. control_plane_api/app/observability/decorators.py +337 -0
  152. control_plane_api/app/observability/local_span_processor.py +702 -0
  153. control_plane_api/app/observability/metrics.py +303 -0
  154. control_plane_api/app/observability/middleware.py +246 -0
  155. control_plane_api/app/observability/optional.py +115 -0
  156. control_plane_api/app/observability/tracing.py +382 -0
  157. control_plane_api/app/policies/README.md +149 -0
  158. control_plane_api/app/policies/approved_users.rego +62 -0
  159. control_plane_api/app/policies/business_hours.rego +51 -0
  160. control_plane_api/app/policies/rate_limiting.rego +100 -0
  161. control_plane_api/app/policies/tool_enforcement/README.md +336 -0
  162. control_plane_api/app/policies/tool_enforcement/bash_command_validation.rego +71 -0
  163. control_plane_api/app/policies/tool_enforcement/business_hours_enforcement.rego +82 -0
  164. control_plane_api/app/policies/tool_enforcement/mcp_tool_allowlist.rego +58 -0
  165. control_plane_api/app/policies/tool_enforcement/production_safeguards.rego +80 -0
  166. control_plane_api/app/policies/tool_enforcement/role_based_tool_access.rego +44 -0
  167. control_plane_api/app/policies/tool_restrictions.rego +86 -0
  168. control_plane_api/app/routers/__init__.py +4 -0
  169. control_plane_api/app/routers/agents.py +382 -0
  170. control_plane_api/app/routers/agents_v2.py +1598 -0
  171. control_plane_api/app/routers/analytics.py +1310 -0
  172. control_plane_api/app/routers/auth.py +59 -0
  173. control_plane_api/app/routers/client_config.py +57 -0
  174. control_plane_api/app/routers/context_graph.py +561 -0
  175. control_plane_api/app/routers/context_manager.py +577 -0
  176. control_plane_api/app/routers/custom_integrations.py +490 -0
  177. control_plane_api/app/routers/enforcer.py +132 -0
  178. control_plane_api/app/routers/environment_context.py +252 -0
  179. control_plane_api/app/routers/environments.py +761 -0
  180. control_plane_api/app/routers/execution_environment.py +847 -0
  181. control_plane_api/app/routers/executions/__init__.py +28 -0
  182. control_plane_api/app/routers/executions/router.py +286 -0
  183. control_plane_api/app/routers/executions/services/__init__.py +22 -0
  184. control_plane_api/app/routers/executions/services/demo_worker_health.py +156 -0
  185. control_plane_api/app/routers/executions/services/status_service.py +420 -0
  186. control_plane_api/app/routers/executions/services/test_worker_health.py +480 -0
  187. control_plane_api/app/routers/executions/services/worker_health.py +514 -0
  188. control_plane_api/app/routers/executions/streaming/__init__.py +22 -0
  189. control_plane_api/app/routers/executions/streaming/deduplication.py +352 -0
  190. control_plane_api/app/routers/executions/streaming/event_buffer.py +353 -0
  191. control_plane_api/app/routers/executions/streaming/event_formatter.py +964 -0
  192. control_plane_api/app/routers/executions/streaming/history_loader.py +588 -0
  193. control_plane_api/app/routers/executions/streaming/live_source.py +693 -0
  194. control_plane_api/app/routers/executions/streaming/streamer.py +849 -0
  195. control_plane_api/app/routers/executions.py +4888 -0
  196. control_plane_api/app/routers/health.py +165 -0
  197. control_plane_api/app/routers/health_v2.py +394 -0
  198. control_plane_api/app/routers/integration_templates.py +496 -0
  199. control_plane_api/app/routers/integrations.py +287 -0
  200. control_plane_api/app/routers/jobs.py +1809 -0
  201. control_plane_api/app/routers/metrics.py +517 -0
  202. control_plane_api/app/routers/models.py +82 -0
  203. control_plane_api/app/routers/models_v2.py +628 -0
  204. control_plane_api/app/routers/plan_executions.py +1481 -0
  205. control_plane_api/app/routers/plan_generation_async.py +304 -0
  206. control_plane_api/app/routers/policies.py +669 -0
  207. control_plane_api/app/routers/presence.py +234 -0
  208. control_plane_api/app/routers/projects.py +987 -0
  209. control_plane_api/app/routers/runners.py +379 -0
  210. control_plane_api/app/routers/runtimes.py +172 -0
  211. control_plane_api/app/routers/secrets.py +171 -0
  212. control_plane_api/app/routers/skills.py +1010 -0
  213. control_plane_api/app/routers/skills_definitions.py +140 -0
  214. control_plane_api/app/routers/storage.py +456 -0
  215. control_plane_api/app/routers/task_planning.py +611 -0
  216. control_plane_api/app/routers/task_queues.py +650 -0
  217. control_plane_api/app/routers/team_context.py +274 -0
  218. control_plane_api/app/routers/teams.py +1747 -0
  219. control_plane_api/app/routers/templates.py +248 -0
  220. control_plane_api/app/routers/traces.py +571 -0
  221. control_plane_api/app/routers/websocket_client.py +479 -0
  222. control_plane_api/app/routers/websocket_executions_status.py +437 -0
  223. control_plane_api/app/routers/websocket_gateway.py +323 -0
  224. control_plane_api/app/routers/websocket_traces.py +576 -0
  225. control_plane_api/app/routers/worker_queues.py +2555 -0
  226. control_plane_api/app/routers/worker_websocket.py +419 -0
  227. control_plane_api/app/routers/workers.py +1004 -0
  228. control_plane_api/app/routers/workflows.py +204 -0
  229. control_plane_api/app/runtimes/__init__.py +6 -0
  230. control_plane_api/app/runtimes/validation.py +344 -0
  231. control_plane_api/app/schemas/__init__.py +1 -0
  232. control_plane_api/app/schemas/job_schemas.py +302 -0
  233. control_plane_api/app/schemas/mcp_schemas.py +311 -0
  234. control_plane_api/app/schemas/template_schemas.py +133 -0
  235. control_plane_api/app/schemas/trace_schemas.py +168 -0
  236. control_plane_api/app/schemas/worker_queue_observability_schemas.py +165 -0
  237. control_plane_api/app/services/__init__.py +1 -0
  238. control_plane_api/app/services/agno_planning_strategy.py +233 -0
  239. control_plane_api/app/services/agno_service.py +838 -0
  240. control_plane_api/app/services/claude_code_planning_service.py +203 -0
  241. control_plane_api/app/services/context_graph_client.py +224 -0
  242. control_plane_api/app/services/custom_integration_service.py +415 -0
  243. control_plane_api/app/services/integration_resolution_service.py +345 -0
  244. control_plane_api/app/services/litellm_service.py +394 -0
  245. control_plane_api/app/services/plan_generator.py +79 -0
  246. control_plane_api/app/services/planning_strategy.py +66 -0
  247. control_plane_api/app/services/planning_strategy_factory.py +118 -0
  248. control_plane_api/app/services/policy_service.py +615 -0
  249. control_plane_api/app/services/state_transition_service.py +755 -0
  250. control_plane_api/app/services/storage_service.py +593 -0
  251. control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
  252. control_plane_api/app/services/toolsets/context_graph_skill.py +432 -0
  253. control_plane_api/app/services/trace_retention.py +354 -0
  254. control_plane_api/app/services/worker_queue_metrics_service.py +190 -0
  255. control_plane_api/app/services/workflow_cancellation_manager.py +135 -0
  256. control_plane_api/app/services/workflow_operations_service.py +611 -0
  257. control_plane_api/app/skills/__init__.py +100 -0
  258. control_plane_api/app/skills/base.py +239 -0
  259. control_plane_api/app/skills/builtin/__init__.py +37 -0
  260. control_plane_api/app/skills/builtin/agent_communication/__init__.py +8 -0
  261. control_plane_api/app/skills/builtin/agent_communication/skill.py +246 -0
  262. control_plane_api/app/skills/builtin/code_ingestion/__init__.py +4 -0
  263. control_plane_api/app/skills/builtin/code_ingestion/skill.py +267 -0
  264. control_plane_api/app/skills/builtin/cognitive_memory/__init__.py +4 -0
  265. control_plane_api/app/skills/builtin/cognitive_memory/skill.py +174 -0
  266. control_plane_api/app/skills/builtin/contextual_awareness/__init__.py +4 -0
  267. control_plane_api/app/skills/builtin/contextual_awareness/skill.py +387 -0
  268. control_plane_api/app/skills/builtin/data_visualization/__init__.py +4 -0
  269. control_plane_api/app/skills/builtin/data_visualization/skill.py +154 -0
  270. control_plane_api/app/skills/builtin/docker/__init__.py +4 -0
  271. control_plane_api/app/skills/builtin/docker/skill.py +104 -0
  272. control_plane_api/app/skills/builtin/file_generation/__init__.py +4 -0
  273. control_plane_api/app/skills/builtin/file_generation/skill.py +94 -0
  274. control_plane_api/app/skills/builtin/file_system/__init__.py +4 -0
  275. control_plane_api/app/skills/builtin/file_system/skill.py +110 -0
  276. control_plane_api/app/skills/builtin/knowledge_api/__init__.py +5 -0
  277. control_plane_api/app/skills/builtin/knowledge_api/skill.py +124 -0
  278. control_plane_api/app/skills/builtin/python/__init__.py +4 -0
  279. control_plane_api/app/skills/builtin/python/skill.py +92 -0
  280. control_plane_api/app/skills/builtin/remote_filesystem/__init__.py +5 -0
  281. control_plane_api/app/skills/builtin/remote_filesystem/skill.py +170 -0
  282. control_plane_api/app/skills/builtin/shell/__init__.py +4 -0
  283. control_plane_api/app/skills/builtin/shell/skill.py +161 -0
  284. control_plane_api/app/skills/builtin/slack/__init__.py +3 -0
  285. control_plane_api/app/skills/builtin/slack/skill.py +302 -0
  286. control_plane_api/app/skills/builtin/workflow_executor/__init__.py +4 -0
  287. control_plane_api/app/skills/builtin/workflow_executor/skill.py +469 -0
  288. control_plane_api/app/skills/business_intelligence.py +189 -0
  289. control_plane_api/app/skills/config.py +63 -0
  290. control_plane_api/app/skills/loaders/__init__.py +14 -0
  291. control_plane_api/app/skills/loaders/base.py +73 -0
  292. control_plane_api/app/skills/loaders/filesystem_loader.py +199 -0
  293. control_plane_api/app/skills/registry.py +125 -0
  294. control_plane_api/app/utils/helpers.py +12 -0
  295. control_plane_api/app/utils/workflow_executor.py +354 -0
  296. control_plane_api/app/workflows/__init__.py +11 -0
  297. control_plane_api/app/workflows/agent_execution.py +520 -0
  298. control_plane_api/app/workflows/agent_execution_with_skills.py +223 -0
  299. control_plane_api/app/workflows/namespace_provisioning.py +326 -0
  300. control_plane_api/app/workflows/plan_generation.py +254 -0
  301. control_plane_api/app/workflows/team_execution.py +442 -0
  302. control_plane_api/scripts/seed_models.py +240 -0
  303. control_plane_api/scripts/validate_existing_tool_names.py +492 -0
  304. control_plane_api/shared/__init__.py +8 -0
  305. control_plane_api/shared/version.py +17 -0
  306. control_plane_api/test_deduplication.py +274 -0
  307. control_plane_api/test_executor_deduplication_e2e.py +309 -0
  308. control_plane_api/test_job_execution_e2e.py +283 -0
  309. control_plane_api/test_real_integration.py +193 -0
  310. control_plane_api/version.py +38 -0
  311. control_plane_api/worker/__init__.py +0 -0
  312. control_plane_api/worker/activities/__init__.py +0 -0
  313. control_plane_api/worker/activities/agent_activities.py +1585 -0
  314. control_plane_api/worker/activities/approval_activities.py +234 -0
  315. control_plane_api/worker/activities/job_activities.py +199 -0
  316. control_plane_api/worker/activities/runtime_activities.py +1167 -0
  317. control_plane_api/worker/activities/skill_activities.py +282 -0
  318. control_plane_api/worker/activities/team_activities.py +479 -0
  319. control_plane_api/worker/agent_runtime_server.py +370 -0
  320. control_plane_api/worker/binary_manager.py +333 -0
  321. control_plane_api/worker/config/__init__.py +31 -0
  322. control_plane_api/worker/config/worker_config.py +273 -0
  323. control_plane_api/worker/control_plane_client.py +1491 -0
  324. control_plane_api/worker/examples/analytics_integration_example.py +362 -0
  325. control_plane_api/worker/health_monitor.py +159 -0
  326. control_plane_api/worker/metrics.py +237 -0
  327. control_plane_api/worker/models/__init__.py +1 -0
  328. control_plane_api/worker/models/error_events.py +105 -0
  329. control_plane_api/worker/models/inputs.py +89 -0
  330. control_plane_api/worker/runtimes/__init__.py +35 -0
  331. control_plane_api/worker/runtimes/agent_runtime/runtime.py +485 -0
  332. control_plane_api/worker/runtimes/agno/__init__.py +34 -0
  333. control_plane_api/worker/runtimes/agno/config.py +248 -0
  334. control_plane_api/worker/runtimes/agno/hooks.py +385 -0
  335. control_plane_api/worker/runtimes/agno/mcp_builder.py +195 -0
  336. control_plane_api/worker/runtimes/agno/runtime.py +1063 -0
  337. control_plane_api/worker/runtimes/agno/utils.py +163 -0
  338. control_plane_api/worker/runtimes/base.py +979 -0
  339. control_plane_api/worker/runtimes/claude_code/__init__.py +38 -0
  340. control_plane_api/worker/runtimes/claude_code/cleanup.py +184 -0
  341. control_plane_api/worker/runtimes/claude_code/client_pool.py +529 -0
  342. control_plane_api/worker/runtimes/claude_code/config.py +829 -0
  343. control_plane_api/worker/runtimes/claude_code/hooks.py +482 -0
  344. control_plane_api/worker/runtimes/claude_code/litellm_proxy.py +1702 -0
  345. control_plane_api/worker/runtimes/claude_code/mcp_builder.py +467 -0
  346. control_plane_api/worker/runtimes/claude_code/mcp_discovery.py +558 -0
  347. control_plane_api/worker/runtimes/claude_code/runtime.py +1546 -0
  348. control_plane_api/worker/runtimes/claude_code/tool_mapper.py +403 -0
  349. control_plane_api/worker/runtimes/claude_code/utils.py +149 -0
  350. control_plane_api/worker/runtimes/factory.py +173 -0
  351. control_plane_api/worker/runtimes/model_utils.py +107 -0
  352. control_plane_api/worker/runtimes/validation.py +93 -0
  353. control_plane_api/worker/services/__init__.py +1 -0
  354. control_plane_api/worker/services/agent_communication_tools.py +908 -0
  355. control_plane_api/worker/services/agent_executor.py +485 -0
  356. control_plane_api/worker/services/agent_executor_v2.py +793 -0
  357. control_plane_api/worker/services/analytics_collector.py +457 -0
  358. control_plane_api/worker/services/analytics_service.py +464 -0
  359. control_plane_api/worker/services/approval_tools.py +310 -0
  360. control_plane_api/worker/services/approval_tools_agno.py +207 -0
  361. control_plane_api/worker/services/cancellation_manager.py +177 -0
  362. control_plane_api/worker/services/code_ingestion_tools.py +465 -0
  363. control_plane_api/worker/services/contextual_awareness_tools.py +405 -0
  364. control_plane_api/worker/services/data_visualization.py +834 -0
  365. control_plane_api/worker/services/event_publisher.py +531 -0
  366. control_plane_api/worker/services/jira_tools.py +257 -0
  367. control_plane_api/worker/services/remote_filesystem_tools.py +498 -0
  368. control_plane_api/worker/services/runtime_analytics.py +328 -0
  369. control_plane_api/worker/services/session_service.py +365 -0
  370. control_plane_api/worker/services/skill_context_enhancement.py +181 -0
  371. control_plane_api/worker/services/skill_factory.py +471 -0
  372. control_plane_api/worker/services/system_prompt_enhancement.py +410 -0
  373. control_plane_api/worker/services/team_executor.py +715 -0
  374. control_plane_api/worker/services/team_executor_v2.py +1866 -0
  375. control_plane_api/worker/services/tool_enforcement.py +254 -0
  376. control_plane_api/worker/services/workflow_executor/__init__.py +52 -0
  377. control_plane_api/worker/services/workflow_executor/event_processor.py +287 -0
  378. control_plane_api/worker/services/workflow_executor/event_publisher.py +210 -0
  379. control_plane_api/worker/services/workflow_executor/executors/__init__.py +15 -0
  380. control_plane_api/worker/services/workflow_executor/executors/base.py +270 -0
  381. control_plane_api/worker/services/workflow_executor/executors/json_executor.py +50 -0
  382. control_plane_api/worker/services/workflow_executor/executors/python_executor.py +50 -0
  383. control_plane_api/worker/services/workflow_executor/models.py +142 -0
  384. control_plane_api/worker/services/workflow_executor_tools.py +1748 -0
  385. control_plane_api/worker/skills/__init__.py +12 -0
  386. control_plane_api/worker/skills/builtin/context_graph_search/README.md +213 -0
  387. control_plane_api/worker/skills/builtin/context_graph_search/__init__.py +5 -0
  388. control_plane_api/worker/skills/builtin/context_graph_search/agno_impl.py +808 -0
  389. control_plane_api/worker/skills/builtin/context_graph_search/skill.yaml +67 -0
  390. control_plane_api/worker/skills/builtin/contextual_awareness/__init__.py +4 -0
  391. control_plane_api/worker/skills/builtin/contextual_awareness/agno_impl.py +62 -0
  392. control_plane_api/worker/skills/builtin/data_visualization/agno_impl.py +18 -0
  393. control_plane_api/worker/skills/builtin/data_visualization/skill.yaml +84 -0
  394. control_plane_api/worker/skills/builtin/docker/agno_impl.py +65 -0
  395. control_plane_api/worker/skills/builtin/docker/skill.yaml +60 -0
  396. control_plane_api/worker/skills/builtin/file_generation/agno_impl.py +47 -0
  397. control_plane_api/worker/skills/builtin/file_generation/skill.yaml +64 -0
  398. control_plane_api/worker/skills/builtin/file_system/agno_impl.py +32 -0
  399. control_plane_api/worker/skills/builtin/file_system/skill.yaml +54 -0
  400. control_plane_api/worker/skills/builtin/knowledge_api/__init__.py +4 -0
  401. control_plane_api/worker/skills/builtin/knowledge_api/agno_impl.py +50 -0
  402. control_plane_api/worker/skills/builtin/knowledge_api/skill.yaml +66 -0
  403. control_plane_api/worker/skills/builtin/python/agno_impl.py +25 -0
  404. control_plane_api/worker/skills/builtin/python/skill.yaml +60 -0
  405. control_plane_api/worker/skills/builtin/schema_fix_mixin.py +260 -0
  406. control_plane_api/worker/skills/builtin/shell/agno_impl.py +31 -0
  407. control_plane_api/worker/skills/builtin/shell/skill.yaml +60 -0
  408. control_plane_api/worker/skills/builtin/slack/__init__.py +3 -0
  409. control_plane_api/worker/skills/builtin/slack/agno_impl.py +1282 -0
  410. control_plane_api/worker/skills/builtin/slack/skill.yaml +276 -0
  411. control_plane_api/worker/skills/builtin/workflow_executor/agno_impl.py +62 -0
  412. control_plane_api/worker/skills/builtin/workflow_executor/skill.yaml +79 -0
  413. control_plane_api/worker/skills/loaders/__init__.py +5 -0
  414. control_plane_api/worker/skills/loaders/base.py +23 -0
  415. control_plane_api/worker/skills/loaders/filesystem_loader.py +357 -0
  416. control_plane_api/worker/skills/registry.py +208 -0
  417. control_plane_api/worker/tests/__init__.py +1 -0
  418. control_plane_api/worker/tests/conftest.py +12 -0
  419. control_plane_api/worker/tests/e2e/__init__.py +0 -0
  420. control_plane_api/worker/tests/e2e/test_context_graph_real_api.py +338 -0
  421. control_plane_api/worker/tests/e2e/test_context_graph_templates_e2e.py +523 -0
  422. control_plane_api/worker/tests/e2e/test_enforcement_e2e.py +344 -0
  423. control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
  424. control_plane_api/worker/tests/e2e/test_single_execution_mode.py +656 -0
  425. control_plane_api/worker/tests/integration/__init__.py +0 -0
  426. control_plane_api/worker/tests/integration/test_builtin_skills_fixes.py +245 -0
  427. control_plane_api/worker/tests/integration/test_context_graph_search_integration.py +365 -0
  428. control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
  429. control_plane_api/worker/tests/integration/test_hook_enforcement_integration.py +579 -0
  430. control_plane_api/worker/tests/integration/test_scheduled_job_workflow.py +237 -0
  431. control_plane_api/worker/tests/integration/test_system_prompt_enhancement_integration.py +343 -0
  432. control_plane_api/worker/tests/unit/__init__.py +0 -0
  433. control_plane_api/worker/tests/unit/test_builtin_skill_autoload.py +396 -0
  434. control_plane_api/worker/tests/unit/test_context_graph_search.py +450 -0
  435. control_plane_api/worker/tests/unit/test_context_graph_templates.py +403 -0
  436. control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
  437. control_plane_api/worker/tests/unit/test_control_plane_client_jobs.py +345 -0
  438. control_plane_api/worker/tests/unit/test_job_activities.py +353 -0
  439. control_plane_api/worker/tests/unit/test_skill_context_enhancement.py +321 -0
  440. control_plane_api/worker/tests/unit/test_system_prompt_enhancement.py +415 -0
  441. control_plane_api/worker/tests/unit/test_tool_enforcement.py +324 -0
  442. control_plane_api/worker/utils/__init__.py +1 -0
  443. control_plane_api/worker/utils/chunk_batcher.py +330 -0
  444. control_plane_api/worker/utils/environment.py +65 -0
  445. control_plane_api/worker/utils/error_publisher.py +260 -0
  446. control_plane_api/worker/utils/event_batcher.py +256 -0
  447. control_plane_api/worker/utils/logging_config.py +335 -0
  448. control_plane_api/worker/utils/logging_helper.py +326 -0
  449. control_plane_api/worker/utils/parameter_validator.py +120 -0
  450. control_plane_api/worker/utils/retry_utils.py +60 -0
  451. control_plane_api/worker/utils/streaming_utils.py +665 -0
  452. control_plane_api/worker/utils/tool_validation.py +332 -0
  453. control_plane_api/worker/utils/workspace_manager.py +163 -0
  454. control_plane_api/worker/websocket_client.py +393 -0
  455. control_plane_api/worker/worker.py +1297 -0
  456. control_plane_api/worker/workflows/__init__.py +0 -0
  457. control_plane_api/worker/workflows/agent_execution.py +909 -0
  458. control_plane_api/worker/workflows/scheduled_job_wrapper.py +332 -0
  459. control_plane_api/worker/workflows/team_execution.py +611 -0
  460. kubiya_control_plane_api-0.9.15.dist-info/METADATA +354 -0
  461. kubiya_control_plane_api-0.9.15.dist-info/RECORD +479 -0
  462. kubiya_control_plane_api-0.9.15.dist-info/WHEEL +5 -0
  463. kubiya_control_plane_api-0.9.15.dist-info/entry_points.txt +5 -0
  464. kubiya_control_plane_api-0.9.15.dist-info/licenses/LICENSE +676 -0
  465. kubiya_control_plane_api-0.9.15.dist-info/top_level.txt +3 -0
  466. scripts/__init__.py +1 -0
  467. scripts/migrations.py +39 -0
  468. scripts/seed_worker_queues.py +128 -0
  469. scripts/setup_agent_runtime.py +142 -0
  470. worker_internal/__init__.py +1 -0
  471. worker_internal/planner/__init__.py +1 -0
  472. worker_internal/planner/activities.py +1499 -0
  473. worker_internal/planner/agent_tools.py +197 -0
  474. worker_internal/planner/event_models.py +148 -0
  475. worker_internal/planner/event_publisher.py +67 -0
  476. worker_internal/planner/models.py +199 -0
  477. worker_internal/planner/retry_logic.py +134 -0
  478. worker_internal/planner/worker.py +300 -0
  479. worker_internal/planner/workflows.py +970 -0
@@ -0,0 +1,1310 @@
1
+ """
2
+ Analytics router for execution metrics and reporting.
3
+
4
+ This router provides endpoints for:
5
+ 1. Persisting analytics data from workers (turns, tool calls, tasks)
6
+ 2. Querying aggregated analytics for reporting
7
+ 3. Organization-level metrics and cost tracking
8
+ """
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
11
+ from typing import List, Optional
12
+ from datetime import datetime, timedelta
13
+ from pydantic import BaseModel, Field
14
+ import structlog
15
+ import uuid as uuid_lib
16
+ import asyncio
17
+ from sqlalchemy.orm import Session
18
+ from sqlalchemy import desc, func
19
+ from sqlalchemy.inspection import inspect
20
+
21
+ from control_plane_api.app.middleware.auth import get_current_organization
22
+ from control_plane_api.app.database import get_db
23
+ from control_plane_api.app.models.execution import Execution
24
+ from control_plane_api.app.models.analytics import ExecutionTurn, ExecutionToolCall, ExecutionTask
25
+
26
+ # Initialize logger first, before using it in import error handling
27
+ logger = structlog.get_logger()
28
+
29
+ # Initialize state transition variables at module level (before import)
30
+ # This ensures they are always defined, preventing UnboundLocalError
31
+ StateTransitionService = None
32
+ update_execution_state_safe = None
33
+ STATE_TRANSITION_AVAILABLE = False
34
+
35
+ # Import state transition utilities at module level to avoid scope issues
36
+ try:
37
+ from control_plane_api.app.services.state_transition_service import StateTransitionService, update_execution_state_safe
38
+ STATE_TRANSITION_AVAILABLE = True
39
+ except ImportError as e:
40
+ logger.warning("state_transition_service_not_available", error=str(e))
41
+ # Variables already initialized above, no need to set to None again
42
+
43
+ router = APIRouter()
44
+
45
+
46
+ # Helper function to convert SQLAlchemy objects to dictionaries
47
+ def model_to_dict(obj):
48
+ """Convert SQLAlchemy model instance to dictionary"""
49
+ if obj is None:
50
+ return None
51
+ return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs}
52
+
53
+
54
+ # ============================================================================
55
+ # Pydantic Schemas for Analytics Data
56
+ # ============================================================================
57
+
58
+ class TurnMetricsCreate(BaseModel):
59
+ """Schema for creating a turn metrics record"""
60
+ execution_id: str
61
+ turn_number: int
62
+ turn_id: Optional[str] = None
63
+ model: str
64
+ model_provider: Optional[str] = None
65
+ started_at: str # ISO timestamp
66
+ completed_at: Optional[str] = None
67
+ duration_ms: Optional[int] = None
68
+ input_tokens: int = 0
69
+ output_tokens: int = 0
70
+ cache_read_tokens: int = 0
71
+ cache_creation_tokens: int = 0
72
+ total_tokens: int = 0
73
+ input_cost: float = 0.0
74
+ output_cost: float = 0.0
75
+ cache_read_cost: float = 0.0
76
+ cache_creation_cost: float = 0.0
77
+ total_cost: float = 0.0
78
+ finish_reason: Optional[str] = None
79
+ response_preview: Optional[str] = None
80
+ tools_called_count: int = 0
81
+ tools_called_names: List[str] = Field(default_factory=list)
82
+ error_message: Optional[str] = None
83
+ metrics: dict = Field(default_factory=dict)
84
+ # Agentic Engineering Minutes (AEM) fields
85
+ runtime_minutes: float = 0.0
86
+ model_weight: float = 1.0
87
+ tool_calls_weight: float = 1.0
88
+ aem_value: float = 0.0
89
+ aem_cost: float = 0.0
90
+
91
+
92
+ class ToolCallCreate(BaseModel):
93
+ """Schema for creating a tool call record"""
94
+ execution_id: str
95
+ turn_id: Optional[str] = None # UUID of the turn (if available)
96
+ tool_name: str
97
+ tool_use_id: Optional[str] = None
98
+ started_at: str # ISO timestamp
99
+ completed_at: Optional[str] = None
100
+ duration_ms: Optional[int] = None
101
+ tool_input: Optional[dict] = None
102
+ tool_output: Optional[str] = None
103
+ tool_output_size: Optional[int] = None
104
+ success: bool = True
105
+ error_message: Optional[str] = None
106
+ error_type: Optional[str] = None
107
+ metadata: dict = Field(default_factory=dict)
108
+
109
+
110
+ class TaskCreate(BaseModel):
111
+ """Schema for creating a task record"""
112
+ execution_id: str
113
+ task_number: Optional[int] = None
114
+ task_id: Optional[str] = None
115
+ task_description: str
116
+ task_type: Optional[str] = None
117
+ status: str = "pending"
118
+ started_at: Optional[str] = None
119
+ completed_at: Optional[str] = None
120
+ duration_ms: Optional[int] = None
121
+ result: Optional[str] = None
122
+ error_message: Optional[str] = None
123
+ metadata: dict = Field(default_factory=dict)
124
+
125
+
126
+ class TaskUpdate(BaseModel):
127
+ """Schema for updating a task's status"""
128
+ status: Optional[str] = None
129
+ completed_at: Optional[str] = None
130
+ duration_ms: Optional[int] = None
131
+ result: Optional[str] = None
132
+ error_message: Optional[str] = None
133
+
134
+
135
+ class BatchAnalyticsCreate(BaseModel):
136
+ """Schema for batch creating analytics data (used by workers to send all data at once)"""
137
+ execution_id: str
138
+ turns: List[TurnMetricsCreate] = Field(default_factory=list)
139
+ tool_calls: List[ToolCallCreate] = Field(default_factory=list)
140
+ tasks: List[TaskCreate] = Field(default_factory=list)
141
+
142
+
143
+ # ============================================================================
144
+ # Data Persistence Endpoints (Used by Workers)
145
+ # ============================================================================
146
+
147
+ @router.post("/turns", status_code=status.HTTP_201_CREATED)
148
+ async def create_turn_metrics(
149
+ turn_data: TurnMetricsCreate,
150
+ request: Request,
151
+ organization: dict = Depends(get_current_organization),
152
+ db: Session = Depends(get_db),
153
+ ):
154
+ """
155
+ Create a turn metrics record.
156
+
157
+ This endpoint is called by workers to persist per-turn LLM metrics
158
+ including tokens, cost, duration, and tool usage.
159
+ """
160
+ try:
161
+ # Verify execution belongs to organization
162
+ execution = db.query(Execution).filter(
163
+ Execution.id == turn_data.execution_id,
164
+ Execution.organization_id == organization["id"]
165
+ ).first()
166
+ if not execution:
167
+ raise HTTPException(status_code=404, detail="Execution not found")
168
+
169
+ turn_record = ExecutionTurn(
170
+ id=uuid_lib.uuid4(),
171
+ organization_id=organization["id"],
172
+ execution_id=turn_data.execution_id,
173
+ turn_number=turn_data.turn_number,
174
+ turn_id=turn_data.turn_id,
175
+ model=turn_data.model,
176
+ model_provider=turn_data.model_provider,
177
+ started_at=turn_data.started_at,
178
+ completed_at=turn_data.completed_at,
179
+ duration_ms=turn_data.duration_ms,
180
+ input_tokens=turn_data.input_tokens,
181
+ output_tokens=turn_data.output_tokens,
182
+ cache_read_tokens=turn_data.cache_read_tokens,
183
+ cache_creation_tokens=turn_data.cache_creation_tokens,
184
+ total_tokens=turn_data.total_tokens,
185
+ input_cost=turn_data.input_cost,
186
+ output_cost=turn_data.output_cost,
187
+ cache_read_cost=turn_data.cache_read_cost,
188
+ cache_creation_cost=turn_data.cache_creation_cost,
189
+ total_cost=turn_data.total_cost,
190
+ finish_reason=turn_data.finish_reason,
191
+ response_preview=turn_data.response_preview[:500] if turn_data.response_preview else None,
192
+ tools_called_count=turn_data.tools_called_count,
193
+ tools_called_names=turn_data.tools_called_names,
194
+ error_message=turn_data.error_message,
195
+ metrics=turn_data.metrics,
196
+ runtime_minutes=turn_data.runtime_minutes,
197
+ model_weight=turn_data.model_weight,
198
+ tool_calls_weight=turn_data.tool_calls_weight,
199
+ aem_value=turn_data.aem_value,
200
+ aem_cost=turn_data.aem_cost,
201
+ )
202
+
203
+ db.add(turn_record)
204
+ db.commit()
205
+ db.refresh(turn_record)
206
+
207
+ logger.info(
208
+ "turn_metrics_created",
209
+ execution_id=turn_data.execution_id,
210
+ turn_number=turn_data.turn_number,
211
+ model=turn_data.model,
212
+ tokens=turn_data.total_tokens,
213
+ cost=turn_data.total_cost,
214
+ org_id=organization["id"]
215
+ )
216
+
217
+ # Trigger intelligent state transition asynchronously
218
+ if STATE_TRANSITION_AVAILABLE and StateTransitionService:
219
+ try:
220
+ transition_service = StateTransitionService(organization_id=organization["id"])
221
+
222
+ # Analyze and transition (async, with timeout)
223
+ decision = await asyncio.wait_for(
224
+ transition_service.analyze_and_transition(
225
+ execution_id=turn_data.execution_id,
226
+ turn_number=turn_data.turn_number,
227
+ turn_data=turn_data,
228
+ ),
229
+ timeout=5.0 # 5 second max
230
+ )
231
+
232
+ logger.info(
233
+ "state_transition_decision",
234
+ execution_id=turn_data.execution_id,
235
+ turn_number=turn_data.turn_number,
236
+ from_state="running",
237
+ to_state=decision.recommended_state,
238
+ confidence=decision.confidence,
239
+ reasoning=decision.reasoning[:200],
240
+ )
241
+
242
+ except asyncio.TimeoutError:
243
+ logger.warning(
244
+ "state_transition_timeout",
245
+ execution_id=turn_data.execution_id,
246
+ turn_number=turn_data.turn_number,
247
+ )
248
+ # Fallback: default to waiting_for_input
249
+ if update_execution_state_safe:
250
+ try:
251
+ await update_execution_state_safe(
252
+ execution_id=turn_data.execution_id,
253
+ state="waiting_for_input",
254
+ reasoning="AI decision timed out - defaulting to safe state",
255
+ )
256
+ except Exception as fallback_error:
257
+ logger.warning("state_transition_fallback_failed", error=str(fallback_error))
258
+ except Exception as e:
259
+ logger.error(
260
+ "state_transition_failed",
261
+ execution_id=turn_data.execution_id,
262
+ error=str(e),
263
+ )
264
+ # Fallback: default to waiting_for_input
265
+ if update_execution_state_safe:
266
+ try:
267
+ await update_execution_state_safe(
268
+ execution_id=turn_data.execution_id,
269
+ state="waiting_for_input",
270
+ reasoning=f"AI decision failed: {str(e)[:200]}",
271
+ )
272
+ except Exception as fallback_error:
273
+ logger.warning("state_transition_fallback_failed", error=str(fallback_error))
274
+ else:
275
+ logger.warning(
276
+ "state_transition_service_unavailable",
277
+ execution_id=turn_data.execution_id,
278
+ note="Falling back to default status update"
279
+ )
280
+ # CRITICAL FIX: Even if state transition service is unavailable,
281
+ # we MUST update the status to prevent infinite workflow loops
282
+ if update_execution_state_safe:
283
+ try:
284
+ await update_execution_state_safe(
285
+ execution_id=turn_data.execution_id,
286
+ state="waiting_for_input",
287
+ reasoning="State transition service unavailable - using safe default",
288
+ )
289
+ logger.info(
290
+ "fallback_status_update_success",
291
+ execution_id=turn_data.execution_id,
292
+ status="waiting_for_input"
293
+ )
294
+ except Exception as fallback_error:
295
+ logger.error(
296
+ "fallback_status_update_failed",
297
+ execution_id=turn_data.execution_id,
298
+ error=str(fallback_error),
299
+ note="CRITICAL: Status may remain 'running' - workflow may loop"
300
+ )
301
+ else:
302
+ # Last resort: direct database update using SQLAlchemy
303
+ logger.warning(
304
+ "using_direct_db_update",
305
+ execution_id=turn_data.execution_id,
306
+ note="update_execution_state_safe not available - using direct database access"
307
+ )
308
+ try:
309
+ execution.status = "waiting_for_input"
310
+ db.commit()
311
+ logger.info(
312
+ "direct_db_update_success",
313
+ execution_id=turn_data.execution_id,
314
+ status="waiting_for_input"
315
+ )
316
+ except Exception as db_error:
317
+ logger.error(
318
+ "direct_db_update_failed",
319
+ execution_id=turn_data.execution_id,
320
+ error=str(db_error),
321
+ note="CRITICAL: Status remains 'running' - workflow will loop indefinitely"
322
+ )
323
+
324
+ return {"success": True, "turn_id": str(turn_record.id)}
325
+
326
+ except HTTPException:
327
+ raise
328
+ except Exception as e:
329
+ logger.error("turn_metrics_create_failed", error=str(e), execution_id=turn_data.execution_id)
330
+ raise HTTPException(status_code=500, detail=f"Failed to create turn metrics: {str(e)}")
331
+
332
+
333
+ @router.post("/tool-calls", status_code=status.HTTP_201_CREATED)
334
+ async def create_tool_call(
335
+ tool_call_data: ToolCallCreate,
336
+ request: Request,
337
+ organization: dict = Depends(get_current_organization),
338
+ db: Session = Depends(get_db),
339
+ ):
340
+ """
341
+ Create a tool call record.
342
+
343
+ This endpoint is called by workers to persist tool execution details
344
+ including timing, success/failure, and error information.
345
+ """
346
+ try:
347
+ # Verify execution belongs to organization
348
+ execution = db.query(Execution).filter(
349
+ Execution.id == tool_call_data.execution_id,
350
+ Execution.organization_id == organization["id"]
351
+ ).first()
352
+ if not execution:
353
+ raise HTTPException(status_code=404, detail="Execution not found")
354
+
355
+ # Truncate tool_output if too large (store first 10KB)
356
+ tool_output = tool_call_data.tool_output
357
+ tool_output_size = len(tool_output) if tool_output else 0
358
+ if tool_output and len(tool_output) > 10000:
359
+ tool_output = tool_output[:10000] + "... [truncated]"
360
+
361
+ tool_call_record = ExecutionToolCall(
362
+ id=uuid_lib.uuid4(),
363
+ organization_id=organization["id"],
364
+ execution_id=tool_call_data.execution_id,
365
+ turn_id=tool_call_data.turn_id,
366
+ tool_name=tool_call_data.tool_name,
367
+ tool_use_id=tool_call_data.tool_use_id,
368
+ started_at=tool_call_data.started_at,
369
+ completed_at=tool_call_data.completed_at,
370
+ duration_ms=tool_call_data.duration_ms,
371
+ tool_input=tool_call_data.tool_input,
372
+ tool_output=tool_output,
373
+ tool_output_size=tool_output_size,
374
+ success=tool_call_data.success,
375
+ error_message=tool_call_data.error_message,
376
+ error_type=tool_call_data.error_type,
377
+ metadata_=tool_call_data.metadata,
378
+ )
379
+
380
+ db.add(tool_call_record)
381
+ db.commit()
382
+ db.refresh(tool_call_record)
383
+
384
+ logger.info(
385
+ "tool_call_created",
386
+ execution_id=tool_call_data.execution_id,
387
+ tool_name=tool_call_data.tool_name,
388
+ success=tool_call_data.success,
389
+ duration_ms=tool_call_data.duration_ms,
390
+ org_id=organization["id"]
391
+ )
392
+
393
+ return {"success": True, "tool_call_id": str(tool_call_record.id)}
394
+
395
+ except HTTPException:
396
+ raise
397
+ except Exception as e:
398
+ logger.error("tool_call_create_failed", error=str(e), execution_id=tool_call_data.execution_id)
399
+ raise HTTPException(status_code=500, detail=f"Failed to create tool call: {str(e)}")
400
+
401
+
402
+ @router.post("/tasks", status_code=status.HTTP_201_CREATED)
403
+ async def create_task(
404
+ task_data: TaskCreate,
405
+ request: Request,
406
+ organization: dict = Depends(get_current_organization),
407
+ db: Session = Depends(get_db),
408
+ ):
409
+ """
410
+ Create a task record.
411
+
412
+ This endpoint is called by workers to persist task tracking information.
413
+ """
414
+ try:
415
+ # Verify execution belongs to organization
416
+ execution = db.query(Execution).filter(
417
+ Execution.id == task_data.execution_id,
418
+ Execution.organization_id == organization["id"]
419
+ ).first()
420
+ if not execution:
421
+ raise HTTPException(status_code=404, detail="Execution not found")
422
+
423
+ task_record = ExecutionTask(
424
+ id=uuid_lib.uuid4(),
425
+ organization_id=organization["id"],
426
+ execution_id=task_data.execution_id,
427
+ task_number=task_data.task_number,
428
+ task_id=task_data.task_id,
429
+ task_description=task_data.task_description,
430
+ task_type=task_data.task_type,
431
+ status=task_data.status,
432
+ started_at=task_data.started_at,
433
+ completed_at=task_data.completed_at,
434
+ duration_ms=task_data.duration_ms,
435
+ result=task_data.result,
436
+ error_message=task_data.error_message,
437
+ custom_metadata=task_data.metadata,
438
+ )
439
+
440
+ db.add(task_record)
441
+ db.commit()
442
+ db.refresh(task_record)
443
+
444
+ logger.info(
445
+ "task_created",
446
+ execution_id=task_data.execution_id,
447
+ task_description=task_data.task_description[:100],
448
+ status=task_data.status,
449
+ org_id=organization["id"]
450
+ )
451
+
452
+ return {"success": True, "task_id": str(task_record.id)}
453
+
454
+ except HTTPException:
455
+ raise
456
+ except Exception as e:
457
+ logger.error("task_create_failed", error=str(e), execution_id=task_data.execution_id)
458
+ raise HTTPException(status_code=500, detail=f"Failed to create task: {str(e)}")
459
+
460
+
461
+ @router.post("/batch", status_code=status.HTTP_201_CREATED)
462
+ async def create_batch_analytics(
463
+ batch_data: BatchAnalyticsCreate,
464
+ request: Request,
465
+ organization: dict = Depends(get_current_organization),
466
+ db: Session = Depends(get_db),
467
+ ):
468
+ """
469
+ Create analytics data in batch.
470
+
471
+ This endpoint allows workers to send all analytics data (turns, tool calls, tasks)
472
+ in a single request, reducing round trips and improving performance.
473
+ """
474
+ try:
475
+ # Verify execution belongs to organization
476
+ execution = db.query(Execution).filter(
477
+ Execution.id == batch_data.execution_id,
478
+ Execution.organization_id == organization["id"]
479
+ ).first()
480
+ if not execution:
481
+ raise HTTPException(status_code=404, detail="Execution not found")
482
+
483
+ results = {
484
+ "turns_created": 0,
485
+ "tool_calls_created": 0,
486
+ "tasks_created": 0,
487
+ "errors": []
488
+ }
489
+
490
+ # Create turns
491
+ if batch_data.turns:
492
+ for turn in batch_data.turns:
493
+ try:
494
+ turn_record = ExecutionTurn(
495
+ id=uuid_lib.uuid4(),
496
+ organization_id=organization["id"],
497
+ execution_id=batch_data.execution_id,
498
+ turn_number=turn.turn_number,
499
+ turn_id=turn.turn_id,
500
+ model=turn.model,
501
+ model_provider=turn.model_provider,
502
+ started_at=turn.started_at,
503
+ completed_at=turn.completed_at,
504
+ duration_ms=turn.duration_ms,
505
+ input_tokens=turn.input_tokens,
506
+ output_tokens=turn.output_tokens,
507
+ cache_read_tokens=turn.cache_read_tokens,
508
+ cache_creation_tokens=turn.cache_creation_tokens,
509
+ total_tokens=turn.total_tokens,
510
+ input_cost=turn.input_cost,
511
+ output_cost=turn.output_cost,
512
+ cache_read_cost=turn.cache_read_cost,
513
+ cache_creation_cost=turn.cache_creation_cost,
514
+ total_cost=turn.total_cost,
515
+ finish_reason=turn.finish_reason,
516
+ response_preview=turn.response_preview[:500] if turn.response_preview else None,
517
+ tools_called_count=turn.tools_called_count,
518
+ tools_called_names=turn.tools_called_names,
519
+ error_message=turn.error_message,
520
+ metrics=turn.metrics,
521
+ runtime_minutes=turn.runtime_minutes,
522
+ model_weight=turn.model_weight,
523
+ tool_calls_weight=turn.tool_calls_weight,
524
+ aem_value=turn.aem_value,
525
+ aem_cost=turn.aem_cost,
526
+ )
527
+ db.add(turn_record)
528
+ results["turns_created"] += 1
529
+ except Exception as e:
530
+ results["errors"].append(f"Turn {turn.turn_number}: {str(e)}")
531
+
532
+ # Create tool calls
533
+ if batch_data.tool_calls:
534
+ for tool_call in batch_data.tool_calls:
535
+ try:
536
+ tool_output = tool_call.tool_output
537
+ tool_output_size = len(tool_output) if tool_output else 0
538
+ if tool_output and len(tool_output) > 10000:
539
+ tool_output = tool_output[:10000] + "... [truncated]"
540
+
541
+ tool_call_record = ExecutionToolCall(
542
+ id=uuid_lib.uuid4(),
543
+ organization_id=organization["id"],
544
+ execution_id=batch_data.execution_id,
545
+ turn_id=tool_call.turn_id,
546
+ tool_name=tool_call.tool_name,
547
+ tool_use_id=tool_call.tool_use_id,
548
+ started_at=tool_call.started_at,
549
+ completed_at=tool_call.completed_at,
550
+ duration_ms=tool_call.duration_ms,
551
+ tool_input=tool_call.tool_input,
552
+ tool_output=tool_output,
553
+ tool_output_size=tool_output_size,
554
+ success=tool_call.success,
555
+ error_message=tool_call.error_message,
556
+ error_type=tool_call.error_type,
557
+ metadata_=tool_call.metadata,
558
+ )
559
+ db.add(tool_call_record)
560
+ results["tool_calls_created"] += 1
561
+ except Exception as e:
562
+ results["errors"].append(f"Tool call {tool_call.tool_name}: {str(e)}")
563
+
564
+ # Create tasks
565
+ if batch_data.tasks:
566
+ for task in batch_data.tasks:
567
+ try:
568
+ task_record = ExecutionTask(
569
+ id=uuid_lib.uuid4(),
570
+ organization_id=organization["id"],
571
+ execution_id=batch_data.execution_id,
572
+ task_number=task.task_number,
573
+ task_id=task.task_id,
574
+ task_description=task.task_description,
575
+ task_type=task.task_type,
576
+ status=task.status,
577
+ started_at=task.started_at,
578
+ completed_at=task.completed_at,
579
+ duration_ms=task.duration_ms,
580
+ result=task.result,
581
+ error_message=task.error_message,
582
+ custom_metadata=task.metadata,
583
+ )
584
+ db.add(task_record)
585
+ results["tasks_created"] += 1
586
+ except Exception as e:
587
+ results["errors"].append(f"Task {task.task_description[:50]}: {str(e)}")
588
+
589
+ # Commit all records at once
590
+ db.commit()
591
+
592
+ logger.info(
593
+ "batch_analytics_created",
594
+ execution_id=batch_data.execution_id,
595
+ turns_created=results["turns_created"],
596
+ tool_calls_created=results["tool_calls_created"],
597
+ tasks_created=results["tasks_created"],
598
+ errors=len(results["errors"]),
599
+ org_id=organization["id"]
600
+ )
601
+
602
+ return {
603
+ "success": len(results["errors"]) == 0,
604
+ "execution_id": batch_data.execution_id,
605
+ **results
606
+ }
607
+
608
+ except HTTPException:
609
+ raise
610
+ except Exception as e:
611
+ logger.error("batch_analytics_create_failed", error=str(e), execution_id=batch_data.execution_id)
612
+ raise HTTPException(status_code=500, detail=f"Failed to create batch analytics: {str(e)}")
613
+
614
+
615
+ @router.patch("/tasks/{task_id}", status_code=status.HTTP_200_OK)
616
+ async def update_task(
617
+ task_id: str,
618
+ task_update: TaskUpdate,
619
+ request: Request,
620
+ organization: dict = Depends(get_current_organization),
621
+ db: Session = Depends(get_db),
622
+ ):
623
+ """
624
+ Update a task's status and completion information.
625
+
626
+ This endpoint is called by workers to update task progress.
627
+ """
628
+ try:
629
+ # Find the task
630
+ task = db.query(ExecutionTask).filter(
631
+ ExecutionTask.id == task_id,
632
+ ExecutionTask.organization_id == organization["id"]
633
+ ).first()
634
+
635
+ if not task:
636
+ raise HTTPException(status_code=404, detail="Task not found")
637
+
638
+ # Update fields
639
+ if task_update.status is not None:
640
+ task.status = task_update.status
641
+ if task_update.completed_at is not None:
642
+ task.completed_at = task_update.completed_at
643
+ if task_update.duration_ms is not None:
644
+ task.duration_ms = task_update.duration_ms
645
+ if task_update.result is not None:
646
+ task.result = task_update.result
647
+ if task_update.error_message is not None:
648
+ task.error_message = task_update.error_message
649
+
650
+ task.updated_at = datetime.utcnow()
651
+
652
+ db.commit()
653
+
654
+ logger.info(
655
+ "task_updated",
656
+ task_id=task_id,
657
+ status=task_update.status,
658
+ org_id=organization["id"]
659
+ )
660
+
661
+ return {"success": True, "task_id": task_id}
662
+
663
+ except HTTPException:
664
+ raise
665
+ except Exception as e:
666
+ logger.error("task_update_failed", error=str(e), task_id=task_id)
667
+ raise HTTPException(status_code=500, detail=f"Failed to update task: {str(e)}")
668
+
669
+
670
+ # ============================================================================
671
+ # Reporting Endpoints (For Analytics Dashboard)
672
+ # ============================================================================
673
+
674
+ @router.get("/executions/{execution_id}/details")
675
+ async def get_execution_analytics(
676
+ execution_id: str,
677
+ request: Request,
678
+ organization: dict = Depends(get_current_organization),
679
+ db: Session = Depends(get_db),
680
+ ):
681
+ """
682
+ Get comprehensive analytics for a specific execution.
683
+
684
+ Returns:
685
+ - Execution summary
686
+ - Per-turn metrics
687
+ - Tool call details
688
+ - Task breakdown
689
+ - Total costs and token usage
690
+ """
691
+ try:
692
+ # Get execution
693
+ execution = db.query(Execution).filter(
694
+ Execution.id == execution_id,
695
+ Execution.organization_id == organization["id"]
696
+ ).first()
697
+ if not execution:
698
+ raise HTTPException(status_code=404, detail="Execution not found")
699
+
700
+ # Get turns
701
+ turns = db.query(ExecutionTurn).filter(
702
+ ExecutionTurn.execution_id == execution_id,
703
+ ExecutionTurn.organization_id == organization["id"]
704
+ ).order_by(ExecutionTurn.turn_number).all()
705
+
706
+ # Get tool calls
707
+ tool_calls = db.query(ExecutionToolCall).filter(
708
+ ExecutionToolCall.execution_id == execution_id,
709
+ ExecutionToolCall.organization_id == organization["id"]
710
+ ).order_by(ExecutionToolCall.started_at).all()
711
+
712
+ # Get tasks
713
+ tasks = db.query(ExecutionTask).filter(
714
+ ExecutionTask.execution_id == execution_id,
715
+ ExecutionTask.organization_id == organization["id"]
716
+ ).order_by(ExecutionTask.task_number).all()
717
+
718
+ # Convert to dicts
719
+ turns_data = [model_to_dict(turn) for turn in turns]
720
+ tool_calls_data = [model_to_dict(tc) for tc in tool_calls]
721
+ tasks_data = [model_to_dict(task) for task in tasks]
722
+
723
+ # Calculate aggregated metrics
724
+ total_turns = len(turns)
725
+ total_tokens = sum(turn.total_tokens or 0 for turn in turns)
726
+ total_cost = sum(turn.total_cost or 0.0 for turn in turns)
727
+ total_duration_ms = sum(turn.duration_ms or 0 for turn in turns)
728
+
729
+ total_tool_calls = len(tool_calls)
730
+ successful_tool_calls = sum(1 for tc in tool_calls if tc.success)
731
+ failed_tool_calls = total_tool_calls - successful_tool_calls
732
+
733
+ unique_tools_used = list(set(tc.tool_name for tc in tool_calls))
734
+
735
+ # Task statistics
736
+ total_tasks = len(tasks)
737
+ completed_tasks = sum(1 for task in tasks if task.status == "completed")
738
+ failed_tasks = sum(1 for task in tasks if task.status == "failed")
739
+ pending_tasks = sum(1 for task in tasks if task.status in ["pending", "in_progress"])
740
+
741
+ return {
742
+ "execution": model_to_dict(execution),
743
+ "summary": {
744
+ "execution_id": execution_id,
745
+ "total_turns": total_turns,
746
+ "total_tokens": total_tokens,
747
+ "total_cost": total_cost,
748
+ "total_duration_ms": total_duration_ms,
749
+ "total_tool_calls": total_tool_calls,
750
+ "successful_tool_calls": successful_tool_calls,
751
+ "failed_tool_calls": failed_tool_calls,
752
+ "unique_tools_used": unique_tools_used,
753
+ "total_tasks": total_tasks,
754
+ "completed_tasks": completed_tasks,
755
+ "failed_tasks": failed_tasks,
756
+ "pending_tasks": pending_tasks,
757
+ },
758
+ "turns": turns_data,
759
+ "tool_calls": tool_calls_data,
760
+ "tasks": tasks_data,
761
+ }
762
+
763
+ except HTTPException:
764
+ raise
765
+ except Exception as e:
766
+ logger.error("get_execution_analytics_failed", error=str(e), execution_id=execution_id)
767
+ raise HTTPException(status_code=500, detail=f"Failed to get execution analytics: {str(e)}")
768
+
769
+
770
+ @router.get("/summary")
771
+ async def get_organization_analytics_summary(
772
+ request: Request,
773
+ organization: dict = Depends(get_current_organization),
774
+ days: int = Query(default=30, ge=1, le=365, description="Number of days to include in the summary"),
775
+ db: Session = Depends(get_db),
776
+ ):
777
+ """
778
+ Get aggregated analytics summary for the organization.
779
+
780
+ Returns high-level metrics over the specified time period:
781
+ - Total executions
782
+ - Total cost
783
+ - Total tokens used
784
+ - Model usage breakdown
785
+ - Tool usage statistics
786
+ - Success rates
787
+ """
788
+ try:
789
+ # Calculate date range
790
+ end_date = datetime.utcnow()
791
+ start_date = end_date - timedelta(days=days)
792
+
793
+ # Get executions in date range
794
+ executions = db.query(Execution).filter(
795
+ Execution.organization_id == organization["id"],
796
+ Execution.created_at >= start_date
797
+ ).all()
798
+
799
+ if not executions:
800
+ return {
801
+ "period_days": days,
802
+ "start_date": start_date.isoformat(),
803
+ "end_date": end_date.isoformat(),
804
+ "total_executions": 0,
805
+ "total_cost": 0.0,
806
+ "total_tokens": 0,
807
+ "total_turns": 0,
808
+ "total_tool_calls": 0,
809
+ "models_used": {},
810
+ "tools_used": {},
811
+ "success_rate": 0.0,
812
+ }
813
+
814
+ # Get all turns for these executions
815
+ turns = db.query(ExecutionTurn).filter(
816
+ ExecutionTurn.organization_id == organization["id"],
817
+ ExecutionTurn.created_at >= start_date
818
+ ).all()
819
+
820
+ # Get all tool calls for these executions
821
+ tool_calls = db.query(ExecutionToolCall).filter(
822
+ ExecutionToolCall.organization_id == organization["id"],
823
+ ExecutionToolCall.created_at >= start_date
824
+ ).all()
825
+
826
+ # Calculate aggregates
827
+ total_executions = len(executions)
828
+ successful_executions = sum(1 for exec in executions if exec.status == "completed")
829
+ success_rate = (successful_executions / total_executions * 100) if total_executions > 0 else 0.0
830
+
831
+ total_turns = len(turns)
832
+ total_tokens = sum(turn.total_tokens or 0 for turn in turns)
833
+ total_cost = sum(turn.total_cost or 0.0 for turn in turns)
834
+
835
+ # Model usage breakdown
836
+ models_used = {}
837
+ for turn in turns:
838
+ model = turn.model or "unknown"
839
+ if model not in models_used:
840
+ models_used[model] = {
841
+ "count": 0,
842
+ "total_tokens": 0,
843
+ "total_cost": 0.0,
844
+ }
845
+ models_used[model]["count"] += 1
846
+ models_used[model]["total_tokens"] += turn.total_tokens or 0
847
+ models_used[model]["total_cost"] += turn.total_cost or 0.0
848
+
849
+ # Tool usage breakdown
850
+ tools_used = {}
851
+ total_tool_calls = len(tool_calls)
852
+ for tool_call in tool_calls:
853
+ tool_name = tool_call.tool_name or "unknown"
854
+ if tool_name not in tools_used:
855
+ tools_used[tool_name] = {
856
+ "count": 0,
857
+ "success_count": 0,
858
+ "fail_count": 0,
859
+ "avg_duration_ms": 0,
860
+ "total_duration_ms": 0,
861
+ }
862
+ tools_used[tool_name]["count"] += 1
863
+ if tool_call.success:
864
+ tools_used[tool_name]["success_count"] += 1
865
+ else:
866
+ tools_used[tool_name]["fail_count"] += 1
867
+
868
+ duration = tool_call.duration_ms or 0
869
+ tools_used[tool_name]["total_duration_ms"] += duration
870
+
871
+ # Calculate average durations
872
+ for tool_name, stats in tools_used.items():
873
+ if stats["count"] > 0:
874
+ stats["avg_duration_ms"] = stats["total_duration_ms"] / stats["count"]
875
+
876
+ return {
877
+ "period_days": days,
878
+ "start_date": start_date.isoformat(),
879
+ "end_date": end_date.isoformat(),
880
+ "total_executions": total_executions,
881
+ "successful_executions": successful_executions,
882
+ "failed_executions": total_executions - successful_executions,
883
+ "success_rate": round(success_rate, 2),
884
+ "total_cost": round(total_cost, 4),
885
+ "total_tokens": total_tokens,
886
+ "total_turns": total_turns,
887
+ "total_tool_calls": total_tool_calls,
888
+ "models_used": models_used,
889
+ "tools_used": tools_used,
890
+ }
891
+
892
+ except HTTPException:
893
+ raise
894
+ except Exception as e:
895
+ logger.error("get_analytics_summary_failed", error=str(e), org_id=organization["id"])
896
+ raise HTTPException(status_code=500, detail=f"Failed to get analytics summary: {str(e)}")
897
+
898
+
899
+ @router.get("/costs")
900
+ async def get_cost_breakdown(
901
+ request: Request,
902
+ organization: dict = Depends(get_current_organization),
903
+ days: int = Query(default=30, ge=1, le=365, description="Number of days to include"),
904
+ group_by: str = Query(default="day", regex="^(day|week|month)$", description="Group costs by time period"),
905
+ db: Session = Depends(get_db),
906
+ ):
907
+ """
908
+ Get detailed cost breakdown over time.
909
+
910
+ Returns cost metrics grouped by the specified time period.
911
+ """
912
+ try:
913
+ # Calculate date range
914
+ end_date = datetime.utcnow()
915
+ start_date = end_date - timedelta(days=days)
916
+
917
+ # Get all turns in date range
918
+ turns = db.query(ExecutionTurn).filter(
919
+ ExecutionTurn.organization_id == organization["id"],
920
+ ExecutionTurn.created_at >= start_date
921
+ ).order_by(ExecutionTurn.created_at).all()
922
+
923
+ # Group by time period
924
+ cost_by_period = {}
925
+ for turn in turns:
926
+ created_at = turn.created_at.replace(tzinfo=None) if turn.created_at else datetime.utcnow()
927
+
928
+ # Determine period key
929
+ if group_by == "day":
930
+ period_key = created_at.strftime("%Y-%m-%d")
931
+ elif group_by == "week":
932
+ period_key = created_at.strftime("%Y-W%U")
933
+ else: # month
934
+ period_key = created_at.strftime("%Y-%m")
935
+
936
+ if period_key not in cost_by_period:
937
+ cost_by_period[period_key] = {
938
+ "period": period_key,
939
+ "total_cost": 0.0,
940
+ "total_tokens": 0,
941
+ "total_input_tokens": 0,
942
+ "total_output_tokens": 0,
943
+ "turn_count": 0,
944
+ "models": {},
945
+ }
946
+
947
+ cost_by_period[period_key]["total_cost"] += turn.total_cost or 0.0
948
+ cost_by_period[period_key]["total_tokens"] += turn.total_tokens or 0
949
+ cost_by_period[period_key]["total_input_tokens"] += turn.input_tokens or 0
950
+ cost_by_period[period_key]["total_output_tokens"] += turn.output_tokens or 0
951
+ cost_by_period[period_key]["turn_count"] += 1
952
+
953
+ # Track by model
954
+ model = turn.model or "unknown"
955
+ if model not in cost_by_period[period_key]["models"]:
956
+ cost_by_period[period_key]["models"][model] = {
957
+ "cost": 0.0,
958
+ "tokens": 0,
959
+ "turns": 0,
960
+ }
961
+ cost_by_period[period_key]["models"][model]["cost"] += turn.total_cost or 0.0
962
+ cost_by_period[period_key]["models"][model]["tokens"] += turn.total_tokens or 0
963
+ cost_by_period[period_key]["models"][model]["turns"] += 1
964
+
965
+ # Convert to list and sort
966
+ cost_breakdown = sorted(cost_by_period.values(), key=lambda x: x["period"])
967
+
968
+ # Calculate totals
969
+ total_cost = sum(period["total_cost"] for period in cost_breakdown)
970
+ total_tokens = sum(period["total_tokens"] for period in cost_breakdown)
971
+
972
+ return {
973
+ "period_days": days,
974
+ "group_by": group_by,
975
+ "start_date": start_date.isoformat(),
976
+ "end_date": end_date.isoformat(),
977
+ "total_cost": round(total_cost, 4),
978
+ "total_tokens": total_tokens,
979
+ "breakdown": cost_breakdown,
980
+ }
981
+
982
+ except HTTPException:
983
+ raise
984
+ except Exception as e:
985
+ logger.error("get_cost_breakdown_failed", error=str(e), org_id=organization["id"])
986
+ raise HTTPException(status_code=500, detail=f"Failed to get cost breakdown: {str(e)}")
987
+
988
+
989
+ @router.get("/aem/summary")
990
+ async def get_aem_summary(
991
+ request: Request,
992
+ organization: dict = Depends(get_current_organization),
993
+ days: int = Query(default=30, ge=1, le=365, description="Number of days to include"),
994
+ db: Session = Depends(get_db),
995
+ ):
996
+ """
997
+ Get Agentic Engineering Minutes (AEM) summary.
998
+
999
+ Returns:
1000
+ - Total AEM consumed
1001
+ - Total AEM cost
1002
+ - Breakdown by model tier (Premium, Mid, Basic) - provider-agnostic classification
1003
+ - Average runtime, model weight, tool complexity
1004
+ """
1005
+ try:
1006
+ # Calculate date range
1007
+ end_date = datetime.utcnow()
1008
+ start_date = end_date - timedelta(days=days)
1009
+
1010
+ # Get all turns with AEM data
1011
+ turns = db.query(ExecutionTurn).filter(
1012
+ ExecutionTurn.organization_id == organization["id"],
1013
+ ExecutionTurn.created_at >= start_date
1014
+ ).all()
1015
+
1016
+ if not turns:
1017
+ return {
1018
+ "period_days": days,
1019
+ "total_aem": 0.0,
1020
+ "total_aem_cost": 0.0,
1021
+ "total_runtime_minutes": 0.0,
1022
+ "turn_count": 0,
1023
+ "by_model_tier": {},
1024
+ "average_model_weight": 0.0,
1025
+ "average_tool_complexity": 0.0,
1026
+ }
1027
+
1028
+ # Calculate totals
1029
+ total_aem = sum(turn.aem_value or 0.0 for turn in turns)
1030
+ total_aem_cost = sum(turn.aem_cost or 0.0 for turn in turns)
1031
+ total_runtime_minutes = sum(turn.runtime_minutes or 0.0 for turn in turns)
1032
+ total_model_weight = sum(turn.model_weight or 1.0 for turn in turns)
1033
+ total_tool_weight = sum(turn.tool_calls_weight or 1.0 for turn in turns)
1034
+
1035
+ # Breakdown by model tier (using provider-agnostic naming)
1036
+ by_tier = {}
1037
+ for turn in turns:
1038
+ weight = turn.model_weight or 1.0
1039
+
1040
+ # Classify into universal tiers
1041
+ if weight >= 1.5:
1042
+ tier = "premium" # Most capable models
1043
+ elif weight >= 0.8:
1044
+ tier = "mid" # Balanced models
1045
+ else:
1046
+ tier = "basic" # Fast/efficient models
1047
+
1048
+ if tier not in by_tier:
1049
+ by_tier[tier] = {
1050
+ "tier": tier,
1051
+ "turn_count": 0,
1052
+ "total_aem": 0.0,
1053
+ "total_aem_cost": 0.0,
1054
+ "total_runtime_minutes": 0.0,
1055
+ "total_tokens": 0,
1056
+ "total_input_tokens": 0,
1057
+ "total_output_tokens": 0,
1058
+ "total_cache_read_tokens": 0,
1059
+ "total_cache_creation_tokens": 0,
1060
+ "total_token_cost": 0.0,
1061
+ "models": set(),
1062
+ }
1063
+
1064
+ by_tier[tier]["turn_count"] += 1
1065
+ by_tier[tier]["total_aem"] += turn.aem_value or 0.0
1066
+ by_tier[tier]["total_aem_cost"] += turn.aem_cost or 0.0
1067
+ by_tier[tier]["total_runtime_minutes"] += turn.runtime_minutes or 0.0
1068
+ by_tier[tier]["total_tokens"] += turn.total_tokens or 0
1069
+ by_tier[tier]["total_input_tokens"] += turn.input_tokens or 0
1070
+ by_tier[tier]["total_output_tokens"] += turn.output_tokens or 0
1071
+ by_tier[tier]["total_cache_read_tokens"] += turn.cache_read_tokens or 0
1072
+ by_tier[tier]["total_cache_creation_tokens"] += turn.cache_creation_tokens or 0
1073
+ by_tier[tier]["total_token_cost"] += turn.total_cost or 0.0
1074
+ by_tier[tier]["models"].add(turn.model or "unknown")
1075
+
1076
+ # Convert sets to lists for JSON serialization
1077
+ for tier_data in by_tier.values():
1078
+ tier_data["models"] = list(tier_data["models"])
1079
+
1080
+ return {
1081
+ "period_days": days,
1082
+ "start_date": start_date.isoformat(),
1083
+ "end_date": end_date.isoformat(),
1084
+ "total_aem": round(total_aem, 2),
1085
+ "total_aem_cost": round(total_aem_cost, 2),
1086
+ "total_runtime_minutes": round(total_runtime_minutes, 2),
1087
+ "turn_count": len(turns),
1088
+ "average_aem_per_turn": round(total_aem / len(turns), 2) if turns else 0.0,
1089
+ "average_model_weight": round(total_model_weight / len(turns), 2) if turns else 0.0,
1090
+ "average_tool_complexity": round(total_tool_weight / len(turns), 2) if turns else 0.0,
1091
+ "by_model_tier": by_tier,
1092
+ }
1093
+
1094
+ except HTTPException:
1095
+ raise
1096
+ except Exception as e:
1097
+ logger.error("get_aem_summary_failed", error=str(e), org_id=organization["id"])
1098
+ raise HTTPException(status_code=500, detail=f"Failed to get AEM summary: {str(e)}")
1099
+
1100
+
1101
+ @router.get("/aem/trends")
1102
+ async def get_aem_trends(
1103
+ request: Request,
1104
+ organization: dict = Depends(get_current_organization),
1105
+ days: int = Query(default=30, ge=1, le=365, description="Number of days to include"),
1106
+ group_by: str = Query(default="day", regex="^(day|week|month)$", description="Group by time period"),
1107
+ db: Session = Depends(get_db),
1108
+ ):
1109
+ """
1110
+ Get AEM trends over time.
1111
+
1112
+ Returns AEM consumption grouped by time period for trend analysis.
1113
+ """
1114
+ try:
1115
+ # Calculate date range
1116
+ end_date = datetime.utcnow()
1117
+ start_date = end_date - timedelta(days=days)
1118
+
1119
+ # Get all turns with AEM data
1120
+ turns = db.query(ExecutionTurn).filter(
1121
+ ExecutionTurn.organization_id == organization["id"],
1122
+ ExecutionTurn.created_at >= start_date
1123
+ ).order_by(ExecutionTurn.created_at).all()
1124
+
1125
+ # Group by time period
1126
+ aem_by_period = {}
1127
+ for turn in turns:
1128
+ created_at = turn.created_at.replace(tzinfo=None) if turn.created_at else datetime.utcnow()
1129
+
1130
+ # Determine period key
1131
+ if group_by == "day":
1132
+ period_key = created_at.strftime("%Y-%m-%d")
1133
+ elif group_by == "week":
1134
+ period_key = created_at.strftime("%Y-W%U")
1135
+ else: # month
1136
+ period_key = created_at.strftime("%Y-%m")
1137
+
1138
+ if period_key not in aem_by_period:
1139
+ aem_by_period[period_key] = {
1140
+ "period": period_key,
1141
+ "total_aem": 0.0,
1142
+ "total_aem_cost": 0.0,
1143
+ "total_runtime_minutes": 0.0,
1144
+ "turn_count": 0,
1145
+ "average_model_weight": 0.0,
1146
+ "average_tool_complexity": 0.0,
1147
+ }
1148
+
1149
+ aem_by_period[period_key]["total_aem"] += turn.aem_value or 0.0
1150
+ aem_by_period[period_key]["total_aem_cost"] += turn.aem_cost or 0.0
1151
+ aem_by_period[period_key]["total_runtime_minutes"] += turn.runtime_minutes or 0.0
1152
+ aem_by_period[period_key]["turn_count"] += 1
1153
+
1154
+ # Calculate averages
1155
+ for period_data in aem_by_period.values():
1156
+ if period_data["turn_count"] > 0:
1157
+ # Get turns for this period to calculate weighted averages
1158
+ period_turns = [t for t in turns if (t.created_at.replace(tzinfo=None) if t.created_at else datetime.utcnow()).strftime(
1159
+ "%Y-%m-%d" if group_by == "day" else "%Y-W%U" if group_by == "week" else "%Y-%m"
1160
+ ) == period_data["period"]]
1161
+
1162
+ total_weight = sum(t.model_weight or 1.0 for t in period_turns)
1163
+ total_tool_weight = sum(t.tool_calls_weight or 1.0 for t in period_turns)
1164
+
1165
+ period_data["average_model_weight"] = round(total_weight / len(period_turns), 2)
1166
+ period_data["average_tool_complexity"] = round(total_tool_weight / len(period_turns), 2)
1167
+
1168
+ # Convert to list and sort
1169
+ aem_trends = sorted(aem_by_period.values(), key=lambda x: x["period"])
1170
+
1171
+ # Calculate totals
1172
+ total_aem = sum(period["total_aem"] for period in aem_trends)
1173
+ total_aem_cost = sum(period["total_aem_cost"] for period in aem_trends)
1174
+
1175
+ return {
1176
+ "period_days": days,
1177
+ "group_by": group_by,
1178
+ "start_date": start_date.isoformat(),
1179
+ "end_date": end_date.isoformat(),
1180
+ "total_aem": round(total_aem, 2),
1181
+ "total_aem_cost": round(total_aem_cost, 2),
1182
+ "trends": aem_trends,
1183
+ }
1184
+
1185
+ except HTTPException:
1186
+ raise
1187
+ except Exception as e:
1188
+ logger.error("get_aem_trends_failed", error=str(e), org_id=organization["id"])
1189
+ raise HTTPException(status_code=500, detail=f"Failed to get AEM trends: {str(e)}")
1190
+
1191
+
1192
+ @router.get("/storage/summary")
1193
+ async def get_storage_analytics_summary(
1194
+ request: Request,
1195
+ organization: dict = Depends(get_current_organization),
1196
+ days: int = Query(default=30, ge=1, le=365, description="Number of days to include"),
1197
+ ):
1198
+ """
1199
+ Get storage usage analytics summary.
1200
+
1201
+ Returns:
1202
+ - Current storage usage and quota
1203
+ - File count and type breakdown
1204
+ - Storage growth trend over time
1205
+ - Upload/download bandwidth statistics
1206
+ """
1207
+ try:
1208
+ client = get_supabase()
1209
+
1210
+ # Calculate date range
1211
+ end_date = datetime.utcnow()
1212
+ start_date = end_date - timedelta(days=days)
1213
+ start_date_iso = start_date.isoformat()
1214
+
1215
+ # Get current usage from storage_usage table
1216
+ usage_result = client.table("storage_usage").select("*").eq(
1217
+ "organization_id", organization["id"]
1218
+ ).execute()
1219
+
1220
+ if not usage_result.data or len(usage_result.data) == 0:
1221
+ # No storage usage yet
1222
+ current_usage = {
1223
+ "total_bytes_used": 0,
1224
+ "total_files_count": 0,
1225
+ "quota_bytes": 1073741824, # 1GB default
1226
+ "total_bytes_uploaded": 0,
1227
+ "total_bytes_downloaded": 0
1228
+ }
1229
+ else:
1230
+ current_usage = usage_result.data[0]
1231
+
1232
+ # Get file type breakdown from storage_files
1233
+ files_result = client.table("storage_files").select(
1234
+ "content_type, file_size_bytes, created_at"
1235
+ ).eq("organization_id", organization["id"]).is_("deleted_at", "null").execute()
1236
+
1237
+ files = files_result.data if files_result.data else []
1238
+
1239
+ # Calculate file type breakdown
1240
+ type_breakdown = {}
1241
+ for file in files:
1242
+ content_type = file.get("content_type", "unknown")
1243
+ if content_type not in type_breakdown:
1244
+ type_breakdown[content_type] = {
1245
+ "count": 0,
1246
+ "total_bytes": 0
1247
+ }
1248
+ type_breakdown[content_type]["count"] += 1
1249
+ type_breakdown[content_type]["total_bytes"] += file.get("file_size_bytes", 0)
1250
+
1251
+ # Get storage growth trend (files created over time)
1252
+ files_in_period = [f for f in files if f.get("created_at") and f["created_at"] >= start_date_iso]
1253
+
1254
+ # Group by day
1255
+ growth_by_day = {}
1256
+ for file in files_in_period:
1257
+ created_at = datetime.fromisoformat(file["created_at"].replace("Z", "+00:00"))
1258
+ day_key = created_at.strftime("%Y-%m-%d")
1259
+
1260
+ if day_key not in growth_by_day:
1261
+ growth_by_day[day_key] = {
1262
+ "date": day_key,
1263
+ "files_added": 0,
1264
+ "bytes_added": 0
1265
+ }
1266
+
1267
+ growth_by_day[day_key]["files_added"] += 1
1268
+ growth_by_day[day_key]["bytes_added"] += file.get("file_size_bytes", 0)
1269
+
1270
+ # Convert to sorted list
1271
+ storage_growth_trend = sorted(growth_by_day.values(), key=lambda x: x["date"])
1272
+
1273
+ # Calculate usage percentage
1274
+ usage_percentage = (
1275
+ (current_usage["total_bytes_used"] / current_usage["quota_bytes"]) * 100
1276
+ if current_usage["quota_bytes"] > 0 else 0
1277
+ )
1278
+
1279
+ logger.info(
1280
+ "storage_analytics_retrieved",
1281
+ organization_id=organization["id"],
1282
+ total_files=current_usage["total_files_count"],
1283
+ usage_percentage=round(usage_percentage, 2)
1284
+ )
1285
+
1286
+ return {
1287
+ "period_days": days,
1288
+ "start_date": start_date_iso,
1289
+ "end_date": end_date.isoformat(),
1290
+ "current_usage": {
1291
+ "total_bytes_used": current_usage["total_bytes_used"],
1292
+ "total_files_count": current_usage["total_files_count"],
1293
+ "quota_bytes": current_usage["quota_bytes"],
1294
+ "remaining_bytes": current_usage["quota_bytes"] - current_usage["total_bytes_used"],
1295
+ "usage_percentage": round(usage_percentage, 2),
1296
+ },
1297
+ "bandwidth_usage": {
1298
+ "total_bytes_uploaded": current_usage.get("total_bytes_uploaded", 0),
1299
+ "total_bytes_downloaded": current_usage.get("total_bytes_downloaded", 0),
1300
+ },
1301
+ "file_type_breakdown": type_breakdown,
1302
+ "storage_growth_trend": storage_growth_trend,
1303
+ "total_file_types": len(type_breakdown),
1304
+ }
1305
+
1306
+ except HTTPException:
1307
+ raise
1308
+ except Exception as e:
1309
+ logger.error("get_storage_analytics_failed", error=str(e), org_id=organization["id"])
1310
+ raise HTTPException(status_code=500, detail=f"Failed to get storage analytics: {str(e)}")