htmlgraph 0.9.3__py3-none-any.whl → 0.27.5__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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +173 -17
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +127 -0
- htmlgraph/agent_registry.py +45 -30
- htmlgraph/agents.py +160 -107
- htmlgraph/analytics/__init__.py +9 -2
- htmlgraph/analytics/cli.py +190 -51
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +192 -100
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +190 -14
- htmlgraph/analytics_index.py +135 -51
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +208 -0
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/__init__.py +14 -0
- htmlgraph/builders/base.py +118 -29
- htmlgraph/builders/bug.py +150 -0
- htmlgraph/builders/chore.py +119 -0
- htmlgraph/builders/epic.py +150 -0
- htmlgraph/builders/feature.py +31 -6
- htmlgraph/builders/insight.py +195 -0
- htmlgraph/builders/metric.py +217 -0
- htmlgraph/builders/pattern.py +202 -0
- htmlgraph/builders/phase.py +162 -0
- htmlgraph/builders/spike.py +52 -19
- htmlgraph/builders/track.py +148 -72
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +18 -0
- htmlgraph/collections/base.py +415 -98
- htmlgraph/collections/bug.py +53 -0
- htmlgraph/collections/chore.py +53 -0
- htmlgraph/collections/epic.py +53 -0
- htmlgraph/collections/feature.py +12 -26
- htmlgraph/collections/insight.py +100 -0
- htmlgraph/collections/metric.py +92 -0
- htmlgraph/collections/pattern.py +97 -0
- htmlgraph/collections/phase.py +53 -0
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +56 -16
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +511 -0
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +344 -0
- htmlgraph/converter.py +216 -28
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2406 -307
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +19 -2
- htmlgraph/deploy.py +142 -125
- htmlgraph/deployment_models.py +474 -0
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +182 -27
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +100 -52
- htmlgraph/event_migration.py +13 -4
- htmlgraph/exceptions.py +49 -0
- htmlgraph/file_watcher.py +101 -28
- htmlgraph/find_api.py +75 -63
- htmlgraph/git_events.py +145 -63
- htmlgraph/graph.py +1122 -106
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +45 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +1314 -0
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/hooks-config.example.json +12 -0
- htmlgraph/hooks/installer.py +343 -0
- htmlgraph/hooks/orchestrator.py +674 -0
- htmlgraph/hooks/orchestrator_reflector.py +223 -0
- htmlgraph/hooks/post-checkout.sh +28 -0
- htmlgraph/hooks/post-commit.sh +24 -0
- htmlgraph/hooks/post-merge.sh +26 -0
- htmlgraph/hooks/post_tool_use_failure.py +273 -0
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +408 -0
- htmlgraph/hooks/pre-commit.sh +94 -0
- htmlgraph/hooks/pre-push.sh +28 -0
- htmlgraph/hooks/pretooluse.py +819 -0
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +255 -0
- htmlgraph/hooks/task_validator.py +177 -0
- htmlgraph/hooks/validator.py +628 -0
- htmlgraph/ids.py +41 -27
- htmlgraph/index.d.ts +286 -0
- htmlgraph/learning.py +767 -0
- htmlgraph/mcp_server.py +69 -23
- htmlgraph/models.py +1586 -87
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/orchestration/task_coordination.py +343 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +669 -0
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +328 -0
- htmlgraph/orchestrator_validator.py +133 -0
- htmlgraph/parallel.py +646 -0
- htmlgraph/parser.py +160 -35
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/planning.py +147 -52
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +109 -72
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/routing.py +8 -19
- htmlgraph/scripts/deploy.py +1 -2
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +685 -180
- htmlgraph/services/__init__.py +10 -0
- htmlgraph/services/claiming.py +199 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +1392 -175
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +201 -0
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/setup.py +34 -17
- htmlgraph/spike_index.py +143 -0
- htmlgraph/sync_docs.py +12 -15
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph/templates/GEMINI.md.template +87 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +146 -15
- htmlgraph/track_manager.py +69 -21
- htmlgraph/transcript.py +890 -0
- htmlgraph/transcript_analytics.py +699 -0
- htmlgraph/types.py +323 -0
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +8 -5
- htmlgraph/work_type_utils.py +3 -2
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
- htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -2688
- htmlgraph/sdk.py +0 -709
- htmlgraph-0.9.3.dist-info/RECORD +0 -61
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/api/main.py
ADDED
|
@@ -0,0 +1,2498 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HtmlGraph FastAPI Backend - Real-time Agent Observability Dashboard
|
|
3
|
+
|
|
4
|
+
Provides REST API and WebSocket support for viewing:
|
|
5
|
+
- Agent activity feed with real-time event streaming
|
|
6
|
+
- Orchestration chains and delegation handoffs
|
|
7
|
+
- Feature tracker with Kanban views
|
|
8
|
+
- Session metrics and performance analytics
|
|
9
|
+
|
|
10
|
+
Architecture:
|
|
11
|
+
- FastAPI backend querying SQLite database
|
|
12
|
+
- Jinja2 templates for server-side rendering
|
|
13
|
+
- HTMX for interactive UI without page reloads
|
|
14
|
+
- WebSocket for real-time event streaming
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import random
|
|
21
|
+
import sqlite3
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import aiosqlite
|
|
28
|
+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
29
|
+
from fastapi.responses import HTMLResponse
|
|
30
|
+
from fastapi.staticfiles import StaticFiles
|
|
31
|
+
from fastapi.templating import Jinja2Templates
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class QueryCache:
|
|
38
|
+
"""Simple in-memory cache with TTL support for query results."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, ttl_seconds: float = 30.0):
|
|
41
|
+
"""Initialize query cache with TTL."""
|
|
42
|
+
self.cache: dict[str, tuple[Any, float]] = {}
|
|
43
|
+
self.ttl_seconds = ttl_seconds
|
|
44
|
+
self.metrics: dict[str, dict[str, float]] = {}
|
|
45
|
+
|
|
46
|
+
def get(self, key: str) -> Any | None:
|
|
47
|
+
"""Get cached value if exists and not expired."""
|
|
48
|
+
if key not in self.cache:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
value, timestamp = self.cache[key]
|
|
52
|
+
if time.time() - timestamp > self.ttl_seconds:
|
|
53
|
+
del self.cache[key]
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
def set(self, key: str, value: Any) -> None:
|
|
59
|
+
"""Store value with current timestamp."""
|
|
60
|
+
self.cache[key] = (value, time.time())
|
|
61
|
+
|
|
62
|
+
def record_metric(self, key: str, query_time_ms: float, cache_hit: bool) -> None:
|
|
63
|
+
"""Record performance metrics for a query."""
|
|
64
|
+
if key not in self.metrics:
|
|
65
|
+
self.metrics[key] = {"count": 0, "total_ms": 0, "avg_ms": 0, "hits": 0}
|
|
66
|
+
|
|
67
|
+
metrics = self.metrics[key]
|
|
68
|
+
metrics["count"] += 1
|
|
69
|
+
metrics["total_ms"] += query_time_ms
|
|
70
|
+
metrics["avg_ms"] = metrics["total_ms"] / metrics["count"]
|
|
71
|
+
if cache_hit:
|
|
72
|
+
metrics["hits"] += 1
|
|
73
|
+
|
|
74
|
+
def get_metrics(self) -> dict[str, dict[str, float]]:
|
|
75
|
+
"""Get all collected metrics."""
|
|
76
|
+
return self.metrics
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EventModel(BaseModel):
|
|
80
|
+
"""Event data model for API responses."""
|
|
81
|
+
|
|
82
|
+
event_id: str
|
|
83
|
+
agent_id: str
|
|
84
|
+
event_type: str
|
|
85
|
+
timestamp: str
|
|
86
|
+
tool_name: str | None = None
|
|
87
|
+
input_summary: str | None = None
|
|
88
|
+
output_summary: str | None = None
|
|
89
|
+
session_id: str
|
|
90
|
+
feature_id: str | None = None
|
|
91
|
+
parent_event_id: str | None = None
|
|
92
|
+
status: str
|
|
93
|
+
model: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FeatureModel(BaseModel):
|
|
97
|
+
"""Feature data model for API responses."""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
type: str
|
|
101
|
+
title: str
|
|
102
|
+
description: str | None = None
|
|
103
|
+
status: str
|
|
104
|
+
priority: str
|
|
105
|
+
assigned_to: str | None = None
|
|
106
|
+
created_at: str
|
|
107
|
+
updated_at: str
|
|
108
|
+
completed_at: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SessionModel(BaseModel):
|
|
112
|
+
"""Session data model for API responses."""
|
|
113
|
+
|
|
114
|
+
session_id: str
|
|
115
|
+
agent: str | None = None
|
|
116
|
+
status: str
|
|
117
|
+
started_at: str
|
|
118
|
+
ended_at: str | None = None
|
|
119
|
+
event_count: int = 0
|
|
120
|
+
duration_seconds: float | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ensure_database_initialized(db_path: str) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Ensure SQLite database exists and has correct schema.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
db_path: Path to SQLite database file
|
|
129
|
+
"""
|
|
130
|
+
db_file = Path(db_path)
|
|
131
|
+
db_file.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
# Check if database exists and has tables
|
|
134
|
+
try:
|
|
135
|
+
conn = sqlite3.connect(db_path)
|
|
136
|
+
cursor = conn.cursor()
|
|
137
|
+
|
|
138
|
+
# Query existing tables
|
|
139
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
140
|
+
tables = cursor.fetchall()
|
|
141
|
+
table_names = [t[0] for t in tables]
|
|
142
|
+
|
|
143
|
+
if not table_names:
|
|
144
|
+
# Database is empty, create schema
|
|
145
|
+
logger.info(f"Creating database schema at {db_path}")
|
|
146
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
147
|
+
|
|
148
|
+
db = HtmlGraphDB(db_path)
|
|
149
|
+
db.connect()
|
|
150
|
+
db.create_tables()
|
|
151
|
+
db.disconnect()
|
|
152
|
+
logger.info("Database schema created successfully")
|
|
153
|
+
else:
|
|
154
|
+
logger.debug(f"Database already initialized with tables: {table_names}")
|
|
155
|
+
|
|
156
|
+
conn.close()
|
|
157
|
+
|
|
158
|
+
except sqlite3.Error as e:
|
|
159
|
+
logger.warning(f"Database check warning: {e}")
|
|
160
|
+
# Try to create anyway
|
|
161
|
+
try:
|
|
162
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
163
|
+
|
|
164
|
+
db = HtmlGraphDB(db_path)
|
|
165
|
+
db.connect()
|
|
166
|
+
db.create_tables()
|
|
167
|
+
db.disconnect()
|
|
168
|
+
except Exception as create_error:
|
|
169
|
+
logger.error(f"Failed to create database: {create_error}")
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_app(db_path: str) -> FastAPI:
|
|
174
|
+
"""
|
|
175
|
+
Create and configure FastAPI application.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
db_path: Path to SQLite database file
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Configured FastAPI application instance
|
|
182
|
+
"""
|
|
183
|
+
# Ensure database is initialized
|
|
184
|
+
_ensure_database_initialized(db_path)
|
|
185
|
+
|
|
186
|
+
app = FastAPI(
|
|
187
|
+
title="HtmlGraph Dashboard API",
|
|
188
|
+
description="Real-time agent observability dashboard",
|
|
189
|
+
version="0.1.0",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Store database path and query cache in app state
|
|
193
|
+
app.state.db_path = db_path
|
|
194
|
+
app.state.query_cache = QueryCache(ttl_seconds=1.0) # Short TTL for real-time data
|
|
195
|
+
|
|
196
|
+
# Setup Jinja2 templates
|
|
197
|
+
template_dir = Path(__file__).parent / "templates"
|
|
198
|
+
template_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
templates = Jinja2Templates(directory=str(template_dir))
|
|
200
|
+
|
|
201
|
+
# Add custom filters
|
|
202
|
+
def format_number(value: int | None) -> str:
|
|
203
|
+
if value is None:
|
|
204
|
+
return "0"
|
|
205
|
+
return f"{value:,}"
|
|
206
|
+
|
|
207
|
+
def format_duration(seconds: float | int | None) -> str:
|
|
208
|
+
"""Format duration in seconds to human-readable string."""
|
|
209
|
+
if seconds is None:
|
|
210
|
+
return "0.00s"
|
|
211
|
+
return f"{float(seconds):.2f}s"
|
|
212
|
+
|
|
213
|
+
def format_bytes(bytes_size: int | float | None) -> str:
|
|
214
|
+
"""Format bytes to MB with 2 decimal places."""
|
|
215
|
+
if bytes_size is None:
|
|
216
|
+
return "0.00MB"
|
|
217
|
+
return f"{int(bytes_size) / (1024 * 1024):.2f}MB"
|
|
218
|
+
|
|
219
|
+
def truncate_text(text: str | None, length: int = 50) -> str:
|
|
220
|
+
"""Truncate text to specified length with ellipsis."""
|
|
221
|
+
if text is None:
|
|
222
|
+
return ""
|
|
223
|
+
return text[:length] + "..." if len(text) > length else text
|
|
224
|
+
|
|
225
|
+
def format_timestamp(ts: Any) -> str:
|
|
226
|
+
"""Format timestamp to readable string."""
|
|
227
|
+
if ts is None:
|
|
228
|
+
return ""
|
|
229
|
+
if hasattr(ts, "strftime"):
|
|
230
|
+
return str(ts.strftime("%Y-%m-%d %H:%M:%S"))
|
|
231
|
+
return str(ts)
|
|
232
|
+
|
|
233
|
+
templates.env.filters["format_number"] = format_number
|
|
234
|
+
templates.env.filters["format_duration"] = format_duration
|
|
235
|
+
templates.env.filters["format_bytes"] = format_bytes
|
|
236
|
+
templates.env.filters["truncate"] = truncate_text
|
|
237
|
+
templates.env.filters["format_timestamp"] = format_timestamp
|
|
238
|
+
|
|
239
|
+
# Setup static files
|
|
240
|
+
static_dir = Path(__file__).parent / "static"
|
|
241
|
+
static_dir.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
if static_dir.exists():
|
|
243
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
244
|
+
|
|
245
|
+
# ========== DATABASE HELPERS ==========
|
|
246
|
+
|
|
247
|
+
async def get_db() -> aiosqlite.Connection:
|
|
248
|
+
"""Get database connection with busy_timeout to prevent lock errors."""
|
|
249
|
+
db = await aiosqlite.connect(app.state.db_path)
|
|
250
|
+
db.row_factory = aiosqlite.Row
|
|
251
|
+
# Set busy_timeout to 5 seconds - prevents "database is locked" errors
|
|
252
|
+
# during concurrent access from spawner scripts and WebSocket polling
|
|
253
|
+
await db.execute("PRAGMA busy_timeout = 5000")
|
|
254
|
+
return db
|
|
255
|
+
|
|
256
|
+
# ========== ROUTES ==========
|
|
257
|
+
|
|
258
|
+
@app.get("/", response_class=HTMLResponse)
|
|
259
|
+
async def dashboard(request: Request) -> HTMLResponse:
|
|
260
|
+
"""Main dashboard view with navigation tabs."""
|
|
261
|
+
return templates.TemplateResponse(
|
|
262
|
+
"dashboard-redesign.html",
|
|
263
|
+
{
|
|
264
|
+
"request": request,
|
|
265
|
+
"title": "HtmlGraph Agent Observability",
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# ========== AGENTS ENDPOINTS ==========
|
|
270
|
+
|
|
271
|
+
@app.get("/views/agents", response_class=HTMLResponse)
|
|
272
|
+
async def agents_view(request: Request) -> HTMLResponse:
|
|
273
|
+
"""Get agent workload and performance stats as HTMX partial."""
|
|
274
|
+
db = await get_db()
|
|
275
|
+
cache = app.state.query_cache
|
|
276
|
+
query_start_time = time.time()
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
# Create cache key for agents view
|
|
280
|
+
cache_key = "agents_view:all"
|
|
281
|
+
|
|
282
|
+
# Check cache first
|
|
283
|
+
cached_response = cache.get(cache_key)
|
|
284
|
+
if cached_response is not None:
|
|
285
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
286
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
287
|
+
logger.debug(
|
|
288
|
+
f"Cache HIT for agents_view (key={cache_key}, time={query_time_ms:.2f}ms)"
|
|
289
|
+
)
|
|
290
|
+
agents, total_actions, total_tokens = cached_response
|
|
291
|
+
else:
|
|
292
|
+
# Query agent statistics from 'agent_events' table joined with sessions
|
|
293
|
+
# Optimized with GROUP BY on indexed column
|
|
294
|
+
query = """
|
|
295
|
+
SELECT
|
|
296
|
+
e.agent_id,
|
|
297
|
+
COUNT(*) as event_count,
|
|
298
|
+
SUM(e.cost_tokens) as total_tokens,
|
|
299
|
+
COUNT(DISTINCT e.session_id) as session_count,
|
|
300
|
+
MAX(e.timestamp) as last_active,
|
|
301
|
+
MAX(e.model) as model,
|
|
302
|
+
CASE
|
|
303
|
+
WHEN MAX(e.timestamp) > datetime('now', '-5 minutes') THEN 'active'
|
|
304
|
+
ELSE 'idle'
|
|
305
|
+
END as status,
|
|
306
|
+
AVG(e.execution_duration_seconds) as avg_duration,
|
|
307
|
+
SUM(CASE WHEN e.event_type = 'error' THEN 1 ELSE 0 END) as error_count,
|
|
308
|
+
ROUND(
|
|
309
|
+
100.0 * COUNT(CASE WHEN e.status = 'completed' THEN 1 END) /
|
|
310
|
+
CAST(COUNT(*) AS FLOAT),
|
|
311
|
+
1
|
|
312
|
+
) as success_rate
|
|
313
|
+
FROM agent_events e
|
|
314
|
+
GROUP BY e.agent_id
|
|
315
|
+
ORDER BY event_count DESC
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
# Execute query with timing
|
|
319
|
+
exec_start = time.time()
|
|
320
|
+
async with db.execute(query) as cursor:
|
|
321
|
+
rows = await cursor.fetchall()
|
|
322
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
323
|
+
|
|
324
|
+
agents = []
|
|
325
|
+
total_actions = 0
|
|
326
|
+
total_tokens = 0
|
|
327
|
+
|
|
328
|
+
# First pass to calculate totals
|
|
329
|
+
for row in rows:
|
|
330
|
+
total_actions += row[1]
|
|
331
|
+
total_tokens += row[2] or 0
|
|
332
|
+
|
|
333
|
+
# Second pass to build agent objects with percentages
|
|
334
|
+
for row in rows:
|
|
335
|
+
event_count = row[1]
|
|
336
|
+
workload_pct = (
|
|
337
|
+
(event_count / total_actions * 100) if total_actions > 0 else 0
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
agents.append(
|
|
341
|
+
{
|
|
342
|
+
"id": row[0],
|
|
343
|
+
"agent_id": row[0],
|
|
344
|
+
"name": row[0],
|
|
345
|
+
"event_count": event_count,
|
|
346
|
+
"total_tokens": row[2] or 0,
|
|
347
|
+
"session_count": row[3],
|
|
348
|
+
"last_activity": row[4],
|
|
349
|
+
"last_active": row[4],
|
|
350
|
+
"model": row[5] or "unknown",
|
|
351
|
+
"status": row[6] or "idle",
|
|
352
|
+
"avg_duration": row[7],
|
|
353
|
+
"error_count": row[8] or 0,
|
|
354
|
+
"success_rate": row[9] or 0.0,
|
|
355
|
+
"workload_pct": round(workload_pct, 1),
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Cache the results
|
|
360
|
+
cache_data = (agents, total_actions, total_tokens)
|
|
361
|
+
cache.set(cache_key, cache_data)
|
|
362
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
363
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
364
|
+
logger.debug(
|
|
365
|
+
f"Cache MISS for agents_view (key={cache_key}, "
|
|
366
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
367
|
+
f"agents={len(agents)})"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return templates.TemplateResponse(
|
|
371
|
+
"partials/agents.html",
|
|
372
|
+
{
|
|
373
|
+
"request": request,
|
|
374
|
+
"agents": agents,
|
|
375
|
+
"total_agents": len(agents),
|
|
376
|
+
"total_actions": total_actions,
|
|
377
|
+
"total_tokens": total_tokens,
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
finally:
|
|
381
|
+
await db.close()
|
|
382
|
+
|
|
383
|
+
# ========== ACTIVITY FEED ENDPOINTS ==========
|
|
384
|
+
|
|
385
|
+
@app.get("/views/activity-feed", response_class=HTMLResponse)
|
|
386
|
+
async def activity_feed(
|
|
387
|
+
request: Request,
|
|
388
|
+
limit: int = 50,
|
|
389
|
+
session_id: str | None = None,
|
|
390
|
+
agent_id: str | None = None,
|
|
391
|
+
) -> HTMLResponse:
|
|
392
|
+
"""Get latest agent events grouped by conversation turn (user prompt).
|
|
393
|
+
|
|
394
|
+
Returns grouped activity feed showing conversation turns with their child events.
|
|
395
|
+
"""
|
|
396
|
+
db = await get_db()
|
|
397
|
+
cache = app.state.query_cache
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
# Call the helper function to get grouped events
|
|
401
|
+
grouped_result = await _get_events_grouped_by_prompt_impl(db, cache, limit)
|
|
402
|
+
|
|
403
|
+
return templates.TemplateResponse(
|
|
404
|
+
"partials/activity-feed.html",
|
|
405
|
+
{
|
|
406
|
+
"request": request,
|
|
407
|
+
"conversation_turns": grouped_result.get("conversation_turns", []),
|
|
408
|
+
"total_turns": grouped_result.get("total_turns", 0),
|
|
409
|
+
"limit": limit,
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
finally:
|
|
413
|
+
await db.close()
|
|
414
|
+
|
|
415
|
+
@app.get("/api/events", response_model=list[EventModel])
|
|
416
|
+
async def get_events(
|
|
417
|
+
limit: int = 50,
|
|
418
|
+
session_id: str | None = None,
|
|
419
|
+
agent_id: str | None = None,
|
|
420
|
+
offset: int = 0,
|
|
421
|
+
) -> list[EventModel]:
|
|
422
|
+
"""Get events as JSON API with parent-child hierarchical linking."""
|
|
423
|
+
db = await get_db()
|
|
424
|
+
cache = app.state.query_cache
|
|
425
|
+
query_start_time = time.time()
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
# Create cache key from query parameters
|
|
429
|
+
cache_key = (
|
|
430
|
+
f"api_events:{limit}:{offset}:{session_id or 'all'}:{agent_id or 'all'}"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Check cache first
|
|
434
|
+
cached_results = cache.get(cache_key)
|
|
435
|
+
if cached_results is not None:
|
|
436
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
437
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
438
|
+
logger.debug(
|
|
439
|
+
f"Cache HIT for api_events (key={cache_key}, time={query_time_ms:.2f}ms)"
|
|
440
|
+
)
|
|
441
|
+
return list(cached_results) if isinstance(cached_results, list) else []
|
|
442
|
+
else:
|
|
443
|
+
# Query from 'agent_events' table from Phase 1 PreToolUse hook implementation
|
|
444
|
+
# Optimized with column selection and proper indexing
|
|
445
|
+
query = """
|
|
446
|
+
SELECT e.event_id, e.agent_id, e.event_type, e.timestamp, e.tool_name,
|
|
447
|
+
e.input_summary, e.output_summary, e.session_id,
|
|
448
|
+
e.parent_event_id, e.status, e.model, e.feature_id
|
|
449
|
+
FROM agent_events e
|
|
450
|
+
WHERE 1=1
|
|
451
|
+
"""
|
|
452
|
+
params: list = []
|
|
453
|
+
|
|
454
|
+
if session_id:
|
|
455
|
+
query += " AND e.session_id = ?"
|
|
456
|
+
params.append(session_id)
|
|
457
|
+
|
|
458
|
+
if agent_id:
|
|
459
|
+
query += " AND e.agent_id = ?"
|
|
460
|
+
params.append(agent_id)
|
|
461
|
+
|
|
462
|
+
query += " ORDER BY e.timestamp DESC LIMIT ? OFFSET ?"
|
|
463
|
+
params.extend([limit, offset])
|
|
464
|
+
|
|
465
|
+
# Execute query with timing
|
|
466
|
+
exec_start = time.time()
|
|
467
|
+
async with db.execute(query, params) as cursor:
|
|
468
|
+
rows = await cursor.fetchall()
|
|
469
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
470
|
+
|
|
471
|
+
# Build result models
|
|
472
|
+
results = [
|
|
473
|
+
EventModel(
|
|
474
|
+
event_id=row[0],
|
|
475
|
+
agent_id=row[1] or "unknown",
|
|
476
|
+
event_type=row[2],
|
|
477
|
+
timestamp=row[3],
|
|
478
|
+
tool_name=row[4],
|
|
479
|
+
input_summary=row[5],
|
|
480
|
+
output_summary=row[6],
|
|
481
|
+
session_id=row[7],
|
|
482
|
+
parent_event_id=row[8],
|
|
483
|
+
status=row[9],
|
|
484
|
+
model=row[10],
|
|
485
|
+
feature_id=row[11],
|
|
486
|
+
)
|
|
487
|
+
for row in rows
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
# Cache the results
|
|
491
|
+
cache.set(cache_key, results)
|
|
492
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
493
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
494
|
+
logger.debug(
|
|
495
|
+
f"Cache MISS for api_events (key={cache_key}, "
|
|
496
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
497
|
+
f"rows={len(results)})"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return results
|
|
501
|
+
finally:
|
|
502
|
+
await db.close()
|
|
503
|
+
|
|
504
|
+
# ========== INITIAL STATS ENDPOINT ==========
|
|
505
|
+
|
|
506
|
+
@app.get("/api/initial-stats")
|
|
507
|
+
async def initial_stats() -> dict[str, Any]:
|
|
508
|
+
"""Get initial statistics for dashboard header (events, agents, sessions)."""
|
|
509
|
+
db = await get_db()
|
|
510
|
+
try:
|
|
511
|
+
# Query all stats in a single query for efficiency
|
|
512
|
+
stats_query = """
|
|
513
|
+
SELECT
|
|
514
|
+
(SELECT COUNT(*) FROM agent_events) as total_events,
|
|
515
|
+
(SELECT COUNT(DISTINCT agent_id) FROM agent_events) as total_agents,
|
|
516
|
+
(SELECT COUNT(*) FROM sessions) as total_sessions
|
|
517
|
+
"""
|
|
518
|
+
async with db.execute(stats_query) as cursor:
|
|
519
|
+
row = await cursor.fetchone()
|
|
520
|
+
|
|
521
|
+
# Query distinct agent IDs for the agent set
|
|
522
|
+
agents_query = (
|
|
523
|
+
"SELECT DISTINCT agent_id FROM agent_events WHERE agent_id IS NOT NULL"
|
|
524
|
+
)
|
|
525
|
+
async with db.execute(agents_query) as agents_cursor:
|
|
526
|
+
agents_rows = await agents_cursor.fetchall()
|
|
527
|
+
agents = [row[0] for row in agents_rows]
|
|
528
|
+
|
|
529
|
+
if row is None:
|
|
530
|
+
return {
|
|
531
|
+
"total_events": 0,
|
|
532
|
+
"total_agents": 0,
|
|
533
|
+
"total_sessions": 0,
|
|
534
|
+
"agents": agents,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
"total_events": int(row[0]) if row[0] else 0,
|
|
539
|
+
"total_agents": int(row[1]) if row[1] else 0,
|
|
540
|
+
"total_sessions": int(row[2]) if row[2] else 0,
|
|
541
|
+
"agents": agents,
|
|
542
|
+
}
|
|
543
|
+
finally:
|
|
544
|
+
await db.close()
|
|
545
|
+
|
|
546
|
+
# ========== PERFORMANCE METRICS ENDPOINT ==========
|
|
547
|
+
|
|
548
|
+
@app.get("/api/query-metrics")
|
|
549
|
+
async def get_query_metrics() -> dict[str, Any]:
|
|
550
|
+
"""Get query performance metrics and cache statistics."""
|
|
551
|
+
cache = app.state.query_cache
|
|
552
|
+
metrics = cache.get_metrics()
|
|
553
|
+
|
|
554
|
+
# Calculate aggregate statistics
|
|
555
|
+
total_queries = sum(m.get("count", 0) for m in metrics.values())
|
|
556
|
+
total_cache_hits = sum(m.get("hits", 0) for m in metrics.values())
|
|
557
|
+
hit_rate = (total_cache_hits / total_queries * 100) if total_queries > 0 else 0
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
"timestamp": datetime.now().isoformat(),
|
|
561
|
+
"cache_status": {
|
|
562
|
+
"ttl_seconds": cache.ttl_seconds,
|
|
563
|
+
"cached_queries": len(cache.cache),
|
|
564
|
+
"total_queries_tracked": total_queries,
|
|
565
|
+
"cache_hits": total_cache_hits,
|
|
566
|
+
"cache_hit_rate_percent": round(hit_rate, 2),
|
|
567
|
+
},
|
|
568
|
+
"query_metrics": metrics,
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
# ========== EVENT TRACES ENDPOINT (Parent-Child Nesting) ==========
|
|
572
|
+
|
|
573
|
+
@app.get("/api/event-traces")
|
|
574
|
+
async def get_event_traces(
|
|
575
|
+
limit: int = 50,
|
|
576
|
+
session_id: str | None = None,
|
|
577
|
+
) -> dict[str, Any]:
|
|
578
|
+
"""
|
|
579
|
+
Get event traces showing parent-child relationships for Task delegations.
|
|
580
|
+
|
|
581
|
+
This endpoint returns task delegation events with their child events,
|
|
582
|
+
showing the complete hierarchy of delegated work:
|
|
583
|
+
|
|
584
|
+
Example:
|
|
585
|
+
{
|
|
586
|
+
"traces": [
|
|
587
|
+
{
|
|
588
|
+
"parent_event_id": "evt-abc123",
|
|
589
|
+
"agent_id": "claude-code",
|
|
590
|
+
"subagent_type": "gemini-spawner",
|
|
591
|
+
"started_at": "2025-01-08T16:40:54",
|
|
592
|
+
"status": "completed",
|
|
593
|
+
"duration_seconds": 287,
|
|
594
|
+
"child_events": [
|
|
595
|
+
{
|
|
596
|
+
"event_id": "subevt-xyz789",
|
|
597
|
+
"agent_id": "subagent-gemini-spawner",
|
|
598
|
+
"event_type": "delegation",
|
|
599
|
+
"timestamp": "2025-01-08T16:42:01",
|
|
600
|
+
"status": "completed"
|
|
601
|
+
}
|
|
602
|
+
],
|
|
603
|
+
"child_spike_count": 2,
|
|
604
|
+
"child_spikes": ["spk-001", "spk-002"]
|
|
605
|
+
}
|
|
606
|
+
]
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
limit: Maximum number of parent events to return (default 50)
|
|
611
|
+
session_id: Filter by session (optional)
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
Dict with traces array showing parent-child relationships
|
|
615
|
+
"""
|
|
616
|
+
db = await get_db()
|
|
617
|
+
cache = app.state.query_cache
|
|
618
|
+
query_start_time = time.time()
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
# Create cache key
|
|
622
|
+
cache_key = f"event_traces:{limit}:{session_id or 'all'}"
|
|
623
|
+
|
|
624
|
+
# Check cache first
|
|
625
|
+
cached_result = cache.get(cache_key)
|
|
626
|
+
if cached_result is not None:
|
|
627
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
628
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
629
|
+
return cached_result # type: ignore[no-any-return]
|
|
630
|
+
|
|
631
|
+
exec_start = time.time()
|
|
632
|
+
|
|
633
|
+
# Query parent events (task delegations)
|
|
634
|
+
parent_query = """
|
|
635
|
+
SELECT event_id, agent_id, subagent_type, timestamp, status,
|
|
636
|
+
child_spike_count, output_summary, model
|
|
637
|
+
FROM agent_events
|
|
638
|
+
WHERE event_type = 'task_delegation'
|
|
639
|
+
"""
|
|
640
|
+
parent_params: list[Any] = []
|
|
641
|
+
|
|
642
|
+
if session_id:
|
|
643
|
+
parent_query += " AND session_id = ?"
|
|
644
|
+
parent_params.append(session_id)
|
|
645
|
+
|
|
646
|
+
parent_query += " ORDER BY timestamp DESC LIMIT ?"
|
|
647
|
+
parent_params.append(limit)
|
|
648
|
+
|
|
649
|
+
async with db.execute(parent_query, parent_params) as cursor:
|
|
650
|
+
parent_rows = await cursor.fetchall()
|
|
651
|
+
|
|
652
|
+
traces: list[dict[str, Any]] = []
|
|
653
|
+
|
|
654
|
+
for parent_row in parent_rows:
|
|
655
|
+
parent_event_id = parent_row[0]
|
|
656
|
+
agent_id = parent_row[1]
|
|
657
|
+
subagent_type = parent_row[2]
|
|
658
|
+
started_at = parent_row[3]
|
|
659
|
+
status = parent_row[4]
|
|
660
|
+
child_spike_count = parent_row[5] or 0
|
|
661
|
+
output_summary = parent_row[6]
|
|
662
|
+
model = parent_row[7]
|
|
663
|
+
|
|
664
|
+
# Parse output summary to get child spike IDs if available
|
|
665
|
+
child_spikes = []
|
|
666
|
+
try:
|
|
667
|
+
if output_summary:
|
|
668
|
+
output_data = (
|
|
669
|
+
json.loads(output_summary)
|
|
670
|
+
if isinstance(output_summary, str)
|
|
671
|
+
else output_summary
|
|
672
|
+
)
|
|
673
|
+
# Try to extract spike IDs if present
|
|
674
|
+
if isinstance(output_data, dict):
|
|
675
|
+
spikes_info = output_data.get("spikes_created", [])
|
|
676
|
+
if isinstance(spikes_info, list):
|
|
677
|
+
child_spikes = spikes_info
|
|
678
|
+
except Exception:
|
|
679
|
+
pass
|
|
680
|
+
|
|
681
|
+
# Query child events (subagent completion events)
|
|
682
|
+
child_query = """
|
|
683
|
+
SELECT event_id, agent_id, event_type, timestamp, status
|
|
684
|
+
FROM agent_events
|
|
685
|
+
WHERE parent_event_id = ?
|
|
686
|
+
ORDER BY timestamp ASC
|
|
687
|
+
"""
|
|
688
|
+
async with db.execute(child_query, (parent_event_id,)) as child_cursor:
|
|
689
|
+
child_rows = await child_cursor.fetchall()
|
|
690
|
+
|
|
691
|
+
child_events = []
|
|
692
|
+
for child_row in child_rows:
|
|
693
|
+
child_events.append(
|
|
694
|
+
{
|
|
695
|
+
"event_id": child_row[0],
|
|
696
|
+
"agent_id": child_row[1],
|
|
697
|
+
"event_type": child_row[2],
|
|
698
|
+
"timestamp": child_row[3],
|
|
699
|
+
"status": child_row[4],
|
|
700
|
+
}
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Calculate duration if completed
|
|
704
|
+
duration_seconds = None
|
|
705
|
+
if status == "completed" and started_at:
|
|
706
|
+
try:
|
|
707
|
+
from datetime import datetime as dt
|
|
708
|
+
|
|
709
|
+
start_dt = dt.fromisoformat(started_at)
|
|
710
|
+
now_dt = dt.now()
|
|
711
|
+
duration_seconds = (now_dt - start_dt).total_seconds()
|
|
712
|
+
except Exception:
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
trace = {
|
|
716
|
+
"parent_event_id": parent_event_id,
|
|
717
|
+
"agent_id": agent_id,
|
|
718
|
+
"subagent_type": subagent_type or "general-purpose",
|
|
719
|
+
"started_at": started_at,
|
|
720
|
+
"status": status,
|
|
721
|
+
"duration_seconds": duration_seconds,
|
|
722
|
+
"child_events": child_events,
|
|
723
|
+
"child_spike_count": child_spike_count,
|
|
724
|
+
"child_spikes": child_spikes,
|
|
725
|
+
"model": model,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
traces.append(trace)
|
|
729
|
+
|
|
730
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
731
|
+
|
|
732
|
+
# Build response
|
|
733
|
+
result = {
|
|
734
|
+
"timestamp": datetime.now().isoformat(),
|
|
735
|
+
"total_traces": len(traces),
|
|
736
|
+
"traces": traces,
|
|
737
|
+
"limitations": {
|
|
738
|
+
"note": "Child spike count is approximate and based on timestamp proximity",
|
|
739
|
+
"note_2": "Spike IDs in child_spikes only available if recorded in output_summary",
|
|
740
|
+
},
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
# Cache the result
|
|
744
|
+
cache.set(cache_key, result)
|
|
745
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
746
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
747
|
+
logger.debug(
|
|
748
|
+
f"Cache MISS for event_traces (key={cache_key}, "
|
|
749
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
750
|
+
f"traces={len(traces)})"
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
return result
|
|
754
|
+
|
|
755
|
+
finally:
|
|
756
|
+
await db.close()
|
|
757
|
+
|
|
758
|
+
# ========== COMPLETE ACTIVITY FEED ENDPOINT ==========
|
|
759
|
+
|
|
760
|
+
@app.get("/api/complete-activity-feed")
|
|
761
|
+
async def complete_activity_feed(
|
|
762
|
+
limit: int = 100,
|
|
763
|
+
session_id: str | None = None,
|
|
764
|
+
include_delegations: bool = True,
|
|
765
|
+
include_spikes: bool = True,
|
|
766
|
+
) -> dict[str, Any]:
|
|
767
|
+
"""
|
|
768
|
+
Get unified activity feed combining events from all sources.
|
|
769
|
+
|
|
770
|
+
This endpoint aggregates:
|
|
771
|
+
- Hook events (tool_call from PreToolUse)
|
|
772
|
+
- Subagent events (delegation completions from SubagentStop)
|
|
773
|
+
- SDK spike logs (knowledge created by delegated tasks)
|
|
774
|
+
|
|
775
|
+
This provides complete visibility into ALL activity, including
|
|
776
|
+
delegated work that would otherwise be invisible due to Claude Code's
|
|
777
|
+
hook isolation design (see GitHub issue #14859).
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
limit: Maximum number of events to return
|
|
781
|
+
session_id: Filter by session (optional)
|
|
782
|
+
include_delegations: Include delegation events (default True)
|
|
783
|
+
include_spikes: Include spike creation events (default True)
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
Dict with events array and metadata
|
|
787
|
+
"""
|
|
788
|
+
db = await get_db()
|
|
789
|
+
cache = app.state.query_cache
|
|
790
|
+
query_start_time = time.time()
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
# Create cache key
|
|
794
|
+
cache_key = f"complete_activity:{limit}:{session_id or 'all'}:{include_delegations}:{include_spikes}"
|
|
795
|
+
|
|
796
|
+
# Check cache first
|
|
797
|
+
cached_result = cache.get(cache_key)
|
|
798
|
+
if cached_result is not None:
|
|
799
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
800
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
801
|
+
return cached_result # type: ignore[no-any-return]
|
|
802
|
+
|
|
803
|
+
events: list[dict[str, Any]] = []
|
|
804
|
+
|
|
805
|
+
# 1. Query hook events (tool_call, delegation from agent_events)
|
|
806
|
+
event_types = ["tool_call"]
|
|
807
|
+
if include_delegations:
|
|
808
|
+
event_types.extend(["delegation", "completion"])
|
|
809
|
+
|
|
810
|
+
event_type_placeholders = ",".join("?" for _ in event_types)
|
|
811
|
+
query = f"""
|
|
812
|
+
SELECT
|
|
813
|
+
'hook_event' as source,
|
|
814
|
+
event_id,
|
|
815
|
+
agent_id,
|
|
816
|
+
event_type,
|
|
817
|
+
timestamp,
|
|
818
|
+
tool_name,
|
|
819
|
+
input_summary,
|
|
820
|
+
output_summary,
|
|
821
|
+
session_id,
|
|
822
|
+
status,
|
|
823
|
+
model,
|
|
824
|
+
parent_event_id,
|
|
825
|
+
feature_id
|
|
826
|
+
FROM agent_events
|
|
827
|
+
WHERE event_type IN ({event_type_placeholders})
|
|
828
|
+
"""
|
|
829
|
+
params: list[Any] = list(event_types)
|
|
830
|
+
|
|
831
|
+
if session_id:
|
|
832
|
+
query += " AND session_id = ?"
|
|
833
|
+
params.append(session_id)
|
|
834
|
+
|
|
835
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
836
|
+
params.append(limit)
|
|
837
|
+
|
|
838
|
+
exec_start = time.time()
|
|
839
|
+
async with db.execute(query, params) as cursor:
|
|
840
|
+
rows = await cursor.fetchall()
|
|
841
|
+
|
|
842
|
+
for row in rows:
|
|
843
|
+
events.append(
|
|
844
|
+
{
|
|
845
|
+
"source": row[0],
|
|
846
|
+
"event_id": row[1],
|
|
847
|
+
"agent_id": row[2] or "unknown",
|
|
848
|
+
"event_type": row[3],
|
|
849
|
+
"timestamp": row[4],
|
|
850
|
+
"tool_name": row[5],
|
|
851
|
+
"input_summary": row[6],
|
|
852
|
+
"output_summary": row[7],
|
|
853
|
+
"session_id": row[8],
|
|
854
|
+
"status": row[9],
|
|
855
|
+
"model": row[10],
|
|
856
|
+
"parent_event_id": row[11],
|
|
857
|
+
"feature_id": row[12],
|
|
858
|
+
}
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# 2. Query spike logs if requested (knowledge created by delegated tasks)
|
|
862
|
+
if include_spikes:
|
|
863
|
+
try:
|
|
864
|
+
spike_query = """
|
|
865
|
+
SELECT
|
|
866
|
+
'spike_log' as source,
|
|
867
|
+
id as event_id,
|
|
868
|
+
assigned_to as agent_id,
|
|
869
|
+
'knowledge_created' as event_type,
|
|
870
|
+
created_at as timestamp,
|
|
871
|
+
title as tool_name,
|
|
872
|
+
hypothesis as input_summary,
|
|
873
|
+
findings as output_summary,
|
|
874
|
+
NULL as session_id,
|
|
875
|
+
status
|
|
876
|
+
FROM features
|
|
877
|
+
WHERE type = 'spike'
|
|
878
|
+
"""
|
|
879
|
+
spike_params: list[Any] = []
|
|
880
|
+
|
|
881
|
+
spike_query += " ORDER BY created_at DESC LIMIT ?"
|
|
882
|
+
spike_params.append(limit)
|
|
883
|
+
|
|
884
|
+
async with db.execute(spike_query, spike_params) as spike_cursor:
|
|
885
|
+
spike_rows = await spike_cursor.fetchall()
|
|
886
|
+
|
|
887
|
+
for row in spike_rows:
|
|
888
|
+
events.append(
|
|
889
|
+
{
|
|
890
|
+
"source": row[0],
|
|
891
|
+
"event_id": row[1],
|
|
892
|
+
"agent_id": row[2] or "sdk",
|
|
893
|
+
"event_type": row[3],
|
|
894
|
+
"timestamp": row[4],
|
|
895
|
+
"tool_name": row[5],
|
|
896
|
+
"input_summary": row[6],
|
|
897
|
+
"output_summary": row[7],
|
|
898
|
+
"session_id": row[8],
|
|
899
|
+
"status": row[9] or "completed",
|
|
900
|
+
}
|
|
901
|
+
)
|
|
902
|
+
except Exception as e:
|
|
903
|
+
# Spike query might fail if columns don't exist
|
|
904
|
+
logger.debug(
|
|
905
|
+
f"Spike query failed (expected if schema differs): {e}"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
# 3. Query delegation handoffs from agent_collaboration
|
|
909
|
+
if include_delegations:
|
|
910
|
+
try:
|
|
911
|
+
collab_query = """
|
|
912
|
+
SELECT
|
|
913
|
+
'delegation' as source,
|
|
914
|
+
handoff_id as event_id,
|
|
915
|
+
from_agent || ' -> ' || to_agent as agent_id,
|
|
916
|
+
'handoff' as event_type,
|
|
917
|
+
timestamp,
|
|
918
|
+
handoff_type as tool_name,
|
|
919
|
+
reason as input_summary,
|
|
920
|
+
context as output_summary,
|
|
921
|
+
session_id,
|
|
922
|
+
status
|
|
923
|
+
FROM agent_collaboration
|
|
924
|
+
WHERE handoff_type = 'delegation'
|
|
925
|
+
"""
|
|
926
|
+
collab_params: list[Any] = []
|
|
927
|
+
|
|
928
|
+
if session_id:
|
|
929
|
+
collab_query += " AND session_id = ?"
|
|
930
|
+
collab_params.append(session_id)
|
|
931
|
+
|
|
932
|
+
collab_query += " ORDER BY timestamp DESC LIMIT ?"
|
|
933
|
+
collab_params.append(limit)
|
|
934
|
+
|
|
935
|
+
async with db.execute(collab_query, collab_params) as collab_cursor:
|
|
936
|
+
collab_rows = await collab_cursor.fetchall()
|
|
937
|
+
|
|
938
|
+
for row in collab_rows:
|
|
939
|
+
events.append(
|
|
940
|
+
{
|
|
941
|
+
"source": row[0],
|
|
942
|
+
"event_id": row[1],
|
|
943
|
+
"agent_id": row[2] or "orchestrator",
|
|
944
|
+
"event_type": row[3],
|
|
945
|
+
"timestamp": row[4],
|
|
946
|
+
"tool_name": row[5],
|
|
947
|
+
"input_summary": row[6],
|
|
948
|
+
"output_summary": row[7],
|
|
949
|
+
"session_id": row[8],
|
|
950
|
+
"status": row[9] or "pending",
|
|
951
|
+
}
|
|
952
|
+
)
|
|
953
|
+
except Exception as e:
|
|
954
|
+
logger.debug(f"Collaboration query failed: {e}")
|
|
955
|
+
|
|
956
|
+
# Sort all events by timestamp DESC
|
|
957
|
+
events.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
|
|
958
|
+
|
|
959
|
+
# Limit to requested count
|
|
960
|
+
events = events[:limit]
|
|
961
|
+
|
|
962
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
963
|
+
|
|
964
|
+
# Build response
|
|
965
|
+
result = {
|
|
966
|
+
"timestamp": datetime.now().isoformat(),
|
|
967
|
+
"total_events": len(events),
|
|
968
|
+
"sources": {
|
|
969
|
+
"hook_events": sum(
|
|
970
|
+
1 for e in events if e["source"] == "hook_event"
|
|
971
|
+
),
|
|
972
|
+
"spike_logs": sum(1 for e in events if e["source"] == "spike_log"),
|
|
973
|
+
"delegations": sum(
|
|
974
|
+
1 for e in events if e["source"] == "delegation"
|
|
975
|
+
),
|
|
976
|
+
},
|
|
977
|
+
"events": events,
|
|
978
|
+
"limitations": {
|
|
979
|
+
"note": "Subagent tool activity not tracked (Claude Code limitation)",
|
|
980
|
+
"github_issue": "https://github.com/anthropics/claude-code/issues/14859",
|
|
981
|
+
"workaround": "SubagentStop hook captures completion, SDK logging captures results",
|
|
982
|
+
},
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
# Cache the result
|
|
986
|
+
cache.set(cache_key, result)
|
|
987
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
988
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
989
|
+
|
|
990
|
+
return result
|
|
991
|
+
|
|
992
|
+
finally:
|
|
993
|
+
await db.close()
|
|
994
|
+
|
|
995
|
+
# ========== HELPER: Grouped Events Logic ==========
|
|
996
|
+
|
|
997
|
+
async def _get_events_grouped_by_prompt_impl(
|
|
998
|
+
db: aiosqlite.Connection, cache: QueryCache, limit: int = 50
|
|
999
|
+
) -> dict[str, Any]:
|
|
1000
|
+
"""
|
|
1001
|
+
Implementation helper: Return activity events grouped by user prompt (conversation turns).
|
|
1002
|
+
|
|
1003
|
+
Each conversation turn includes:
|
|
1004
|
+
- userQuery: The original UserQuery event with prompt text
|
|
1005
|
+
- children: All child events triggered by this prompt
|
|
1006
|
+
- stats: Aggregated statistics for the conversation turn
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
db: Database connection
|
|
1010
|
+
cache: Query cache instance
|
|
1011
|
+
limit: Maximum number of conversation turns to return (default 50)
|
|
1012
|
+
|
|
1013
|
+
Returns:
|
|
1014
|
+
Dictionary with conversation turns and metadata
|
|
1015
|
+
"""
|
|
1016
|
+
query_start_time = time.time()
|
|
1017
|
+
|
|
1018
|
+
try:
|
|
1019
|
+
# Create cache key
|
|
1020
|
+
cache_key = f"events_grouped_by_prompt:{limit}"
|
|
1021
|
+
|
|
1022
|
+
# Check cache first
|
|
1023
|
+
cached_result = cache.get(cache_key)
|
|
1024
|
+
if cached_result is not None:
|
|
1025
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1026
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
1027
|
+
logger.debug(
|
|
1028
|
+
f"Cache HIT for events_grouped_by_prompt (key={cache_key}, time={query_time_ms:.2f}ms)"
|
|
1029
|
+
)
|
|
1030
|
+
return cached_result # type: ignore[no-any-return]
|
|
1031
|
+
|
|
1032
|
+
exec_start = time.time()
|
|
1033
|
+
|
|
1034
|
+
# Step 1: Query UserQuery events (most recent first)
|
|
1035
|
+
user_query_query = """
|
|
1036
|
+
SELECT
|
|
1037
|
+
event_id,
|
|
1038
|
+
timestamp,
|
|
1039
|
+
input_summary,
|
|
1040
|
+
execution_duration_seconds,
|
|
1041
|
+
status,
|
|
1042
|
+
agent_id
|
|
1043
|
+
FROM agent_events
|
|
1044
|
+
WHERE tool_name = 'UserQuery'
|
|
1045
|
+
ORDER BY timestamp DESC
|
|
1046
|
+
LIMIT ?
|
|
1047
|
+
"""
|
|
1048
|
+
|
|
1049
|
+
async with db.execute(user_query_query, [limit]) as cursor:
|
|
1050
|
+
user_query_rows = await cursor.fetchall()
|
|
1051
|
+
|
|
1052
|
+
conversation_turns: list[dict[str, Any]] = []
|
|
1053
|
+
|
|
1054
|
+
# Step 2: For each UserQuery, fetch child events
|
|
1055
|
+
for uq_row in user_query_rows:
|
|
1056
|
+
uq_event_id = uq_row[0]
|
|
1057
|
+
uq_timestamp = uq_row[1]
|
|
1058
|
+
uq_input = uq_row[2] or ""
|
|
1059
|
+
uq_duration = uq_row[3] or 0.0
|
|
1060
|
+
uq_status = uq_row[4]
|
|
1061
|
+
|
|
1062
|
+
# Extract prompt text from input_summary
|
|
1063
|
+
# Since format_tool_summary now properly formats UserQuery events,
|
|
1064
|
+
# input_summary contains just the prompt text (preview up to 100 chars)
|
|
1065
|
+
prompt_text = uq_input
|
|
1066
|
+
|
|
1067
|
+
# Step 2a: Query child events linked via parent_event_id
|
|
1068
|
+
children_query = """
|
|
1069
|
+
SELECT
|
|
1070
|
+
event_id,
|
|
1071
|
+
tool_name,
|
|
1072
|
+
timestamp,
|
|
1073
|
+
input_summary,
|
|
1074
|
+
execution_duration_seconds,
|
|
1075
|
+
status,
|
|
1076
|
+
agent_id,
|
|
1077
|
+
model,
|
|
1078
|
+
context,
|
|
1079
|
+
subagent_type,
|
|
1080
|
+
feature_id
|
|
1081
|
+
FROM agent_events
|
|
1082
|
+
WHERE parent_event_id = ?
|
|
1083
|
+
ORDER BY timestamp ASC
|
|
1084
|
+
"""
|
|
1085
|
+
|
|
1086
|
+
# Recursive helper to fetch children at any depth
|
|
1087
|
+
async def fetch_children_recursive(
|
|
1088
|
+
parent_id: str, depth: int = 0, max_depth: int = 4
|
|
1089
|
+
) -> tuple[list[dict[str, Any]], float, int, int]:
|
|
1090
|
+
"""Recursively fetch children up to max_depth levels."""
|
|
1091
|
+
if depth >= max_depth:
|
|
1092
|
+
return [], 0.0, 0, 0
|
|
1093
|
+
|
|
1094
|
+
async with db.execute(children_query, [parent_id]) as cursor:
|
|
1095
|
+
rows = await cursor.fetchall()
|
|
1096
|
+
|
|
1097
|
+
children_list: list[dict[str, Any]] = []
|
|
1098
|
+
total_dur = 0.0
|
|
1099
|
+
success_cnt = 0
|
|
1100
|
+
error_cnt = 0
|
|
1101
|
+
|
|
1102
|
+
for row in rows:
|
|
1103
|
+
evt_id = row[0]
|
|
1104
|
+
tool = row[1]
|
|
1105
|
+
timestamp = row[2]
|
|
1106
|
+
input_text = row[3] or ""
|
|
1107
|
+
duration = row[4] or 0.0
|
|
1108
|
+
status = row[5]
|
|
1109
|
+
agent = row[6] or "unknown"
|
|
1110
|
+
model = row[7]
|
|
1111
|
+
context_json = row[8]
|
|
1112
|
+
subagent_type = row[9]
|
|
1113
|
+
feature_id = row[10]
|
|
1114
|
+
|
|
1115
|
+
# Parse context to extract spawner metadata
|
|
1116
|
+
context = {}
|
|
1117
|
+
spawner_type = None
|
|
1118
|
+
spawned_agent = None
|
|
1119
|
+
if context_json:
|
|
1120
|
+
try:
|
|
1121
|
+
context = json.loads(context_json)
|
|
1122
|
+
spawner_type = context.get("spawner_type")
|
|
1123
|
+
spawned_agent = context.get("spawned_agent")
|
|
1124
|
+
except (json.JSONDecodeError, TypeError):
|
|
1125
|
+
pass
|
|
1126
|
+
|
|
1127
|
+
# If no spawner_type but subagent_type is set, treat it as a spawner delegation
|
|
1128
|
+
# This handles both HeadlessSpawner (spawner_type in context) and
|
|
1129
|
+
# Claude Code plugin agents (subagent_type field)
|
|
1130
|
+
if not spawner_type and subagent_type:
|
|
1131
|
+
# Extract spawner name from subagent_type (e.g., ".claude-plugin:gemini" -> "gemini")
|
|
1132
|
+
if ":" in subagent_type:
|
|
1133
|
+
spawner_type = subagent_type.split(":")[-1]
|
|
1134
|
+
else:
|
|
1135
|
+
spawner_type = subagent_type
|
|
1136
|
+
spawned_agent = (
|
|
1137
|
+
agent # Use the agent_id as the spawned agent
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
# Build summary (input_text already contains formatted summary)
|
|
1141
|
+
summary = input_text[:80] + (
|
|
1142
|
+
"..." if len(input_text) > 80 else ""
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
# Recursively fetch this child's children
|
|
1146
|
+
(
|
|
1147
|
+
nested_children,
|
|
1148
|
+
nested_dur,
|
|
1149
|
+
nested_success,
|
|
1150
|
+
nested_error,
|
|
1151
|
+
) = await fetch_children_recursive(evt_id, depth + 1, max_depth)
|
|
1152
|
+
|
|
1153
|
+
child_dict: dict[str, Any] = {
|
|
1154
|
+
"event_id": evt_id,
|
|
1155
|
+
"tool_name": tool,
|
|
1156
|
+
"timestamp": timestamp,
|
|
1157
|
+
"summary": summary,
|
|
1158
|
+
"duration_seconds": round(duration, 2),
|
|
1159
|
+
"agent": agent,
|
|
1160
|
+
"depth": depth,
|
|
1161
|
+
"model": model,
|
|
1162
|
+
"feature_id": feature_id,
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
# Include spawner metadata if present
|
|
1166
|
+
if spawner_type:
|
|
1167
|
+
child_dict["spawner_type"] = spawner_type
|
|
1168
|
+
if spawned_agent:
|
|
1169
|
+
child_dict["spawned_agent"] = spawned_agent
|
|
1170
|
+
if subagent_type:
|
|
1171
|
+
child_dict["subagent_type"] = subagent_type
|
|
1172
|
+
|
|
1173
|
+
# Only add children key if there are nested children
|
|
1174
|
+
if nested_children:
|
|
1175
|
+
child_dict["children"] = nested_children
|
|
1176
|
+
|
|
1177
|
+
children_list.append(child_dict)
|
|
1178
|
+
|
|
1179
|
+
# Update stats (include nested)
|
|
1180
|
+
total_dur += duration + nested_dur
|
|
1181
|
+
if status == "recorded" or status == "success":
|
|
1182
|
+
success_cnt += 1
|
|
1183
|
+
else:
|
|
1184
|
+
error_cnt += 1
|
|
1185
|
+
success_cnt += nested_success
|
|
1186
|
+
error_cnt += nested_error
|
|
1187
|
+
|
|
1188
|
+
return children_list, total_dur, success_cnt, error_cnt
|
|
1189
|
+
|
|
1190
|
+
# Step 3: Build child events with recursive nesting
|
|
1191
|
+
(
|
|
1192
|
+
children,
|
|
1193
|
+
children_duration,
|
|
1194
|
+
children_success,
|
|
1195
|
+
children_error,
|
|
1196
|
+
) = await fetch_children_recursive(uq_event_id, depth=0, max_depth=4)
|
|
1197
|
+
|
|
1198
|
+
total_duration = uq_duration + children_duration
|
|
1199
|
+
success_count = (
|
|
1200
|
+
1 if uq_status == "recorded" or uq_status == "success" else 0
|
|
1201
|
+
) + children_success
|
|
1202
|
+
error_count = (
|
|
1203
|
+
0 if uq_status == "recorded" or uq_status == "success" else 1
|
|
1204
|
+
) + children_error
|
|
1205
|
+
|
|
1206
|
+
# Check if any child has spawner metadata
|
|
1207
|
+
def has_spawner_in_children(
|
|
1208
|
+
children_list: list[dict[str, Any]],
|
|
1209
|
+
) -> bool:
|
|
1210
|
+
"""Recursively check if any child has spawner metadata."""
|
|
1211
|
+
for child in children_list:
|
|
1212
|
+
if child.get("spawner_type") or child.get("spawned_agent"):
|
|
1213
|
+
return True
|
|
1214
|
+
if child.get("children") and has_spawner_in_children(
|
|
1215
|
+
child["children"]
|
|
1216
|
+
):
|
|
1217
|
+
return True
|
|
1218
|
+
return False
|
|
1219
|
+
|
|
1220
|
+
has_spawner = has_spawner_in_children(children)
|
|
1221
|
+
|
|
1222
|
+
# Step 4: Build conversation turn object
|
|
1223
|
+
conversation_turn = {
|
|
1224
|
+
"userQuery": {
|
|
1225
|
+
"event_id": uq_event_id,
|
|
1226
|
+
"timestamp": uq_timestamp,
|
|
1227
|
+
"prompt": prompt_text[:200], # Truncate for display
|
|
1228
|
+
"duration_seconds": round(uq_duration, 2),
|
|
1229
|
+
"agent_id": uq_row[5], # Include agent_id from UserQuery
|
|
1230
|
+
},
|
|
1231
|
+
"children": children,
|
|
1232
|
+
"has_spawner": has_spawner,
|
|
1233
|
+
"stats": {
|
|
1234
|
+
"tool_count": len(children),
|
|
1235
|
+
"total_duration": round(total_duration, 2),
|
|
1236
|
+
"success_count": success_count,
|
|
1237
|
+
"error_count": error_count,
|
|
1238
|
+
},
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
conversation_turns.append(conversation_turn)
|
|
1242
|
+
|
|
1243
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
1244
|
+
|
|
1245
|
+
# Build response
|
|
1246
|
+
result = {
|
|
1247
|
+
"timestamp": datetime.now().isoformat(),
|
|
1248
|
+
"total_turns": len(conversation_turns),
|
|
1249
|
+
"conversation_turns": conversation_turns,
|
|
1250
|
+
"note": "Groups events by UserQuery prompt (conversation turn). Child events are linked via parent_event_id.",
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
# Cache the result
|
|
1254
|
+
cache.set(cache_key, result)
|
|
1255
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1256
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
1257
|
+
logger.debug(
|
|
1258
|
+
f"Cache MISS for events_grouped_by_prompt (key={cache_key}, "
|
|
1259
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
1260
|
+
f"turns={len(conversation_turns)})"
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
return result
|
|
1264
|
+
|
|
1265
|
+
except Exception as e:
|
|
1266
|
+
logger.error(f"Error in _get_events_grouped_by_prompt_impl: {e}")
|
|
1267
|
+
raise
|
|
1268
|
+
|
|
1269
|
+
# ========== EVENTS GROUPED BY PROMPT ENDPOINT ==========
|
|
1270
|
+
|
|
1271
|
+
@app.get("/api/events-grouped-by-prompt")
|
|
1272
|
+
async def events_grouped_by_prompt(limit: int = 50) -> dict[str, Any]:
|
|
1273
|
+
"""
|
|
1274
|
+
Return activity events grouped by user prompt (conversation turns).
|
|
1275
|
+
|
|
1276
|
+
Each conversation turn includes:
|
|
1277
|
+
- userQuery: The original UserQuery event with prompt text
|
|
1278
|
+
- children: All child events triggered by this prompt
|
|
1279
|
+
- stats: Aggregated statistics for the conversation turn
|
|
1280
|
+
|
|
1281
|
+
Args:
|
|
1282
|
+
limit: Maximum number of conversation turns to return (default 50)
|
|
1283
|
+
|
|
1284
|
+
Returns:
|
|
1285
|
+
Dictionary with conversation_turns list and metadata
|
|
1286
|
+
"""
|
|
1287
|
+
db = await get_db()
|
|
1288
|
+
cache = app.state.query_cache
|
|
1289
|
+
|
|
1290
|
+
try:
|
|
1291
|
+
return await _get_events_grouped_by_prompt_impl(db, cache, limit)
|
|
1292
|
+
finally:
|
|
1293
|
+
await db.close()
|
|
1294
|
+
|
|
1295
|
+
# ========== SESSIONS API ENDPOINT ==========
|
|
1296
|
+
|
|
1297
|
+
@app.get("/api/sessions")
|
|
1298
|
+
async def get_sessions(
|
|
1299
|
+
status: str | None = None,
|
|
1300
|
+
limit: int = 50,
|
|
1301
|
+
offset: int = 0,
|
|
1302
|
+
) -> dict[str, Any]:
|
|
1303
|
+
"""Get sessions from the database.
|
|
1304
|
+
|
|
1305
|
+
Args:
|
|
1306
|
+
status: Filter by session status (e.g., 'active', 'completed')
|
|
1307
|
+
limit: Maximum number of sessions to return (default 50)
|
|
1308
|
+
offset: Number of sessions to skip (default 0)
|
|
1309
|
+
|
|
1310
|
+
Returns:
|
|
1311
|
+
{
|
|
1312
|
+
"total": int,
|
|
1313
|
+
"limit": int,
|
|
1314
|
+
"offset": int,
|
|
1315
|
+
"sessions": [
|
|
1316
|
+
{
|
|
1317
|
+
"session_id": str,
|
|
1318
|
+
"agent": str | None,
|
|
1319
|
+
"continued_from": str | None,
|
|
1320
|
+
"started_at": str,
|
|
1321
|
+
"status": str,
|
|
1322
|
+
"start_commit": str | None,
|
|
1323
|
+
"ended_at": str | None
|
|
1324
|
+
}
|
|
1325
|
+
]
|
|
1326
|
+
}
|
|
1327
|
+
"""
|
|
1328
|
+
db = await get_db()
|
|
1329
|
+
cache = app.state.query_cache
|
|
1330
|
+
query_start_time = time.time()
|
|
1331
|
+
|
|
1332
|
+
try:
|
|
1333
|
+
# Create cache key from query parameters
|
|
1334
|
+
cache_key = f"api_sessions:{status or 'all'}:{limit}:{offset}"
|
|
1335
|
+
|
|
1336
|
+
# Check cache first
|
|
1337
|
+
cached_result = cache.get(cache_key)
|
|
1338
|
+
if cached_result is not None:
|
|
1339
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1340
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
1341
|
+
logger.debug(
|
|
1342
|
+
f"Cache HIT for api_sessions (key={cache_key}, time={query_time_ms:.2f}ms)"
|
|
1343
|
+
)
|
|
1344
|
+
return cached_result # type: ignore[no-any-return]
|
|
1345
|
+
|
|
1346
|
+
exec_start = time.time()
|
|
1347
|
+
|
|
1348
|
+
# Build query with optional status filter
|
|
1349
|
+
# Note: Database uses agent_assigned, created_at, and completed_at
|
|
1350
|
+
query = """
|
|
1351
|
+
SELECT
|
|
1352
|
+
session_id,
|
|
1353
|
+
agent_assigned,
|
|
1354
|
+
continued_from,
|
|
1355
|
+
created_at,
|
|
1356
|
+
status,
|
|
1357
|
+
start_commit,
|
|
1358
|
+
completed_at
|
|
1359
|
+
FROM sessions
|
|
1360
|
+
WHERE 1=1
|
|
1361
|
+
"""
|
|
1362
|
+
params: list[Any] = []
|
|
1363
|
+
|
|
1364
|
+
if status:
|
|
1365
|
+
query += " AND status = ?"
|
|
1366
|
+
params.append(status)
|
|
1367
|
+
|
|
1368
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
1369
|
+
params.extend([limit, offset])
|
|
1370
|
+
|
|
1371
|
+
async with db.execute(query, params) as cursor:
|
|
1372
|
+
rows = await cursor.fetchall()
|
|
1373
|
+
|
|
1374
|
+
# Get total count for pagination
|
|
1375
|
+
count_query = "SELECT COUNT(*) FROM sessions WHERE 1=1"
|
|
1376
|
+
count_params: list[Any] = []
|
|
1377
|
+
if status:
|
|
1378
|
+
count_query += " AND status = ?"
|
|
1379
|
+
count_params.append(status)
|
|
1380
|
+
|
|
1381
|
+
async with db.execute(count_query, count_params) as count_cursor:
|
|
1382
|
+
count_row = await count_cursor.fetchone()
|
|
1383
|
+
total = int(count_row[0]) if count_row else 0
|
|
1384
|
+
|
|
1385
|
+
# Build session objects
|
|
1386
|
+
# Map schema columns to API response fields for backward compatibility
|
|
1387
|
+
sessions = []
|
|
1388
|
+
for row in rows:
|
|
1389
|
+
sessions.append(
|
|
1390
|
+
{
|
|
1391
|
+
"session_id": row[0],
|
|
1392
|
+
"agent": row[1], # agent_assigned -> agent for API compat
|
|
1393
|
+
"continued_from": row[2],
|
|
1394
|
+
"created_at": row[3], # created_at timestamp
|
|
1395
|
+
"status": row[4] or "unknown",
|
|
1396
|
+
"start_commit": row[5],
|
|
1397
|
+
"completed_at": row[6], # completed_at timestamp
|
|
1398
|
+
}
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
1402
|
+
|
|
1403
|
+
result = {
|
|
1404
|
+
"total": total,
|
|
1405
|
+
"limit": limit,
|
|
1406
|
+
"offset": offset,
|
|
1407
|
+
"sessions": sessions,
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
# Cache the result
|
|
1411
|
+
cache.set(cache_key, result)
|
|
1412
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1413
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
1414
|
+
logger.debug(
|
|
1415
|
+
f"Cache MISS for api_sessions (key={cache_key}, "
|
|
1416
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
1417
|
+
f"sessions={len(sessions)})"
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
return result
|
|
1421
|
+
|
|
1422
|
+
finally:
|
|
1423
|
+
await db.close()
|
|
1424
|
+
|
|
1425
|
+
# ========== ORCHESTRATION ENDPOINTS ==========
|
|
1426
|
+
|
|
1427
|
+
@app.get("/views/orchestration", response_class=HTMLResponse)
|
|
1428
|
+
async def orchestration_view(request: Request) -> HTMLResponse:
|
|
1429
|
+
"""Get delegation chains and agent handoffs as HTMX partial."""
|
|
1430
|
+
db = await get_db()
|
|
1431
|
+
try:
|
|
1432
|
+
# Query delegation events from agent_events table
|
|
1433
|
+
# Use same query as API endpoint - filter by tool_name = 'Task'
|
|
1434
|
+
query = """
|
|
1435
|
+
SELECT
|
|
1436
|
+
event_id,
|
|
1437
|
+
agent_id as from_agent,
|
|
1438
|
+
subagent_type as to_agent,
|
|
1439
|
+
timestamp,
|
|
1440
|
+
input_summary,
|
|
1441
|
+
session_id,
|
|
1442
|
+
status
|
|
1443
|
+
FROM agent_events
|
|
1444
|
+
WHERE tool_name = 'Task'
|
|
1445
|
+
ORDER BY timestamp DESC
|
|
1446
|
+
LIMIT 50
|
|
1447
|
+
"""
|
|
1448
|
+
|
|
1449
|
+
async with db.execute(query) as cursor:
|
|
1450
|
+
rows = list(await cursor.fetchall())
|
|
1451
|
+
logger.debug(f"orchestration_view: Query executed, got {len(rows)} rows")
|
|
1452
|
+
|
|
1453
|
+
delegations = []
|
|
1454
|
+
for row in rows:
|
|
1455
|
+
from_agent = row[1] or "unknown"
|
|
1456
|
+
to_agent = row[2] # May be NULL
|
|
1457
|
+
task_summary = row[4] or ""
|
|
1458
|
+
|
|
1459
|
+
# Extract to_agent from input_summary JSON if NULL
|
|
1460
|
+
if not to_agent:
|
|
1461
|
+
try:
|
|
1462
|
+
input_data = json.loads(task_summary) if task_summary else {}
|
|
1463
|
+
to_agent = input_data.get("subagent_type", "unknown")
|
|
1464
|
+
except Exception:
|
|
1465
|
+
to_agent = "unknown"
|
|
1466
|
+
|
|
1467
|
+
delegation = {
|
|
1468
|
+
"event_id": row[0],
|
|
1469
|
+
"from_agent": from_agent,
|
|
1470
|
+
"to_agent": to_agent,
|
|
1471
|
+
"timestamp": row[3],
|
|
1472
|
+
"task": task_summary or "Unnamed task",
|
|
1473
|
+
"session_id": row[5],
|
|
1474
|
+
"status": row[6] or "pending",
|
|
1475
|
+
"result": "", # Not available in agent_events
|
|
1476
|
+
}
|
|
1477
|
+
delegations.append(delegation)
|
|
1478
|
+
|
|
1479
|
+
logger.debug(
|
|
1480
|
+
f"orchestration_view: Created {len(delegations)} delegation dicts"
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
return templates.TemplateResponse(
|
|
1484
|
+
"partials/orchestration.html",
|
|
1485
|
+
{
|
|
1486
|
+
"request": request,
|
|
1487
|
+
"delegations": delegations,
|
|
1488
|
+
},
|
|
1489
|
+
)
|
|
1490
|
+
except Exception as e:
|
|
1491
|
+
logger.error(f"orchestration_view ERROR: {e}")
|
|
1492
|
+
raise
|
|
1493
|
+
finally:
|
|
1494
|
+
await db.close()
|
|
1495
|
+
|
|
1496
|
+
@app.get("/api/orchestration")
|
|
1497
|
+
async def orchestration_api() -> dict[str, Any]:
|
|
1498
|
+
"""Get delegation chains and agent coordination information as JSON.
|
|
1499
|
+
|
|
1500
|
+
Returns:
|
|
1501
|
+
{
|
|
1502
|
+
"delegation_count": int,
|
|
1503
|
+
"unique_agents": int,
|
|
1504
|
+
"agents": [str],
|
|
1505
|
+
"delegation_chains": {
|
|
1506
|
+
"from_agent": [
|
|
1507
|
+
{
|
|
1508
|
+
"to_agent": str,
|
|
1509
|
+
"event_type": str,
|
|
1510
|
+
"timestamp": str,
|
|
1511
|
+
"task": str,
|
|
1512
|
+
"status": str
|
|
1513
|
+
}
|
|
1514
|
+
]
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
"""
|
|
1518
|
+
db = await get_db()
|
|
1519
|
+
try:
|
|
1520
|
+
# Query delegation events from agent_events table
|
|
1521
|
+
# Filter by tool_name = 'Task' (not event_type)
|
|
1522
|
+
query = """
|
|
1523
|
+
SELECT
|
|
1524
|
+
event_id,
|
|
1525
|
+
agent_id as from_agent,
|
|
1526
|
+
subagent_type as to_agent,
|
|
1527
|
+
timestamp,
|
|
1528
|
+
input_summary,
|
|
1529
|
+
status
|
|
1530
|
+
FROM agent_events
|
|
1531
|
+
WHERE tool_name = 'Task'
|
|
1532
|
+
ORDER BY timestamp DESC
|
|
1533
|
+
LIMIT 1000
|
|
1534
|
+
"""
|
|
1535
|
+
|
|
1536
|
+
cursor = await db.execute(query)
|
|
1537
|
+
rows = await cursor.fetchall()
|
|
1538
|
+
|
|
1539
|
+
# Build delegation chains grouped by from_agent
|
|
1540
|
+
delegation_chains: dict[str, list[dict[str, Any]]] = {}
|
|
1541
|
+
agents = set()
|
|
1542
|
+
delegation_count = 0
|
|
1543
|
+
|
|
1544
|
+
for row in rows:
|
|
1545
|
+
from_agent = row[1] or "unknown"
|
|
1546
|
+
to_agent = row[2] # May be NULL
|
|
1547
|
+
timestamp = row[3] or ""
|
|
1548
|
+
task_summary = row[4] or ""
|
|
1549
|
+
status = row[5] or "pending"
|
|
1550
|
+
|
|
1551
|
+
# Extract to_agent from input_summary JSON if NULL
|
|
1552
|
+
if not to_agent:
|
|
1553
|
+
try:
|
|
1554
|
+
import json
|
|
1555
|
+
|
|
1556
|
+
input_data = json.loads(task_summary) if task_summary else {}
|
|
1557
|
+
to_agent = input_data.get("subagent_type", "unknown")
|
|
1558
|
+
except Exception:
|
|
1559
|
+
to_agent = "unknown"
|
|
1560
|
+
|
|
1561
|
+
agents.add(from_agent)
|
|
1562
|
+
agents.add(to_agent)
|
|
1563
|
+
delegation_count += 1
|
|
1564
|
+
|
|
1565
|
+
if from_agent not in delegation_chains:
|
|
1566
|
+
delegation_chains[from_agent] = []
|
|
1567
|
+
|
|
1568
|
+
delegation_chains[from_agent].append(
|
|
1569
|
+
{
|
|
1570
|
+
"to_agent": to_agent,
|
|
1571
|
+
"event_type": "delegation",
|
|
1572
|
+
"timestamp": timestamp,
|
|
1573
|
+
"task": task_summary or "Unnamed task",
|
|
1574
|
+
"status": status,
|
|
1575
|
+
}
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
"delegation_count": delegation_count,
|
|
1580
|
+
"unique_agents": len(agents),
|
|
1581
|
+
"agents": sorted(list(agents)),
|
|
1582
|
+
"delegation_chains": delegation_chains,
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
except Exception as e:
|
|
1586
|
+
logger.error(f"Failed to get orchestration data: {e}")
|
|
1587
|
+
raise
|
|
1588
|
+
finally:
|
|
1589
|
+
await db.close()
|
|
1590
|
+
|
|
1591
|
+
@app.get("/api/orchestration/delegations")
|
|
1592
|
+
async def orchestration_delegations_api() -> dict[str, Any]:
|
|
1593
|
+
"""Get delegation statistics and chains as JSON.
|
|
1594
|
+
|
|
1595
|
+
This endpoint is used by the dashboard JavaScript to display
|
|
1596
|
+
delegation metrics in the orchestration panel.
|
|
1597
|
+
|
|
1598
|
+
Returns:
|
|
1599
|
+
{
|
|
1600
|
+
"delegation_count": int,
|
|
1601
|
+
"unique_agents": int,
|
|
1602
|
+
"delegation_chains": {
|
|
1603
|
+
"from_agent": [
|
|
1604
|
+
{
|
|
1605
|
+
"to_agent": str,
|
|
1606
|
+
"timestamp": str,
|
|
1607
|
+
"task": str,
|
|
1608
|
+
"status": str
|
|
1609
|
+
}
|
|
1610
|
+
]
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
"""
|
|
1614
|
+
db = await get_db()
|
|
1615
|
+
cache = app.state.query_cache
|
|
1616
|
+
query_start_time = time.time()
|
|
1617
|
+
|
|
1618
|
+
try:
|
|
1619
|
+
# Create cache key
|
|
1620
|
+
cache_key = "orchestration_delegations:all"
|
|
1621
|
+
|
|
1622
|
+
# Check cache first
|
|
1623
|
+
cached_result = cache.get(cache_key)
|
|
1624
|
+
if cached_result is not None:
|
|
1625
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1626
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
1627
|
+
logger.debug(
|
|
1628
|
+
f"Cache HIT for orchestration_delegations (key={cache_key}, "
|
|
1629
|
+
f"time={query_time_ms:.2f}ms)"
|
|
1630
|
+
)
|
|
1631
|
+
return cached_result # type: ignore[no-any-return]
|
|
1632
|
+
|
|
1633
|
+
exec_start = time.time()
|
|
1634
|
+
|
|
1635
|
+
# Query delegation events from agent_events table
|
|
1636
|
+
# Filter by tool_name = 'Task' to get Task() delegations
|
|
1637
|
+
query = """
|
|
1638
|
+
SELECT
|
|
1639
|
+
event_id,
|
|
1640
|
+
agent_id as from_agent,
|
|
1641
|
+
subagent_type as to_agent,
|
|
1642
|
+
timestamp,
|
|
1643
|
+
input_summary,
|
|
1644
|
+
status
|
|
1645
|
+
FROM agent_events
|
|
1646
|
+
WHERE tool_name = 'Task'
|
|
1647
|
+
ORDER BY timestamp DESC
|
|
1648
|
+
LIMIT 1000
|
|
1649
|
+
"""
|
|
1650
|
+
|
|
1651
|
+
cursor = await db.execute(query)
|
|
1652
|
+
rows = await cursor.fetchall()
|
|
1653
|
+
|
|
1654
|
+
# Build delegation chains grouped by from_agent
|
|
1655
|
+
delegation_chains: dict[str, list[dict[str, Any]]] = {}
|
|
1656
|
+
agents = set()
|
|
1657
|
+
delegation_count = 0
|
|
1658
|
+
|
|
1659
|
+
for row in rows:
|
|
1660
|
+
from_agent = row[1] or "unknown"
|
|
1661
|
+
to_agent = row[2] # May be NULL
|
|
1662
|
+
timestamp = row[3] or ""
|
|
1663
|
+
task_summary = row[4] or ""
|
|
1664
|
+
status = row[5] or "pending"
|
|
1665
|
+
|
|
1666
|
+
# Extract to_agent from input_summary JSON if NULL
|
|
1667
|
+
if not to_agent:
|
|
1668
|
+
try:
|
|
1669
|
+
input_data = json.loads(task_summary) if task_summary else {}
|
|
1670
|
+
to_agent = input_data.get("subagent_type", "unknown")
|
|
1671
|
+
except Exception:
|
|
1672
|
+
to_agent = "unknown"
|
|
1673
|
+
|
|
1674
|
+
agents.add(from_agent)
|
|
1675
|
+
agents.add(to_agent)
|
|
1676
|
+
delegation_count += 1
|
|
1677
|
+
|
|
1678
|
+
if from_agent not in delegation_chains:
|
|
1679
|
+
delegation_chains[from_agent] = []
|
|
1680
|
+
|
|
1681
|
+
delegation_chains[from_agent].append(
|
|
1682
|
+
{
|
|
1683
|
+
"to_agent": to_agent,
|
|
1684
|
+
"timestamp": timestamp,
|
|
1685
|
+
"task": task_summary or "Unnamed task",
|
|
1686
|
+
"status": status,
|
|
1687
|
+
}
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
1691
|
+
|
|
1692
|
+
result = {
|
|
1693
|
+
"delegation_count": delegation_count,
|
|
1694
|
+
"unique_agents": len(agents),
|
|
1695
|
+
"delegation_chains": delegation_chains,
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
# Cache the result
|
|
1699
|
+
cache.set(cache_key, result)
|
|
1700
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1701
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
1702
|
+
logger.debug(
|
|
1703
|
+
f"Cache MISS for orchestration_delegations (key={cache_key}, "
|
|
1704
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
1705
|
+
f"delegations={delegation_count})"
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
return result
|
|
1709
|
+
|
|
1710
|
+
except Exception as e:
|
|
1711
|
+
logger.error(f"Failed to get orchestration delegations: {e}")
|
|
1712
|
+
raise
|
|
1713
|
+
finally:
|
|
1714
|
+
await db.close()
|
|
1715
|
+
|
|
1716
|
+
# ========== WORK ITEMS ENDPOINTS ==========
|
|
1717
|
+
|
|
1718
|
+
@app.get("/views/features", response_class=HTMLResponse)
|
|
1719
|
+
async def features_view_redirect(
|
|
1720
|
+
request: Request, status: str = "all"
|
|
1721
|
+
) -> HTMLResponse:
|
|
1722
|
+
"""Redirect to work-items view (legacy endpoint for backward compatibility)."""
|
|
1723
|
+
return await work_items_view(request, status)
|
|
1724
|
+
|
|
1725
|
+
@app.get("/views/work-items", response_class=HTMLResponse)
|
|
1726
|
+
async def work_items_view(request: Request, status: str = "all") -> HTMLResponse:
|
|
1727
|
+
"""Get work items (features, bugs, spikes) by status as HTMX partial."""
|
|
1728
|
+
db = await get_db()
|
|
1729
|
+
cache = app.state.query_cache
|
|
1730
|
+
query_start_time = time.time()
|
|
1731
|
+
|
|
1732
|
+
try:
|
|
1733
|
+
# Create cache key from query parameters
|
|
1734
|
+
cache_key = f"work_items_view:{status}"
|
|
1735
|
+
|
|
1736
|
+
# Check cache first
|
|
1737
|
+
cached_response = cache.get(cache_key)
|
|
1738
|
+
work_items_by_status: dict = {
|
|
1739
|
+
"todo": [],
|
|
1740
|
+
"in_progress": [],
|
|
1741
|
+
"blocked": [],
|
|
1742
|
+
"done": [],
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if cached_response is not None:
|
|
1746
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1747
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
1748
|
+
logger.debug(
|
|
1749
|
+
f"Cache HIT for work_items_view (key={cache_key}, time={query_time_ms:.2f}ms)"
|
|
1750
|
+
)
|
|
1751
|
+
work_items_by_status = cached_response
|
|
1752
|
+
else:
|
|
1753
|
+
# OPTIMIZATION: Use composite index idx_features_status_priority
|
|
1754
|
+
# for efficient filtering and ordering
|
|
1755
|
+
query = """
|
|
1756
|
+
SELECT id, type, title, status, priority, assigned_to, created_at, updated_at, description
|
|
1757
|
+
FROM features
|
|
1758
|
+
WHERE 1=1
|
|
1759
|
+
"""
|
|
1760
|
+
params: list = []
|
|
1761
|
+
|
|
1762
|
+
if status != "all":
|
|
1763
|
+
query += " AND status = ?"
|
|
1764
|
+
params.append(status)
|
|
1765
|
+
|
|
1766
|
+
query += " ORDER BY priority DESC, created_at DESC LIMIT 1000"
|
|
1767
|
+
|
|
1768
|
+
exec_start = time.time()
|
|
1769
|
+
cursor = await db.execute(query, params)
|
|
1770
|
+
rows = await cursor.fetchall()
|
|
1771
|
+
|
|
1772
|
+
# Query all unique agents per feature for attribution chain
|
|
1773
|
+
# This only works for events that have feature_id populated
|
|
1774
|
+
agents_query = """
|
|
1775
|
+
SELECT feature_id, agent_id
|
|
1776
|
+
FROM agent_events
|
|
1777
|
+
WHERE feature_id IS NOT NULL
|
|
1778
|
+
GROUP BY feature_id, agent_id
|
|
1779
|
+
"""
|
|
1780
|
+
agents_cursor = await db.execute(agents_query)
|
|
1781
|
+
agents_rows = await agents_cursor.fetchall()
|
|
1782
|
+
|
|
1783
|
+
feature_agents: dict[str, list[str]] = {}
|
|
1784
|
+
for row in agents_rows:
|
|
1785
|
+
fid, aid = row[0], row[1]
|
|
1786
|
+
if fid not in feature_agents:
|
|
1787
|
+
feature_agents[fid] = []
|
|
1788
|
+
feature_agents[fid].append(aid)
|
|
1789
|
+
|
|
1790
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
1791
|
+
|
|
1792
|
+
for row in rows:
|
|
1793
|
+
item_id = row[0]
|
|
1794
|
+
item_status = row[3]
|
|
1795
|
+
work_items_by_status.setdefault(item_status, []).append(
|
|
1796
|
+
{
|
|
1797
|
+
"id": item_id,
|
|
1798
|
+
"type": row[1],
|
|
1799
|
+
"title": row[2],
|
|
1800
|
+
"status": item_status,
|
|
1801
|
+
"priority": row[4],
|
|
1802
|
+
"assigned_to": row[5],
|
|
1803
|
+
"created_at": row[6],
|
|
1804
|
+
"updated_at": row[7],
|
|
1805
|
+
"description": row[8],
|
|
1806
|
+
"contributors": feature_agents.get(item_id, []),
|
|
1807
|
+
}
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
# Cache the results
|
|
1811
|
+
cache.set(cache_key, work_items_by_status)
|
|
1812
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1813
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
1814
|
+
logger.debug(
|
|
1815
|
+
f"Cache MISS for work_items_view (key={cache_key}, "
|
|
1816
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms)"
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
return templates.TemplateResponse(
|
|
1820
|
+
"partials/work-items.html",
|
|
1821
|
+
{
|
|
1822
|
+
"request": request,
|
|
1823
|
+
"work_items_by_status": work_items_by_status,
|
|
1824
|
+
},
|
|
1825
|
+
)
|
|
1826
|
+
finally:
|
|
1827
|
+
await db.close()
|
|
1828
|
+
|
|
1829
|
+
# ========== SPAWNERS ENDPOINTS ==========
|
|
1830
|
+
|
|
1831
|
+
@app.get("/views/spawners", response_class=HTMLResponse)
|
|
1832
|
+
async def spawners_view(request: Request) -> HTMLResponse:
|
|
1833
|
+
"""Get spawner activity dashboard as HTMX partial."""
|
|
1834
|
+
db = await get_db()
|
|
1835
|
+
try:
|
|
1836
|
+
# Get spawner statistics
|
|
1837
|
+
stats_response = await get_spawner_statistics()
|
|
1838
|
+
spawner_stats = stats_response.get("spawner_statistics", [])
|
|
1839
|
+
|
|
1840
|
+
# Get recent spawner activities
|
|
1841
|
+
activities_response = await get_spawner_activities(limit=50)
|
|
1842
|
+
recent_activities = activities_response.get("spawner_activities", [])
|
|
1843
|
+
|
|
1844
|
+
return templates.TemplateResponse(
|
|
1845
|
+
"partials/spawners.html",
|
|
1846
|
+
{
|
|
1847
|
+
"request": request,
|
|
1848
|
+
"spawner_stats": spawner_stats,
|
|
1849
|
+
"recent_activities": recent_activities,
|
|
1850
|
+
},
|
|
1851
|
+
)
|
|
1852
|
+
except Exception as e:
|
|
1853
|
+
logger.error(f"spawners_view ERROR: {e}")
|
|
1854
|
+
return templates.TemplateResponse(
|
|
1855
|
+
"partials/spawners.html",
|
|
1856
|
+
{
|
|
1857
|
+
"request": request,
|
|
1858
|
+
"spawner_stats": [],
|
|
1859
|
+
"recent_activities": [],
|
|
1860
|
+
},
|
|
1861
|
+
)
|
|
1862
|
+
finally:
|
|
1863
|
+
await db.close()
|
|
1864
|
+
|
|
1865
|
+
# ========== METRICS ENDPOINTS ==========
|
|
1866
|
+
|
|
1867
|
+
@app.get("/views/metrics", response_class=HTMLResponse)
|
|
1868
|
+
async def metrics_view(request: Request) -> HTMLResponse:
|
|
1869
|
+
"""Get session metrics and performance data as HTMX partial."""
|
|
1870
|
+
db = await get_db()
|
|
1871
|
+
cache = app.state.query_cache
|
|
1872
|
+
query_start_time = time.time()
|
|
1873
|
+
|
|
1874
|
+
try:
|
|
1875
|
+
# Create cache key for metrics view
|
|
1876
|
+
cache_key = "metrics_view:all"
|
|
1877
|
+
|
|
1878
|
+
# Check cache first
|
|
1879
|
+
cached_response = cache.get(cache_key)
|
|
1880
|
+
if cached_response is not None:
|
|
1881
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1882
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
1883
|
+
logger.debug(
|
|
1884
|
+
f"Cache HIT for metrics_view (key={cache_key}, time={query_time_ms:.2f}ms)"
|
|
1885
|
+
)
|
|
1886
|
+
sessions, stats = cached_response
|
|
1887
|
+
else:
|
|
1888
|
+
# OPTIMIZATION: Combine session data with event counts in single query
|
|
1889
|
+
# This eliminates N+1 query problem (was 20+ queries, now 2)
|
|
1890
|
+
# Note: Database uses created_at and completed_at (not started_at/ended_at)
|
|
1891
|
+
query = """
|
|
1892
|
+
SELECT
|
|
1893
|
+
s.session_id,
|
|
1894
|
+
s.agent_assigned,
|
|
1895
|
+
s.status,
|
|
1896
|
+
s.created_at,
|
|
1897
|
+
s.completed_at,
|
|
1898
|
+
COUNT(DISTINCT e.event_id) as event_count
|
|
1899
|
+
FROM sessions s
|
|
1900
|
+
LEFT JOIN agent_events e ON s.session_id = e.session_id
|
|
1901
|
+
GROUP BY s.session_id
|
|
1902
|
+
ORDER BY s.created_at DESC
|
|
1903
|
+
LIMIT 20
|
|
1904
|
+
"""
|
|
1905
|
+
|
|
1906
|
+
exec_start = time.time()
|
|
1907
|
+
cursor = await db.execute(query)
|
|
1908
|
+
rows = await cursor.fetchall()
|
|
1909
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
1910
|
+
|
|
1911
|
+
sessions = []
|
|
1912
|
+
for row in rows:
|
|
1913
|
+
started_at = datetime.fromisoformat(row[3])
|
|
1914
|
+
|
|
1915
|
+
# Calculate duration
|
|
1916
|
+
if row[4]:
|
|
1917
|
+
ended_at = datetime.fromisoformat(row[4])
|
|
1918
|
+
duration_seconds = (ended_at - started_at).total_seconds()
|
|
1919
|
+
else:
|
|
1920
|
+
# Use UTC to handle timezone-aware datetime comparison
|
|
1921
|
+
now = (
|
|
1922
|
+
datetime.now(started_at.tzinfo)
|
|
1923
|
+
if started_at.tzinfo
|
|
1924
|
+
else datetime.now()
|
|
1925
|
+
)
|
|
1926
|
+
duration_seconds = (now - started_at).total_seconds()
|
|
1927
|
+
|
|
1928
|
+
sessions.append(
|
|
1929
|
+
{
|
|
1930
|
+
"session_id": row[0],
|
|
1931
|
+
"agent": row[1],
|
|
1932
|
+
"status": row[2],
|
|
1933
|
+
"started_at": row[3],
|
|
1934
|
+
"ended_at": row[4],
|
|
1935
|
+
"event_count": int(row[5]) if row[5] else 0,
|
|
1936
|
+
"duration_seconds": duration_seconds,
|
|
1937
|
+
}
|
|
1938
|
+
)
|
|
1939
|
+
|
|
1940
|
+
# OPTIMIZATION: Combine all stats in single query instead of subqueries
|
|
1941
|
+
# This reduces query count from 4 subqueries + 1 main to just 1
|
|
1942
|
+
stats_query = """
|
|
1943
|
+
SELECT
|
|
1944
|
+
(SELECT COUNT(*) FROM agent_events) as total_events,
|
|
1945
|
+
(SELECT COUNT(DISTINCT agent_id) FROM agent_events) as total_agents,
|
|
1946
|
+
(SELECT COUNT(*) FROM sessions) as total_sessions,
|
|
1947
|
+
(SELECT COUNT(*) FROM features) as total_features
|
|
1948
|
+
"""
|
|
1949
|
+
|
|
1950
|
+
stats_cursor = await db.execute(stats_query)
|
|
1951
|
+
stats_row = await stats_cursor.fetchone()
|
|
1952
|
+
|
|
1953
|
+
if stats_row:
|
|
1954
|
+
stats = {
|
|
1955
|
+
"total_events": int(stats_row[0]) if stats_row[0] else 0,
|
|
1956
|
+
"total_agents": int(stats_row[1]) if stats_row[1] else 0,
|
|
1957
|
+
"total_sessions": int(stats_row[2]) if stats_row[2] else 0,
|
|
1958
|
+
"total_features": int(stats_row[3]) if stats_row[3] else 0,
|
|
1959
|
+
}
|
|
1960
|
+
else:
|
|
1961
|
+
stats = {
|
|
1962
|
+
"total_events": 0,
|
|
1963
|
+
"total_agents": 0,
|
|
1964
|
+
"total_sessions": 0,
|
|
1965
|
+
"total_features": 0,
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
# Cache the results
|
|
1969
|
+
cache_data = (sessions, stats)
|
|
1970
|
+
cache.set(cache_key, cache_data)
|
|
1971
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
1972
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
1973
|
+
logger.debug(
|
|
1974
|
+
f"Cache MISS for metrics_view (key={cache_key}, "
|
|
1975
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms)"
|
|
1976
|
+
)
|
|
1977
|
+
|
|
1978
|
+
# Provide default values for metrics template variables
|
|
1979
|
+
# These prevent Jinja2 UndefinedError for variables the template expects
|
|
1980
|
+
exec_time_dist = {
|
|
1981
|
+
"very_fast": 0,
|
|
1982
|
+
"fast": 0,
|
|
1983
|
+
"medium": 0,
|
|
1984
|
+
"slow": 0,
|
|
1985
|
+
"very_slow": 0,
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
# Count active sessions from the fetched sessions
|
|
1989
|
+
active_sessions = sum(1 for s in sessions if s.get("status") == "active")
|
|
1990
|
+
|
|
1991
|
+
# Default token stats (empty until we compute real values)
|
|
1992
|
+
token_stats = {
|
|
1993
|
+
"total_tokens": 0,
|
|
1994
|
+
"avg_per_event": 0,
|
|
1995
|
+
"peak_usage": 0,
|
|
1996
|
+
"estimated_cost": 0.0,
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
# Default activity timeline (last 24 hours with 0 counts)
|
|
2000
|
+
activity_timeline = {str(h): 0 for h in range(24)}
|
|
2001
|
+
max_hourly_count = 1 # Avoid division by zero in template
|
|
2002
|
+
|
|
2003
|
+
# Default agent performance (empty list)
|
|
2004
|
+
agent_performance: list[dict[str, str | float]] = []
|
|
2005
|
+
|
|
2006
|
+
# Default system health metrics
|
|
2007
|
+
error_rate = 0.0
|
|
2008
|
+
avg_response_time = 0.5 # seconds
|
|
2009
|
+
|
|
2010
|
+
return templates.TemplateResponse(
|
|
2011
|
+
"partials/metrics.html",
|
|
2012
|
+
{
|
|
2013
|
+
"request": request,
|
|
2014
|
+
"sessions": sessions,
|
|
2015
|
+
"stats": stats,
|
|
2016
|
+
"exec_time_dist": exec_time_dist,
|
|
2017
|
+
"active_sessions": active_sessions,
|
|
2018
|
+
"token_stats": token_stats,
|
|
2019
|
+
"activity_timeline": activity_timeline,
|
|
2020
|
+
"max_hourly_count": max_hourly_count,
|
|
2021
|
+
"agent_performance": agent_performance,
|
|
2022
|
+
"error_rate": error_rate,
|
|
2023
|
+
"avg_response_time": avg_response_time,
|
|
2024
|
+
},
|
|
2025
|
+
)
|
|
2026
|
+
finally:
|
|
2027
|
+
await db.close()
|
|
2028
|
+
|
|
2029
|
+
# ========== SPAWNER OBSERVABILITY ENDPOINTS ==========
|
|
2030
|
+
|
|
2031
|
+
@app.get("/api/spawner-activities")
|
|
2032
|
+
async def get_spawner_activities(
|
|
2033
|
+
spawner_type: str | None = None,
|
|
2034
|
+
session_id: str | None = None,
|
|
2035
|
+
limit: int = 100,
|
|
2036
|
+
offset: int = 0,
|
|
2037
|
+
) -> dict[str, Any]:
|
|
2038
|
+
"""
|
|
2039
|
+
Get spawner delegation activities with clear attribution.
|
|
2040
|
+
|
|
2041
|
+
Returns events where spawner_type IS NOT NULL, ordered by recency.
|
|
2042
|
+
Shows which orchestrator delegated to which spawned AI.
|
|
2043
|
+
|
|
2044
|
+
Args:
|
|
2045
|
+
spawner_type: Filter by spawner type (gemini, codex, copilot)
|
|
2046
|
+
session_id: Filter by session
|
|
2047
|
+
limit: Maximum results (default 100)
|
|
2048
|
+
offset: Result offset for pagination
|
|
2049
|
+
|
|
2050
|
+
Returns:
|
|
2051
|
+
Dict with spawner_activities array and metadata
|
|
2052
|
+
"""
|
|
2053
|
+
db = await get_db()
|
|
2054
|
+
cache = app.state.query_cache
|
|
2055
|
+
query_start_time = time.time()
|
|
2056
|
+
|
|
2057
|
+
try:
|
|
2058
|
+
# Create cache key
|
|
2059
|
+
cache_key = f"spawner_activities:{spawner_type or 'all'}:{session_id or 'all'}:{limit}:{offset}"
|
|
2060
|
+
|
|
2061
|
+
# Check cache first
|
|
2062
|
+
cached_result = cache.get(cache_key)
|
|
2063
|
+
if cached_result is not None:
|
|
2064
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
2065
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
2066
|
+
return cached_result # type: ignore[no-any-return]
|
|
2067
|
+
|
|
2068
|
+
exec_start = time.time()
|
|
2069
|
+
|
|
2070
|
+
query = """
|
|
2071
|
+
SELECT
|
|
2072
|
+
event_id,
|
|
2073
|
+
agent_id AS orchestrator_agent,
|
|
2074
|
+
spawner_type,
|
|
2075
|
+
subagent_type AS spawned_agent,
|
|
2076
|
+
tool_name,
|
|
2077
|
+
input_summary AS task,
|
|
2078
|
+
output_summary AS result,
|
|
2079
|
+
status,
|
|
2080
|
+
execution_duration_seconds AS duration,
|
|
2081
|
+
cost_tokens AS tokens,
|
|
2082
|
+
cost_usd,
|
|
2083
|
+
child_spike_count AS artifacts,
|
|
2084
|
+
timestamp,
|
|
2085
|
+
created_at
|
|
2086
|
+
FROM agent_events
|
|
2087
|
+
WHERE spawner_type IS NOT NULL
|
|
2088
|
+
"""
|
|
2089
|
+
|
|
2090
|
+
params: list[Any] = []
|
|
2091
|
+
if spawner_type:
|
|
2092
|
+
query += " AND spawner_type = ?"
|
|
2093
|
+
params.append(spawner_type)
|
|
2094
|
+
if session_id:
|
|
2095
|
+
query += " AND session_id = ?"
|
|
2096
|
+
params.append(session_id)
|
|
2097
|
+
|
|
2098
|
+
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
|
2099
|
+
params.extend([limit, offset])
|
|
2100
|
+
|
|
2101
|
+
cursor = await db.execute(query, params)
|
|
2102
|
+
events = [
|
|
2103
|
+
dict(zip([c[0] for c in cursor.description], row))
|
|
2104
|
+
for row in await cursor.fetchall()
|
|
2105
|
+
]
|
|
2106
|
+
|
|
2107
|
+
# Get total count
|
|
2108
|
+
count_query = (
|
|
2109
|
+
"SELECT COUNT(*) FROM agent_events WHERE spawner_type IS NOT NULL"
|
|
2110
|
+
)
|
|
2111
|
+
count_params: list[Any] = []
|
|
2112
|
+
if spawner_type:
|
|
2113
|
+
count_query += " AND spawner_type = ?"
|
|
2114
|
+
count_params.append(spawner_type)
|
|
2115
|
+
if session_id:
|
|
2116
|
+
count_query += " AND session_id = ?"
|
|
2117
|
+
count_params.append(session_id)
|
|
2118
|
+
|
|
2119
|
+
count_cursor = await db.execute(count_query, count_params)
|
|
2120
|
+
count_row = await count_cursor.fetchone()
|
|
2121
|
+
total_count = int(count_row[0]) if count_row else 0
|
|
2122
|
+
|
|
2123
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
2124
|
+
|
|
2125
|
+
result = {
|
|
2126
|
+
"spawner_activities": events,
|
|
2127
|
+
"count": len(events),
|
|
2128
|
+
"total": total_count,
|
|
2129
|
+
"offset": offset,
|
|
2130
|
+
"limit": limit,
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
# Cache the result
|
|
2134
|
+
cache.set(cache_key, result)
|
|
2135
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
2136
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
2137
|
+
logger.debug(
|
|
2138
|
+
f"Cache MISS for spawner_activities (key={cache_key}, "
|
|
2139
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms, "
|
|
2140
|
+
f"activities={len(events)})"
|
|
2141
|
+
)
|
|
2142
|
+
|
|
2143
|
+
return result
|
|
2144
|
+
finally:
|
|
2145
|
+
await db.close()
|
|
2146
|
+
|
|
2147
|
+
@app.get("/api/spawner-statistics")
|
|
2148
|
+
async def get_spawner_statistics(session_id: str | None = None) -> dict[str, Any]:
|
|
2149
|
+
"""
|
|
2150
|
+
Get aggregated statistics for each spawner type.
|
|
2151
|
+
|
|
2152
|
+
Shows delegations, success rate, average duration, token usage, and costs
|
|
2153
|
+
broken down by spawner type (Gemini, Codex, Copilot).
|
|
2154
|
+
|
|
2155
|
+
Args:
|
|
2156
|
+
session_id: Filter by session (optional)
|
|
2157
|
+
|
|
2158
|
+
Returns:
|
|
2159
|
+
Dict with spawner_statistics array
|
|
2160
|
+
"""
|
|
2161
|
+
db = await get_db()
|
|
2162
|
+
cache = app.state.query_cache
|
|
2163
|
+
query_start_time = time.time()
|
|
2164
|
+
|
|
2165
|
+
try:
|
|
2166
|
+
# Create cache key
|
|
2167
|
+
cache_key = f"spawner_statistics:{session_id or 'all'}"
|
|
2168
|
+
|
|
2169
|
+
# Check cache first
|
|
2170
|
+
cached_result = cache.get(cache_key)
|
|
2171
|
+
if cached_result is not None:
|
|
2172
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
2173
|
+
cache.record_metric(cache_key, query_time_ms, cache_hit=True)
|
|
2174
|
+
return cached_result # type: ignore[no-any-return]
|
|
2175
|
+
|
|
2176
|
+
exec_start = time.time()
|
|
2177
|
+
|
|
2178
|
+
query = """
|
|
2179
|
+
SELECT
|
|
2180
|
+
spawner_type,
|
|
2181
|
+
COUNT(*) as total_delegations,
|
|
2182
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful,
|
|
2183
|
+
ROUND(100.0 * SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) / COUNT(*), 1) as success_rate,
|
|
2184
|
+
ROUND(AVG(execution_duration_seconds), 2) as avg_duration,
|
|
2185
|
+
SUM(cost_tokens) as total_tokens,
|
|
2186
|
+
ROUND(SUM(cost_usd), 2) as total_cost,
|
|
2187
|
+
MIN(timestamp) as first_used,
|
|
2188
|
+
MAX(timestamp) as last_used
|
|
2189
|
+
FROM agent_events
|
|
2190
|
+
WHERE spawner_type IS NOT NULL
|
|
2191
|
+
"""
|
|
2192
|
+
|
|
2193
|
+
params: list[Any] = []
|
|
2194
|
+
if session_id:
|
|
2195
|
+
query += " AND session_id = ?"
|
|
2196
|
+
params.append(session_id)
|
|
2197
|
+
|
|
2198
|
+
query += " GROUP BY spawner_type ORDER BY total_delegations DESC"
|
|
2199
|
+
|
|
2200
|
+
cursor = await db.execute(query, params)
|
|
2201
|
+
stats = [
|
|
2202
|
+
dict(zip([c[0] for c in cursor.description], row))
|
|
2203
|
+
for row in await cursor.fetchall()
|
|
2204
|
+
]
|
|
2205
|
+
|
|
2206
|
+
exec_time_ms = (time.time() - exec_start) * 1000
|
|
2207
|
+
|
|
2208
|
+
result = {"spawner_statistics": stats}
|
|
2209
|
+
|
|
2210
|
+
# Cache the result
|
|
2211
|
+
cache.set(cache_key, result)
|
|
2212
|
+
query_time_ms = (time.time() - query_start_time) * 1000
|
|
2213
|
+
cache.record_metric(cache_key, exec_time_ms, cache_hit=False)
|
|
2214
|
+
logger.debug(
|
|
2215
|
+
f"Cache MISS for spawner_statistics (key={cache_key}, "
|
|
2216
|
+
f"db_time={exec_time_ms:.2f}ms, total_time={query_time_ms:.2f}ms)"
|
|
2217
|
+
)
|
|
2218
|
+
|
|
2219
|
+
return result
|
|
2220
|
+
finally:
|
|
2221
|
+
await db.close()
|
|
2222
|
+
|
|
2223
|
+
# ========== WEBSOCKET FOR REAL-TIME UPDATES ==========
|
|
2224
|
+
|
|
2225
|
+
@app.websocket("/ws/events")
|
|
2226
|
+
async def websocket_events(websocket: WebSocket, since: str | None = None) -> None:
|
|
2227
|
+
"""WebSocket endpoint for real-time event streaming.
|
|
2228
|
+
|
|
2229
|
+
OPTIMIZATION: Uses timestamp-based filtering to minimize data transfers.
|
|
2230
|
+
The timestamp > ? filter with DESC index makes queries O(log n) instead of O(n).
|
|
2231
|
+
|
|
2232
|
+
FIX 3: Now supports loading historical events via 'since' parameter.
|
|
2233
|
+
- If 'since' provided: Load events from that timestamp onwards
|
|
2234
|
+
- If 'since' not provided: Load events from last 1 hour (default)
|
|
2235
|
+
- After historical load: Continue streaming real-time events
|
|
2236
|
+
|
|
2237
|
+
LIVE EVENTS: Also polls live_events table for real-time spawner activity
|
|
2238
|
+
streaming. These events are marked as broadcast after sending and cleaned up.
|
|
2239
|
+
|
|
2240
|
+
Args:
|
|
2241
|
+
since: Optional ISO timestamp to start streaming from (e.g., "2025-01-16T10:00:00")
|
|
2242
|
+
If not provided, defaults to 1 hour ago
|
|
2243
|
+
"""
|
|
2244
|
+
await websocket.accept()
|
|
2245
|
+
|
|
2246
|
+
# FIX 3: Determine starting timestamp
|
|
2247
|
+
if since:
|
|
2248
|
+
try:
|
|
2249
|
+
# Validate timestamp format
|
|
2250
|
+
datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
2251
|
+
last_timestamp = since
|
|
2252
|
+
except (ValueError, AttributeError):
|
|
2253
|
+
# Invalid timestamp - default to 24 hours ago
|
|
2254
|
+
last_timestamp = (datetime.now() - timedelta(hours=24)).isoformat()
|
|
2255
|
+
else:
|
|
2256
|
+
# Default: Load events from last 24 hours (captures all recent events in typical workflow)
|
|
2257
|
+
last_timestamp = (datetime.now() - timedelta(hours=24)).isoformat()
|
|
2258
|
+
|
|
2259
|
+
# FIX 3: Load historical events first (before real-time streaming)
|
|
2260
|
+
db = await get_db()
|
|
2261
|
+
try:
|
|
2262
|
+
historical_query = """
|
|
2263
|
+
SELECT event_id, agent_id, event_type, timestamp, tool_name,
|
|
2264
|
+
input_summary, output_summary, session_id, status, model,
|
|
2265
|
+
parent_event_id, execution_duration_seconds, context,
|
|
2266
|
+
cost_tokens
|
|
2267
|
+
FROM agent_events
|
|
2268
|
+
WHERE timestamp >= ? AND timestamp < datetime('now')
|
|
2269
|
+
ORDER BY timestamp ASC
|
|
2270
|
+
LIMIT 1000
|
|
2271
|
+
"""
|
|
2272
|
+
cursor = await db.execute(historical_query, [last_timestamp])
|
|
2273
|
+
historical_rows = await cursor.fetchall()
|
|
2274
|
+
|
|
2275
|
+
# Send historical events first
|
|
2276
|
+
if historical_rows:
|
|
2277
|
+
historical_rows_list = list(historical_rows)
|
|
2278
|
+
for row in historical_rows_list:
|
|
2279
|
+
row_list = list(row)
|
|
2280
|
+
# Parse context JSON if present
|
|
2281
|
+
context_data = {}
|
|
2282
|
+
if row_list[12]: # context column
|
|
2283
|
+
try:
|
|
2284
|
+
context_data = json.loads(row_list[12])
|
|
2285
|
+
except (json.JSONDecodeError, TypeError):
|
|
2286
|
+
pass
|
|
2287
|
+
|
|
2288
|
+
event_data = {
|
|
2289
|
+
"type": "event",
|
|
2290
|
+
"event_id": row_list[0],
|
|
2291
|
+
"agent_id": row_list[1] or "unknown",
|
|
2292
|
+
"event_type": row_list[2],
|
|
2293
|
+
"timestamp": row_list[3],
|
|
2294
|
+
"tool_name": row_list[4],
|
|
2295
|
+
"input_summary": row_list[5],
|
|
2296
|
+
"output_summary": row_list[6],
|
|
2297
|
+
"session_id": row_list[7],
|
|
2298
|
+
"status": row_list[8],
|
|
2299
|
+
"model": row_list[9],
|
|
2300
|
+
"parent_event_id": row_list[10],
|
|
2301
|
+
"execution_duration_seconds": row_list[11] or 0.0,
|
|
2302
|
+
"cost_tokens": row_list[13] or 0,
|
|
2303
|
+
"context": context_data,
|
|
2304
|
+
}
|
|
2305
|
+
await websocket.send_json(event_data)
|
|
2306
|
+
|
|
2307
|
+
# Update last_timestamp to last historical event
|
|
2308
|
+
last_timestamp = historical_rows_list[-1][3]
|
|
2309
|
+
|
|
2310
|
+
except Exception as e:
|
|
2311
|
+
logger.error(f"Error loading historical events: {e}")
|
|
2312
|
+
finally:
|
|
2313
|
+
await db.close()
|
|
2314
|
+
|
|
2315
|
+
# Update to current time for real-time streaming
|
|
2316
|
+
last_timestamp = datetime.now().isoformat()
|
|
2317
|
+
poll_interval = 0.5 # OPTIMIZATION: Adaptive polling (reduced from 1s)
|
|
2318
|
+
last_live_event_id = 0 # Track last broadcast live event ID
|
|
2319
|
+
|
|
2320
|
+
try:
|
|
2321
|
+
while True:
|
|
2322
|
+
db = await get_db()
|
|
2323
|
+
has_activity = False
|
|
2324
|
+
try:
|
|
2325
|
+
# ===== 1. Poll agent_events (existing logic) =====
|
|
2326
|
+
# OPTIMIZATION: Only select needed columns, use DESC index
|
|
2327
|
+
# Pattern uses index: idx_agent_events_timestamp DESC
|
|
2328
|
+
# Only fetch events AFTER last_timestamp to stream new events only
|
|
2329
|
+
query = """
|
|
2330
|
+
SELECT event_id, agent_id, event_type, timestamp, tool_name,
|
|
2331
|
+
input_summary, output_summary, session_id, status, model,
|
|
2332
|
+
parent_event_id, execution_duration_seconds, context,
|
|
2333
|
+
cost_tokens
|
|
2334
|
+
FROM agent_events
|
|
2335
|
+
WHERE timestamp > ?
|
|
2336
|
+
ORDER BY timestamp ASC
|
|
2337
|
+
LIMIT 100
|
|
2338
|
+
"""
|
|
2339
|
+
|
|
2340
|
+
cursor = await db.execute(query, [last_timestamp])
|
|
2341
|
+
rows = await cursor.fetchall()
|
|
2342
|
+
|
|
2343
|
+
if rows:
|
|
2344
|
+
has_activity = True
|
|
2345
|
+
rows_list: list[list[Any]] = [list(row) for row in rows]
|
|
2346
|
+
# Update last timestamp (last row since ORDER BY ts ASC)
|
|
2347
|
+
last_timestamp = rows_list[-1][3]
|
|
2348
|
+
|
|
2349
|
+
# Send events in order (no need to reverse with ASC)
|
|
2350
|
+
for event_row in rows_list:
|
|
2351
|
+
# Parse context JSON if present
|
|
2352
|
+
context_data = {}
|
|
2353
|
+
if event_row[12]: # context column
|
|
2354
|
+
try:
|
|
2355
|
+
context_data = json.loads(event_row[12])
|
|
2356
|
+
except (json.JSONDecodeError, TypeError):
|
|
2357
|
+
pass
|
|
2358
|
+
|
|
2359
|
+
event_data = {
|
|
2360
|
+
"type": "event",
|
|
2361
|
+
"event_id": event_row[0],
|
|
2362
|
+
"agent_id": event_row[1] or "unknown",
|
|
2363
|
+
"event_type": event_row[2],
|
|
2364
|
+
"timestamp": event_row[3],
|
|
2365
|
+
"tool_name": event_row[4],
|
|
2366
|
+
"input_summary": event_row[5],
|
|
2367
|
+
"output_summary": event_row[6],
|
|
2368
|
+
"session_id": event_row[7],
|
|
2369
|
+
"status": event_row[8],
|
|
2370
|
+
"model": event_row[9],
|
|
2371
|
+
"parent_event_id": event_row[10],
|
|
2372
|
+
"execution_duration_seconds": event_row[11] or 0.0,
|
|
2373
|
+
"cost_tokens": event_row[13] or 0,
|
|
2374
|
+
"context": context_data,
|
|
2375
|
+
}
|
|
2376
|
+
await websocket.send_json(event_data)
|
|
2377
|
+
|
|
2378
|
+
# ===== 2. Poll live_events for spawner streaming =====
|
|
2379
|
+
# Fetch pending live events that haven't been broadcast yet
|
|
2380
|
+
live_query = """
|
|
2381
|
+
SELECT id, event_type, event_data, parent_event_id,
|
|
2382
|
+
session_id, spawner_type, created_at
|
|
2383
|
+
FROM live_events
|
|
2384
|
+
WHERE broadcast_at IS NULL AND id > ?
|
|
2385
|
+
ORDER BY created_at ASC
|
|
2386
|
+
LIMIT 50
|
|
2387
|
+
"""
|
|
2388
|
+
live_cursor = await db.execute(live_query, [last_live_event_id])
|
|
2389
|
+
live_rows = list(await live_cursor.fetchall())
|
|
2390
|
+
|
|
2391
|
+
if live_rows:
|
|
2392
|
+
logger.info(
|
|
2393
|
+
f"[WebSocket] Found {len(live_rows)} pending live_events to broadcast"
|
|
2394
|
+
)
|
|
2395
|
+
has_activity = True
|
|
2396
|
+
broadcast_ids: list[int] = []
|
|
2397
|
+
|
|
2398
|
+
for live_row in live_rows:
|
|
2399
|
+
live_id: int = live_row[0]
|
|
2400
|
+
event_type: str = live_row[1]
|
|
2401
|
+
event_data_json: str | None = live_row[2]
|
|
2402
|
+
parent_event_id: str | None = live_row[3]
|
|
2403
|
+
session_id: str | None = live_row[4]
|
|
2404
|
+
spawner_type: str | None = live_row[5]
|
|
2405
|
+
created_at: str = live_row[6]
|
|
2406
|
+
|
|
2407
|
+
# Parse event_data JSON
|
|
2408
|
+
try:
|
|
2409
|
+
event_data_parsed = (
|
|
2410
|
+
json.loads(event_data_json)
|
|
2411
|
+
if event_data_json
|
|
2412
|
+
else {}
|
|
2413
|
+
)
|
|
2414
|
+
except (json.JSONDecodeError, TypeError):
|
|
2415
|
+
event_data_parsed = {}
|
|
2416
|
+
|
|
2417
|
+
# Send spawner event to client
|
|
2418
|
+
spawner_event = {
|
|
2419
|
+
"type": "spawner_event",
|
|
2420
|
+
"live_event_id": live_id,
|
|
2421
|
+
"event_type": event_type,
|
|
2422
|
+
"spawner_type": spawner_type,
|
|
2423
|
+
"parent_event_id": parent_event_id,
|
|
2424
|
+
"session_id": session_id,
|
|
2425
|
+
"timestamp": created_at,
|
|
2426
|
+
"data": event_data_parsed,
|
|
2427
|
+
}
|
|
2428
|
+
logger.info(
|
|
2429
|
+
f"[WebSocket] Sending spawner_event: id={live_id}, type={event_type}, spawner={spawner_type}"
|
|
2430
|
+
)
|
|
2431
|
+
await websocket.send_json(spawner_event)
|
|
2432
|
+
|
|
2433
|
+
broadcast_ids.append(live_id)
|
|
2434
|
+
last_live_event_id = max(last_live_event_id, live_id)
|
|
2435
|
+
|
|
2436
|
+
# Mark events as broadcast
|
|
2437
|
+
if broadcast_ids:
|
|
2438
|
+
logger.info(
|
|
2439
|
+
f"[WebSocket] Marking {len(broadcast_ids)} events as broadcast: {broadcast_ids}"
|
|
2440
|
+
)
|
|
2441
|
+
placeholders = ",".join("?" for _ in broadcast_ids)
|
|
2442
|
+
await db.execute(
|
|
2443
|
+
f"""
|
|
2444
|
+
UPDATE live_events
|
|
2445
|
+
SET broadcast_at = CURRENT_TIMESTAMP
|
|
2446
|
+
WHERE id IN ({placeholders})
|
|
2447
|
+
""",
|
|
2448
|
+
broadcast_ids,
|
|
2449
|
+
)
|
|
2450
|
+
await db.commit()
|
|
2451
|
+
|
|
2452
|
+
# ===== 3. Periodic cleanup of old broadcast events =====
|
|
2453
|
+
# Clean up events older than 5 minutes (every ~10 poll cycles)
|
|
2454
|
+
if random.random() < 0.1: # 10% chance each cycle
|
|
2455
|
+
await db.execute(
|
|
2456
|
+
"""
|
|
2457
|
+
DELETE FROM live_events
|
|
2458
|
+
WHERE broadcast_at IS NOT NULL
|
|
2459
|
+
AND created_at < datetime('now', '-5 minutes')
|
|
2460
|
+
"""
|
|
2461
|
+
)
|
|
2462
|
+
await db.commit()
|
|
2463
|
+
|
|
2464
|
+
# Adjust poll interval based on activity
|
|
2465
|
+
if has_activity:
|
|
2466
|
+
poll_interval = 0.3 # Speed up when active
|
|
2467
|
+
else:
|
|
2468
|
+
# No new events, increase poll interval (exponential backoff)
|
|
2469
|
+
poll_interval = min(poll_interval * 1.2, 2.0)
|
|
2470
|
+
|
|
2471
|
+
finally:
|
|
2472
|
+
await db.close()
|
|
2473
|
+
|
|
2474
|
+
# OPTIMIZATION: Reduced sleep interval for faster real-time updates
|
|
2475
|
+
await asyncio.sleep(poll_interval)
|
|
2476
|
+
|
|
2477
|
+
except WebSocketDisconnect:
|
|
2478
|
+
logger.info("WebSocket client disconnected")
|
|
2479
|
+
except Exception as e:
|
|
2480
|
+
logger.error(f"WebSocket error: {e}")
|
|
2481
|
+
await websocket.close(code=1011)
|
|
2482
|
+
|
|
2483
|
+
return app
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
# Create default app instance
|
|
2487
|
+
def create_app(db_path: str | None = None) -> FastAPI:
|
|
2488
|
+
"""Create FastAPI app with default database path."""
|
|
2489
|
+
if db_path is None:
|
|
2490
|
+
# Use htmlgraph.db - this is the main database with all events
|
|
2491
|
+
# Note: Changed from index.sqlite which was empty analytics cache
|
|
2492
|
+
db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
|
|
2493
|
+
|
|
2494
|
+
return get_app(db_path)
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
# Export for uvicorn
|
|
2498
|
+
app = create_app()
|