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.
- control_plane_api/LICENSE +676 -0
- control_plane_api/README.md +350 -0
- control_plane_api/__init__.py +4 -0
- control_plane_api/__version__.py +8 -0
- control_plane_api/alembic/README +1 -0
- control_plane_api/alembic/env.py +121 -0
- control_plane_api/alembic/script.py.mako +28 -0
- control_plane_api/alembic/versions/2613c65c3dbe_initial_database_setup.py +32 -0
- control_plane_api/alembic/versions/2df520d4927d_merge_heads.py +28 -0
- control_plane_api/alembic/versions/43abf98d6a01_add_paused_status_to_executions.py +73 -0
- control_plane_api/alembic/versions/6289854264cb_merge_multiple_heads.py +28 -0
- control_plane_api/alembic/versions/6a4d4dc3d8dc_generate_execution_transitions.py +50 -0
- control_plane_api/alembic/versions/87d11cf0a783_add_disconnected_status_to_worker_.py +44 -0
- control_plane_api/alembic/versions/add_ephemeral_queue_support.py +85 -0
- control_plane_api/alembic/versions/add_model_type_to_llm_models.py +31 -0
- control_plane_api/alembic/versions/add_plan_executions_table.py +114 -0
- control_plane_api/alembic/versions/add_trace_span_tables.py +154 -0
- control_plane_api/alembic/versions/add_user_info_to_traces.py +36 -0
- control_plane_api/alembic/versions/adjusting_foreign_keys.py +32 -0
- control_plane_api/alembic/versions/b4983d976db2_initial_tables.py +1128 -0
- control_plane_api/alembic/versions/d181a3b40e71_rename_custom_metadata_to_metadata_in_.py +50 -0
- control_plane_api/alembic/versions/df9117888e82_add_missing_columns.py +82 -0
- control_plane_api/alembic/versions/f25de6ad895a_missing_migrations.py +34 -0
- control_plane_api/alembic/versions/f71305fb69b9_fix_ephemeral_queue_deletion_foreign_key.py +54 -0
- control_plane_api/alembic/versions/mark_local_exec_queues_as_ephemeral.py +68 -0
- control_plane_api/alembic.ini +148 -0
- control_plane_api/api/index.py +12 -0
- control_plane_api/app/__init__.py +11 -0
- control_plane_api/app/activities/__init__.py +20 -0
- control_plane_api/app/activities/agent_activities.py +384 -0
- control_plane_api/app/activities/plan_generation_activities.py +499 -0
- control_plane_api/app/activities/team_activities.py +424 -0
- control_plane_api/app/activities/temporal_cloud_activities.py +588 -0
- control_plane_api/app/config/__init__.py +35 -0
- control_plane_api/app/config/api_config.py +469 -0
- control_plane_api/app/config/config_loader.py +224 -0
- control_plane_api/app/config/model_pricing.py +323 -0
- control_plane_api/app/config/storage_config.py +159 -0
- control_plane_api/app/config.py +115 -0
- control_plane_api/app/controllers/__init__.py +0 -0
- control_plane_api/app/controllers/execution_environment_controller.py +1315 -0
- control_plane_api/app/database.py +135 -0
- control_plane_api/app/exceptions.py +408 -0
- control_plane_api/app/lib/__init__.py +11 -0
- control_plane_api/app/lib/environment.py +65 -0
- control_plane_api/app/lib/event_bus/__init__.py +17 -0
- control_plane_api/app/lib/event_bus/base.py +136 -0
- control_plane_api/app/lib/event_bus/manager.py +335 -0
- control_plane_api/app/lib/event_bus/providers/__init__.py +6 -0
- control_plane_api/app/lib/event_bus/providers/http_provider.py +166 -0
- control_plane_api/app/lib/event_bus/providers/nats_provider.py +324 -0
- control_plane_api/app/lib/event_bus/providers/redis_provider.py +233 -0
- control_plane_api/app/lib/event_bus/providers/websocket_provider.py +497 -0
- control_plane_api/app/lib/job_executor.py +330 -0
- control_plane_api/app/lib/kubiya_client.py +293 -0
- control_plane_api/app/lib/litellm_pricing.py +166 -0
- control_plane_api/app/lib/mcp_validation.py +163 -0
- control_plane_api/app/lib/nats/__init__.py +13 -0
- control_plane_api/app/lib/nats/credentials_manager.py +288 -0
- control_plane_api/app/lib/nats/listener.py +374 -0
- control_plane_api/app/lib/planning_prompt_builder.py +153 -0
- control_plane_api/app/lib/planning_tools/__init__.py +41 -0
- control_plane_api/app/lib/planning_tools/agents.py +409 -0
- control_plane_api/app/lib/planning_tools/agno_toolkit.py +836 -0
- control_plane_api/app/lib/planning_tools/base.py +119 -0
- control_plane_api/app/lib/planning_tools/cognitive_memory_tools.py +403 -0
- control_plane_api/app/lib/planning_tools/context_graph_tools.py +545 -0
- control_plane_api/app/lib/planning_tools/environments.py +218 -0
- control_plane_api/app/lib/planning_tools/knowledge.py +204 -0
- control_plane_api/app/lib/planning_tools/models.py +93 -0
- control_plane_api/app/lib/planning_tools/planning_service.py +646 -0
- control_plane_api/app/lib/planning_tools/resources.py +242 -0
- control_plane_api/app/lib/planning_tools/teams.py +334 -0
- control_plane_api/app/lib/policy_enforcer_client.py +1016 -0
- control_plane_api/app/lib/redis_client.py +803 -0
- control_plane_api/app/lib/sqlalchemy_utils.py +486 -0
- control_plane_api/app/lib/state_transition_tools/__init__.py +7 -0
- control_plane_api/app/lib/state_transition_tools/execution_context.py +388 -0
- control_plane_api/app/lib/storage/__init__.py +20 -0
- control_plane_api/app/lib/storage/base_provider.py +274 -0
- control_plane_api/app/lib/storage/provider_factory.py +157 -0
- control_plane_api/app/lib/storage/vercel_blob_provider.py +468 -0
- control_plane_api/app/lib/supabase.py +71 -0
- control_plane_api/app/lib/supabase_utils.py +138 -0
- control_plane_api/app/lib/task_planning/__init__.py +138 -0
- control_plane_api/app/lib/task_planning/agent_factory.py +308 -0
- control_plane_api/app/lib/task_planning/agents.py +389 -0
- control_plane_api/app/lib/task_planning/cache.py +218 -0
- control_plane_api/app/lib/task_planning/entity_resolver.py +273 -0
- control_plane_api/app/lib/task_planning/helpers.py +293 -0
- control_plane_api/app/lib/task_planning/hooks.py +474 -0
- control_plane_api/app/lib/task_planning/models.py +503 -0
- control_plane_api/app/lib/task_planning/plan_validator.py +166 -0
- control_plane_api/app/lib/task_planning/planning_workflow.py +2911 -0
- control_plane_api/app/lib/task_planning/runner.py +656 -0
- control_plane_api/app/lib/task_planning/streaming_hook.py +213 -0
- control_plane_api/app/lib/task_planning/workflow.py +424 -0
- control_plane_api/app/lib/templating/__init__.py +88 -0
- control_plane_api/app/lib/templating/compiler.py +278 -0
- control_plane_api/app/lib/templating/engine.py +178 -0
- control_plane_api/app/lib/templating/parsers/__init__.py +29 -0
- control_plane_api/app/lib/templating/parsers/base.py +96 -0
- control_plane_api/app/lib/templating/parsers/env.py +85 -0
- control_plane_api/app/lib/templating/parsers/graph.py +112 -0
- control_plane_api/app/lib/templating/parsers/secret.py +87 -0
- control_plane_api/app/lib/templating/parsers/simple.py +81 -0
- control_plane_api/app/lib/templating/resolver.py +366 -0
- control_plane_api/app/lib/templating/types.py +214 -0
- control_plane_api/app/lib/templating/validator.py +201 -0
- control_plane_api/app/lib/temporal_client.py +232 -0
- control_plane_api/app/lib/temporal_credentials_cache.py +178 -0
- control_plane_api/app/lib/temporal_credentials_service.py +203 -0
- control_plane_api/app/lib/validation/__init__.py +24 -0
- control_plane_api/app/lib/validation/runtime_validation.py +388 -0
- control_plane_api/app/main.py +531 -0
- control_plane_api/app/middleware/__init__.py +10 -0
- control_plane_api/app/middleware/auth.py +645 -0
- control_plane_api/app/middleware/exception_handler.py +267 -0
- control_plane_api/app/middleware/prometheus_middleware.py +173 -0
- control_plane_api/app/middleware/rate_limiting.py +384 -0
- control_plane_api/app/middleware/request_id.py +202 -0
- control_plane_api/app/models/__init__.py +40 -0
- control_plane_api/app/models/agent.py +90 -0
- control_plane_api/app/models/analytics.py +206 -0
- control_plane_api/app/models/associations.py +107 -0
- control_plane_api/app/models/auth_user.py +73 -0
- control_plane_api/app/models/context.py +161 -0
- control_plane_api/app/models/custom_integration.py +99 -0
- control_plane_api/app/models/environment.py +64 -0
- control_plane_api/app/models/execution.py +125 -0
- control_plane_api/app/models/execution_transition.py +50 -0
- control_plane_api/app/models/job.py +159 -0
- control_plane_api/app/models/llm_model.py +78 -0
- control_plane_api/app/models/orchestration.py +66 -0
- control_plane_api/app/models/plan_execution.py +102 -0
- control_plane_api/app/models/presence.py +49 -0
- control_plane_api/app/models/project.py +61 -0
- control_plane_api/app/models/project_management.py +85 -0
- control_plane_api/app/models/session.py +29 -0
- control_plane_api/app/models/skill.py +155 -0
- control_plane_api/app/models/system_tables.py +43 -0
- control_plane_api/app/models/task_planning.py +372 -0
- control_plane_api/app/models/team.py +86 -0
- control_plane_api/app/models/trace.py +257 -0
- control_plane_api/app/models/user_profile.py +54 -0
- control_plane_api/app/models/worker.py +221 -0
- control_plane_api/app/models/workflow.py +161 -0
- control_plane_api/app/models/workspace.py +50 -0
- control_plane_api/app/observability/__init__.py +177 -0
- control_plane_api/app/observability/context_logging.py +475 -0
- control_plane_api/app/observability/decorators.py +337 -0
- control_plane_api/app/observability/local_span_processor.py +702 -0
- control_plane_api/app/observability/metrics.py +303 -0
- control_plane_api/app/observability/middleware.py +246 -0
- control_plane_api/app/observability/optional.py +115 -0
- control_plane_api/app/observability/tracing.py +382 -0
- control_plane_api/app/policies/README.md +149 -0
- control_plane_api/app/policies/approved_users.rego +62 -0
- control_plane_api/app/policies/business_hours.rego +51 -0
- control_plane_api/app/policies/rate_limiting.rego +100 -0
- control_plane_api/app/policies/tool_enforcement/README.md +336 -0
- control_plane_api/app/policies/tool_enforcement/bash_command_validation.rego +71 -0
- control_plane_api/app/policies/tool_enforcement/business_hours_enforcement.rego +82 -0
- control_plane_api/app/policies/tool_enforcement/mcp_tool_allowlist.rego +58 -0
- control_plane_api/app/policies/tool_enforcement/production_safeguards.rego +80 -0
- control_plane_api/app/policies/tool_enforcement/role_based_tool_access.rego +44 -0
- control_plane_api/app/policies/tool_restrictions.rego +86 -0
- control_plane_api/app/routers/__init__.py +4 -0
- control_plane_api/app/routers/agents.py +382 -0
- control_plane_api/app/routers/agents_v2.py +1598 -0
- control_plane_api/app/routers/analytics.py +1310 -0
- control_plane_api/app/routers/auth.py +59 -0
- control_plane_api/app/routers/client_config.py +57 -0
- control_plane_api/app/routers/context_graph.py +561 -0
- control_plane_api/app/routers/context_manager.py +577 -0
- control_plane_api/app/routers/custom_integrations.py +490 -0
- control_plane_api/app/routers/enforcer.py +132 -0
- control_plane_api/app/routers/environment_context.py +252 -0
- control_plane_api/app/routers/environments.py +761 -0
- control_plane_api/app/routers/execution_environment.py +847 -0
- control_plane_api/app/routers/executions/__init__.py +28 -0
- control_plane_api/app/routers/executions/router.py +286 -0
- control_plane_api/app/routers/executions/services/__init__.py +22 -0
- control_plane_api/app/routers/executions/services/demo_worker_health.py +156 -0
- control_plane_api/app/routers/executions/services/status_service.py +420 -0
- control_plane_api/app/routers/executions/services/test_worker_health.py +480 -0
- control_plane_api/app/routers/executions/services/worker_health.py +514 -0
- control_plane_api/app/routers/executions/streaming/__init__.py +22 -0
- control_plane_api/app/routers/executions/streaming/deduplication.py +352 -0
- control_plane_api/app/routers/executions/streaming/event_buffer.py +353 -0
- control_plane_api/app/routers/executions/streaming/event_formatter.py +964 -0
- control_plane_api/app/routers/executions/streaming/history_loader.py +588 -0
- control_plane_api/app/routers/executions/streaming/live_source.py +693 -0
- control_plane_api/app/routers/executions/streaming/streamer.py +849 -0
- control_plane_api/app/routers/executions.py +4888 -0
- control_plane_api/app/routers/health.py +165 -0
- control_plane_api/app/routers/health_v2.py +394 -0
- control_plane_api/app/routers/integration_templates.py +496 -0
- control_plane_api/app/routers/integrations.py +287 -0
- control_plane_api/app/routers/jobs.py +1809 -0
- control_plane_api/app/routers/metrics.py +517 -0
- control_plane_api/app/routers/models.py +82 -0
- control_plane_api/app/routers/models_v2.py +628 -0
- control_plane_api/app/routers/plan_executions.py +1481 -0
- control_plane_api/app/routers/plan_generation_async.py +304 -0
- control_plane_api/app/routers/policies.py +669 -0
- control_plane_api/app/routers/presence.py +234 -0
- control_plane_api/app/routers/projects.py +987 -0
- control_plane_api/app/routers/runners.py +379 -0
- control_plane_api/app/routers/runtimes.py +172 -0
- control_plane_api/app/routers/secrets.py +171 -0
- control_plane_api/app/routers/skills.py +1010 -0
- control_plane_api/app/routers/skills_definitions.py +140 -0
- control_plane_api/app/routers/storage.py +456 -0
- control_plane_api/app/routers/task_planning.py +611 -0
- control_plane_api/app/routers/task_queues.py +650 -0
- control_plane_api/app/routers/team_context.py +274 -0
- control_plane_api/app/routers/teams.py +1747 -0
- control_plane_api/app/routers/templates.py +248 -0
- control_plane_api/app/routers/traces.py +571 -0
- control_plane_api/app/routers/websocket_client.py +479 -0
- control_plane_api/app/routers/websocket_executions_status.py +437 -0
- control_plane_api/app/routers/websocket_gateway.py +323 -0
- control_plane_api/app/routers/websocket_traces.py +576 -0
- control_plane_api/app/routers/worker_queues.py +2555 -0
- control_plane_api/app/routers/worker_websocket.py +419 -0
- control_plane_api/app/routers/workers.py +1004 -0
- control_plane_api/app/routers/workflows.py +204 -0
- control_plane_api/app/runtimes/__init__.py +6 -0
- control_plane_api/app/runtimes/validation.py +344 -0
- control_plane_api/app/schemas/__init__.py +1 -0
- control_plane_api/app/schemas/job_schemas.py +302 -0
- control_plane_api/app/schemas/mcp_schemas.py +311 -0
- control_plane_api/app/schemas/template_schemas.py +133 -0
- control_plane_api/app/schemas/trace_schemas.py +168 -0
- control_plane_api/app/schemas/worker_queue_observability_schemas.py +165 -0
- control_plane_api/app/services/__init__.py +1 -0
- control_plane_api/app/services/agno_planning_strategy.py +233 -0
- control_plane_api/app/services/agno_service.py +838 -0
- control_plane_api/app/services/claude_code_planning_service.py +203 -0
- control_plane_api/app/services/context_graph_client.py +224 -0
- control_plane_api/app/services/custom_integration_service.py +415 -0
- control_plane_api/app/services/integration_resolution_service.py +345 -0
- control_plane_api/app/services/litellm_service.py +394 -0
- control_plane_api/app/services/plan_generator.py +79 -0
- control_plane_api/app/services/planning_strategy.py +66 -0
- control_plane_api/app/services/planning_strategy_factory.py +118 -0
- control_plane_api/app/services/policy_service.py +615 -0
- control_plane_api/app/services/state_transition_service.py +755 -0
- control_plane_api/app/services/storage_service.py +593 -0
- control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
- control_plane_api/app/services/toolsets/context_graph_skill.py +432 -0
- control_plane_api/app/services/trace_retention.py +354 -0
- control_plane_api/app/services/worker_queue_metrics_service.py +190 -0
- control_plane_api/app/services/workflow_cancellation_manager.py +135 -0
- control_plane_api/app/services/workflow_operations_service.py +611 -0
- control_plane_api/app/skills/__init__.py +100 -0
- control_plane_api/app/skills/base.py +239 -0
- control_plane_api/app/skills/builtin/__init__.py +37 -0
- control_plane_api/app/skills/builtin/agent_communication/__init__.py +8 -0
- control_plane_api/app/skills/builtin/agent_communication/skill.py +246 -0
- control_plane_api/app/skills/builtin/code_ingestion/__init__.py +4 -0
- control_plane_api/app/skills/builtin/code_ingestion/skill.py +267 -0
- control_plane_api/app/skills/builtin/cognitive_memory/__init__.py +4 -0
- control_plane_api/app/skills/builtin/cognitive_memory/skill.py +174 -0
- control_plane_api/app/skills/builtin/contextual_awareness/__init__.py +4 -0
- control_plane_api/app/skills/builtin/contextual_awareness/skill.py +387 -0
- control_plane_api/app/skills/builtin/data_visualization/__init__.py +4 -0
- control_plane_api/app/skills/builtin/data_visualization/skill.py +154 -0
- control_plane_api/app/skills/builtin/docker/__init__.py +4 -0
- control_plane_api/app/skills/builtin/docker/skill.py +104 -0
- control_plane_api/app/skills/builtin/file_generation/__init__.py +4 -0
- control_plane_api/app/skills/builtin/file_generation/skill.py +94 -0
- control_plane_api/app/skills/builtin/file_system/__init__.py +4 -0
- control_plane_api/app/skills/builtin/file_system/skill.py +110 -0
- control_plane_api/app/skills/builtin/knowledge_api/__init__.py +5 -0
- control_plane_api/app/skills/builtin/knowledge_api/skill.py +124 -0
- control_plane_api/app/skills/builtin/python/__init__.py +4 -0
- control_plane_api/app/skills/builtin/python/skill.py +92 -0
- control_plane_api/app/skills/builtin/remote_filesystem/__init__.py +5 -0
- control_plane_api/app/skills/builtin/remote_filesystem/skill.py +170 -0
- control_plane_api/app/skills/builtin/shell/__init__.py +4 -0
- control_plane_api/app/skills/builtin/shell/skill.py +161 -0
- control_plane_api/app/skills/builtin/slack/__init__.py +3 -0
- control_plane_api/app/skills/builtin/slack/skill.py +302 -0
- control_plane_api/app/skills/builtin/workflow_executor/__init__.py +4 -0
- control_plane_api/app/skills/builtin/workflow_executor/skill.py +469 -0
- control_plane_api/app/skills/business_intelligence.py +189 -0
- control_plane_api/app/skills/config.py +63 -0
- control_plane_api/app/skills/loaders/__init__.py +14 -0
- control_plane_api/app/skills/loaders/base.py +73 -0
- control_plane_api/app/skills/loaders/filesystem_loader.py +199 -0
- control_plane_api/app/skills/registry.py +125 -0
- control_plane_api/app/utils/helpers.py +12 -0
- control_plane_api/app/utils/workflow_executor.py +354 -0
- control_plane_api/app/workflows/__init__.py +11 -0
- control_plane_api/app/workflows/agent_execution.py +520 -0
- control_plane_api/app/workflows/agent_execution_with_skills.py +223 -0
- control_plane_api/app/workflows/namespace_provisioning.py +326 -0
- control_plane_api/app/workflows/plan_generation.py +254 -0
- control_plane_api/app/workflows/team_execution.py +442 -0
- control_plane_api/scripts/seed_models.py +240 -0
- control_plane_api/scripts/validate_existing_tool_names.py +492 -0
- control_plane_api/shared/__init__.py +8 -0
- control_plane_api/shared/version.py +17 -0
- control_plane_api/test_deduplication.py +274 -0
- control_plane_api/test_executor_deduplication_e2e.py +309 -0
- control_plane_api/test_job_execution_e2e.py +283 -0
- control_plane_api/test_real_integration.py +193 -0
- control_plane_api/version.py +38 -0
- control_plane_api/worker/__init__.py +0 -0
- control_plane_api/worker/activities/__init__.py +0 -0
- control_plane_api/worker/activities/agent_activities.py +1585 -0
- control_plane_api/worker/activities/approval_activities.py +234 -0
- control_plane_api/worker/activities/job_activities.py +199 -0
- control_plane_api/worker/activities/runtime_activities.py +1167 -0
- control_plane_api/worker/activities/skill_activities.py +282 -0
- control_plane_api/worker/activities/team_activities.py +479 -0
- control_plane_api/worker/agent_runtime_server.py +370 -0
- control_plane_api/worker/binary_manager.py +333 -0
- control_plane_api/worker/config/__init__.py +31 -0
- control_plane_api/worker/config/worker_config.py +273 -0
- control_plane_api/worker/control_plane_client.py +1491 -0
- control_plane_api/worker/examples/analytics_integration_example.py +362 -0
- control_plane_api/worker/health_monitor.py +159 -0
- control_plane_api/worker/metrics.py +237 -0
- control_plane_api/worker/models/__init__.py +1 -0
- control_plane_api/worker/models/error_events.py +105 -0
- control_plane_api/worker/models/inputs.py +89 -0
- control_plane_api/worker/runtimes/__init__.py +35 -0
- control_plane_api/worker/runtimes/agent_runtime/runtime.py +485 -0
- control_plane_api/worker/runtimes/agno/__init__.py +34 -0
- control_plane_api/worker/runtimes/agno/config.py +248 -0
- control_plane_api/worker/runtimes/agno/hooks.py +385 -0
- control_plane_api/worker/runtimes/agno/mcp_builder.py +195 -0
- control_plane_api/worker/runtimes/agno/runtime.py +1063 -0
- control_plane_api/worker/runtimes/agno/utils.py +163 -0
- control_plane_api/worker/runtimes/base.py +979 -0
- control_plane_api/worker/runtimes/claude_code/__init__.py +38 -0
- control_plane_api/worker/runtimes/claude_code/cleanup.py +184 -0
- control_plane_api/worker/runtimes/claude_code/client_pool.py +529 -0
- control_plane_api/worker/runtimes/claude_code/config.py +829 -0
- control_plane_api/worker/runtimes/claude_code/hooks.py +482 -0
- control_plane_api/worker/runtimes/claude_code/litellm_proxy.py +1702 -0
- control_plane_api/worker/runtimes/claude_code/mcp_builder.py +467 -0
- control_plane_api/worker/runtimes/claude_code/mcp_discovery.py +558 -0
- control_plane_api/worker/runtimes/claude_code/runtime.py +1546 -0
- control_plane_api/worker/runtimes/claude_code/tool_mapper.py +403 -0
- control_plane_api/worker/runtimes/claude_code/utils.py +149 -0
- control_plane_api/worker/runtimes/factory.py +173 -0
- control_plane_api/worker/runtimes/model_utils.py +107 -0
- control_plane_api/worker/runtimes/validation.py +93 -0
- control_plane_api/worker/services/__init__.py +1 -0
- control_plane_api/worker/services/agent_communication_tools.py +908 -0
- control_plane_api/worker/services/agent_executor.py +485 -0
- control_plane_api/worker/services/agent_executor_v2.py +793 -0
- control_plane_api/worker/services/analytics_collector.py +457 -0
- control_plane_api/worker/services/analytics_service.py +464 -0
- control_plane_api/worker/services/approval_tools.py +310 -0
- control_plane_api/worker/services/approval_tools_agno.py +207 -0
- control_plane_api/worker/services/cancellation_manager.py +177 -0
- control_plane_api/worker/services/code_ingestion_tools.py +465 -0
- control_plane_api/worker/services/contextual_awareness_tools.py +405 -0
- control_plane_api/worker/services/data_visualization.py +834 -0
- control_plane_api/worker/services/event_publisher.py +531 -0
- control_plane_api/worker/services/jira_tools.py +257 -0
- control_plane_api/worker/services/remote_filesystem_tools.py +498 -0
- control_plane_api/worker/services/runtime_analytics.py +328 -0
- control_plane_api/worker/services/session_service.py +365 -0
- control_plane_api/worker/services/skill_context_enhancement.py +181 -0
- control_plane_api/worker/services/skill_factory.py +471 -0
- control_plane_api/worker/services/system_prompt_enhancement.py +410 -0
- control_plane_api/worker/services/team_executor.py +715 -0
- control_plane_api/worker/services/team_executor_v2.py +1866 -0
- control_plane_api/worker/services/tool_enforcement.py +254 -0
- control_plane_api/worker/services/workflow_executor/__init__.py +52 -0
- control_plane_api/worker/services/workflow_executor/event_processor.py +287 -0
- control_plane_api/worker/services/workflow_executor/event_publisher.py +210 -0
- control_plane_api/worker/services/workflow_executor/executors/__init__.py +15 -0
- control_plane_api/worker/services/workflow_executor/executors/base.py +270 -0
- control_plane_api/worker/services/workflow_executor/executors/json_executor.py +50 -0
- control_plane_api/worker/services/workflow_executor/executors/python_executor.py +50 -0
- control_plane_api/worker/services/workflow_executor/models.py +142 -0
- control_plane_api/worker/services/workflow_executor_tools.py +1748 -0
- control_plane_api/worker/skills/__init__.py +12 -0
- control_plane_api/worker/skills/builtin/context_graph_search/README.md +213 -0
- control_plane_api/worker/skills/builtin/context_graph_search/__init__.py +5 -0
- control_plane_api/worker/skills/builtin/context_graph_search/agno_impl.py +808 -0
- control_plane_api/worker/skills/builtin/context_graph_search/skill.yaml +67 -0
- control_plane_api/worker/skills/builtin/contextual_awareness/__init__.py +4 -0
- control_plane_api/worker/skills/builtin/contextual_awareness/agno_impl.py +62 -0
- control_plane_api/worker/skills/builtin/data_visualization/agno_impl.py +18 -0
- control_plane_api/worker/skills/builtin/data_visualization/skill.yaml +84 -0
- control_plane_api/worker/skills/builtin/docker/agno_impl.py +65 -0
- control_plane_api/worker/skills/builtin/docker/skill.yaml +60 -0
- control_plane_api/worker/skills/builtin/file_generation/agno_impl.py +47 -0
- control_plane_api/worker/skills/builtin/file_generation/skill.yaml +64 -0
- control_plane_api/worker/skills/builtin/file_system/agno_impl.py +32 -0
- control_plane_api/worker/skills/builtin/file_system/skill.yaml +54 -0
- control_plane_api/worker/skills/builtin/knowledge_api/__init__.py +4 -0
- control_plane_api/worker/skills/builtin/knowledge_api/agno_impl.py +50 -0
- control_plane_api/worker/skills/builtin/knowledge_api/skill.yaml +66 -0
- control_plane_api/worker/skills/builtin/python/agno_impl.py +25 -0
- control_plane_api/worker/skills/builtin/python/skill.yaml +60 -0
- control_plane_api/worker/skills/builtin/schema_fix_mixin.py +260 -0
- control_plane_api/worker/skills/builtin/shell/agno_impl.py +31 -0
- control_plane_api/worker/skills/builtin/shell/skill.yaml +60 -0
- control_plane_api/worker/skills/builtin/slack/__init__.py +3 -0
- control_plane_api/worker/skills/builtin/slack/agno_impl.py +1282 -0
- control_plane_api/worker/skills/builtin/slack/skill.yaml +276 -0
- control_plane_api/worker/skills/builtin/workflow_executor/agno_impl.py +62 -0
- control_plane_api/worker/skills/builtin/workflow_executor/skill.yaml +79 -0
- control_plane_api/worker/skills/loaders/__init__.py +5 -0
- control_plane_api/worker/skills/loaders/base.py +23 -0
- control_plane_api/worker/skills/loaders/filesystem_loader.py +357 -0
- control_plane_api/worker/skills/registry.py +208 -0
- control_plane_api/worker/tests/__init__.py +1 -0
- control_plane_api/worker/tests/conftest.py +12 -0
- control_plane_api/worker/tests/e2e/__init__.py +0 -0
- control_plane_api/worker/tests/e2e/test_context_graph_real_api.py +338 -0
- control_plane_api/worker/tests/e2e/test_context_graph_templates_e2e.py +523 -0
- control_plane_api/worker/tests/e2e/test_enforcement_e2e.py +344 -0
- control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
- control_plane_api/worker/tests/e2e/test_single_execution_mode.py +656 -0
- control_plane_api/worker/tests/integration/__init__.py +0 -0
- control_plane_api/worker/tests/integration/test_builtin_skills_fixes.py +245 -0
- control_plane_api/worker/tests/integration/test_context_graph_search_integration.py +365 -0
- control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
- control_plane_api/worker/tests/integration/test_hook_enforcement_integration.py +579 -0
- control_plane_api/worker/tests/integration/test_scheduled_job_workflow.py +237 -0
- control_plane_api/worker/tests/integration/test_system_prompt_enhancement_integration.py +343 -0
- control_plane_api/worker/tests/unit/__init__.py +0 -0
- control_plane_api/worker/tests/unit/test_builtin_skill_autoload.py +396 -0
- control_plane_api/worker/tests/unit/test_context_graph_search.py +450 -0
- control_plane_api/worker/tests/unit/test_context_graph_templates.py +403 -0
- control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
- control_plane_api/worker/tests/unit/test_control_plane_client_jobs.py +345 -0
- control_plane_api/worker/tests/unit/test_job_activities.py +353 -0
- control_plane_api/worker/tests/unit/test_skill_context_enhancement.py +321 -0
- control_plane_api/worker/tests/unit/test_system_prompt_enhancement.py +415 -0
- control_plane_api/worker/tests/unit/test_tool_enforcement.py +324 -0
- control_plane_api/worker/utils/__init__.py +1 -0
- control_plane_api/worker/utils/chunk_batcher.py +330 -0
- control_plane_api/worker/utils/environment.py +65 -0
- control_plane_api/worker/utils/error_publisher.py +260 -0
- control_plane_api/worker/utils/event_batcher.py +256 -0
- control_plane_api/worker/utils/logging_config.py +335 -0
- control_plane_api/worker/utils/logging_helper.py +326 -0
- control_plane_api/worker/utils/parameter_validator.py +120 -0
- control_plane_api/worker/utils/retry_utils.py +60 -0
- control_plane_api/worker/utils/streaming_utils.py +665 -0
- control_plane_api/worker/utils/tool_validation.py +332 -0
- control_plane_api/worker/utils/workspace_manager.py +163 -0
- control_plane_api/worker/websocket_client.py +393 -0
- control_plane_api/worker/worker.py +1297 -0
- control_plane_api/worker/workflows/__init__.py +0 -0
- control_plane_api/worker/workflows/agent_execution.py +909 -0
- control_plane_api/worker/workflows/scheduled_job_wrapper.py +332 -0
- control_plane_api/worker/workflows/team_execution.py +611 -0
- kubiya_control_plane_api-0.9.15.dist-info/METADATA +354 -0
- kubiya_control_plane_api-0.9.15.dist-info/RECORD +479 -0
- kubiya_control_plane_api-0.9.15.dist-info/WHEEL +5 -0
- kubiya_control_plane_api-0.9.15.dist-info/entry_points.txt +5 -0
- kubiya_control_plane_api-0.9.15.dist-info/licenses/LICENSE +676 -0
- kubiya_control_plane_api-0.9.15.dist-info/top_level.txt +3 -0
- scripts/__init__.py +1 -0
- scripts/migrations.py +39 -0
- scripts/seed_worker_queues.py +128 -0
- scripts/setup_agent_runtime.py +142 -0
- worker_internal/__init__.py +1 -0
- worker_internal/planner/__init__.py +1 -0
- worker_internal/planner/activities.py +1499 -0
- worker_internal/planner/agent_tools.py +197 -0
- worker_internal/planner/event_models.py +148 -0
- worker_internal/planner/event_publisher.py +67 -0
- worker_internal/planner/models.py +199 -0
- worker_internal/planner/retry_logic.py +134 -0
- worker_internal/planner/worker.py +300 -0
- 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)}")
|