htmlgraph 0.20.1__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.
Files changed (304) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.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()