draft-board 0.1.0-beta.0

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 (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,351 @@
1
+ """Dashboard and metrics API endpoints."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+
6
+ from fastapi import APIRouter, Depends, Query
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy import func, select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.database import get_db
12
+ from app.models.agent_session import AgentSession
13
+ from app.models.ticket import Ticket
14
+ from app.state_machine import TicketState
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
19
+
20
+
21
+ # ============================================================================
22
+ # Response Models
23
+ # ============================================================================
24
+
25
+
26
+ class BudgetStatus(BaseModel):
27
+ """Budget tracking status."""
28
+
29
+ daily_budget: float | None = None
30
+ daily_spent: float = 0
31
+ daily_remaining: float = 0
32
+ weekly_budget: float | None = None
33
+ weekly_spent: float = 0
34
+ weekly_remaining: float = 0
35
+ monthly_budget: float | None = None
36
+ monthly_spent: float = 0
37
+ monthly_remaining: float = 0
38
+ is_over_budget: bool = False
39
+ warning_threshold_reached: bool = False
40
+
41
+
42
+ class SprintMetrics(BaseModel):
43
+ """Sprint progress metrics."""
44
+
45
+ total_tickets: int = 0
46
+ completed_tickets: int = 0
47
+ in_progress_tickets: int = 0
48
+ blocked_tickets: int = 0
49
+ completion_rate: float = 0
50
+ avg_cycle_time_hours: float = 0
51
+ velocity: float = 0 # tickets per day
52
+
53
+
54
+ class AgentMetrics(BaseModel):
55
+ """AI agent usage metrics."""
56
+
57
+ total_sessions: int = 0
58
+ successful_sessions: int = 0
59
+ success_rate: float = 0
60
+ avg_turns_per_session: float = 0
61
+ most_used_agent: str = "claude"
62
+ total_cost_usd: float = 0
63
+
64
+
65
+ class CostTrendItem(BaseModel):
66
+ """Daily cost trend item."""
67
+
68
+ date: str
69
+ cost: float
70
+
71
+
72
+ class DashboardResponse(BaseModel):
73
+ """Complete dashboard data."""
74
+
75
+ budget: BudgetStatus
76
+ sprint: SprintMetrics
77
+ agent: AgentMetrics
78
+ cost_trend: list[CostTrendItem] = Field(default_factory=list)
79
+
80
+
81
+ # ============================================================================
82
+ # Helper Functions
83
+ # ============================================================================
84
+
85
+
86
+ async def get_period_cost(
87
+ db: AsyncSession,
88
+ start_date: datetime,
89
+ end_date: datetime,
90
+ goal_id: str | None = None,
91
+ ) -> float:
92
+ """Get total cost for a time period."""
93
+ query = select(func.coalesce(func.sum(AgentSession.estimated_cost_usd), 0)).where(
94
+ AgentSession.created_at >= start_date, AgentSession.created_at < end_date
95
+ )
96
+
97
+ if goal_id:
98
+ query = query.join(Ticket).where(Ticket.goal_id == goal_id)
99
+
100
+ result = await db.execute(query)
101
+ return float(result.scalar() or 0)
102
+
103
+
104
+ async def get_budget_status(
105
+ db: AsyncSession,
106
+ goal_id: str | None = None,
107
+ daily_budget: float = 10.0,
108
+ weekly_budget: float = 50.0,
109
+ monthly_budget: float = 150.0,
110
+ ) -> BudgetStatus:
111
+ """Calculate budget status for all periods."""
112
+ now = datetime.utcnow()
113
+
114
+ # Daily
115
+ day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
116
+ daily_spent = await get_period_cost(
117
+ db, day_start, day_start + timedelta(days=1), goal_id
118
+ )
119
+
120
+ # Weekly (Monday start)
121
+ week_start = day_start - timedelta(days=day_start.weekday())
122
+ weekly_spent = await get_period_cost(
123
+ db, week_start, week_start + timedelta(weeks=1), goal_id
124
+ )
125
+
126
+ # Monthly
127
+ month_start = day_start.replace(day=1)
128
+ if month_start.month == 12:
129
+ month_end = month_start.replace(year=month_start.year + 1, month=1)
130
+ else:
131
+ month_end = month_start.replace(month=month_start.month + 1)
132
+ monthly_spent = await get_period_cost(db, month_start, month_end, goal_id)
133
+
134
+ is_over = (
135
+ daily_spent > daily_budget
136
+ or weekly_spent > weekly_budget
137
+ or monthly_spent > monthly_budget
138
+ )
139
+
140
+ warning = (
141
+ daily_spent >= daily_budget * 0.8
142
+ or weekly_spent >= weekly_budget * 0.8
143
+ or monthly_spent >= monthly_budget * 0.8
144
+ )
145
+
146
+ return BudgetStatus(
147
+ daily_budget=daily_budget,
148
+ daily_spent=round(daily_spent, 2),
149
+ daily_remaining=round(max(0, daily_budget - daily_spent), 2),
150
+ weekly_budget=weekly_budget,
151
+ weekly_spent=round(weekly_spent, 2),
152
+ weekly_remaining=round(max(0, weekly_budget - weekly_spent), 2),
153
+ monthly_budget=monthly_budget,
154
+ monthly_spent=round(monthly_spent, 2),
155
+ monthly_remaining=round(max(0, monthly_budget - monthly_spent), 2),
156
+ is_over_budget=is_over,
157
+ warning_threshold_reached=warning,
158
+ )
159
+
160
+
161
+ async def get_sprint_metrics(
162
+ db: AsyncSession, goal_id: str | None = None
163
+ ) -> SprintMetrics:
164
+ """Calculate sprint progress metrics."""
165
+ base_query = select(Ticket)
166
+ if goal_id:
167
+ base_query = base_query.where(Ticket.goal_id == goal_id)
168
+
169
+ result = await db.execute(base_query)
170
+ tickets = result.scalars().all()
171
+
172
+ if not tickets:
173
+ return SprintMetrics()
174
+
175
+ total = len(tickets)
176
+ completed = sum(1 for t in tickets if t.state == TicketState.DONE.value)
177
+ in_progress = sum(
178
+ 1
179
+ for t in tickets
180
+ if t.state
181
+ in [
182
+ TicketState.EXECUTING.value,
183
+ TicketState.VERIFYING.value,
184
+ TicketState.NEEDS_HUMAN.value,
185
+ ]
186
+ )
187
+ blocked = sum(1 for t in tickets if t.state == TicketState.BLOCKED.value)
188
+
189
+ # Calculate velocity (tickets completed in last 7 days)
190
+ week_ago = datetime.utcnow() - timedelta(days=7)
191
+ completed_recently = sum(
192
+ 1
193
+ for t in tickets
194
+ if t.state == TicketState.DONE.value and t.updated_at >= week_ago
195
+ )
196
+ velocity = completed_recently / 7.0
197
+
198
+ # Average cycle time (from created to done)
199
+ done_tickets = [t for t in tickets if t.state == TicketState.DONE.value]
200
+ if done_tickets:
201
+ cycle_times = [
202
+ (t.updated_at - t.created_at).total_seconds() / 3600 for t in done_tickets
203
+ ]
204
+ avg_cycle = sum(cycle_times) / len(cycle_times)
205
+ else:
206
+ avg_cycle = 0
207
+
208
+ return SprintMetrics(
209
+ total_tickets=total,
210
+ completed_tickets=completed,
211
+ in_progress_tickets=in_progress,
212
+ blocked_tickets=blocked,
213
+ completion_rate=round((completed / total) * 100, 1) if total > 0 else 0,
214
+ avg_cycle_time_hours=round(avg_cycle, 1),
215
+ velocity=round(velocity, 1),
216
+ )
217
+
218
+
219
+ async def get_agent_metrics(
220
+ db: AsyncSession, goal_id: str | None = None
221
+ ) -> AgentMetrics:
222
+ """Calculate AI agent usage metrics using SQL aggregation."""
223
+ # Build base filter condition
224
+ filters = []
225
+ if goal_id:
226
+ filters.append(Ticket.goal_id == goal_id)
227
+
228
+ # Aggregate totals in a single query
229
+ totals_query = select(
230
+ func.count(AgentSession.id).label("total"),
231
+ func.count(AgentSession.ended_at)
232
+ .filter(AgentSession.turn_count > 0)
233
+ .label("successful"),
234
+ func.coalesce(func.avg(AgentSession.turn_count), 0).label("avg_turns"),
235
+ func.coalesce(func.sum(AgentSession.estimated_cost_usd), 0).label("total_cost"),
236
+ )
237
+ if goal_id:
238
+ totals_query = totals_query.join(Ticket).where(Ticket.goal_id == goal_id)
239
+
240
+ totals_result = await db.execute(totals_query)
241
+ row = totals_result.one()
242
+ total = row.total
243
+ successful = row.successful
244
+ avg_turns = float(row.avg_turns)
245
+ total_cost = float(row.total_cost)
246
+
247
+ if total == 0:
248
+ return AgentMetrics()
249
+
250
+ # Most used agent via GROUP BY
251
+ agent_query = select(
252
+ AgentSession.agent_type, func.count(AgentSession.id).label("cnt")
253
+ ).group_by(AgentSession.agent_type)
254
+ if goal_id:
255
+ agent_query = agent_query.join(Ticket).where(Ticket.goal_id == goal_id)
256
+ agent_query = agent_query.order_by(func.count(AgentSession.id).desc()).limit(1)
257
+
258
+ agent_result = await db.execute(agent_query)
259
+ agent_row = agent_result.first()
260
+ most_used = agent_row[0] if agent_row else "claude"
261
+
262
+ return AgentMetrics(
263
+ total_sessions=total,
264
+ successful_sessions=successful,
265
+ success_rate=round((successful / total) * 100, 1) if total > 0 else 0,
266
+ avg_turns_per_session=round(avg_turns, 1),
267
+ most_used_agent=most_used,
268
+ total_cost_usd=round(total_cost, 2),
269
+ )
270
+
271
+
272
+ async def get_cost_trend(
273
+ db: AsyncSession, days: int = 7, goal_id: str | None = None
274
+ ) -> list[CostTrendItem]:
275
+ """Get daily cost trend for the last N days."""
276
+ trends = []
277
+ now = datetime.utcnow()
278
+
279
+ for i in range(days - 1, -1, -1):
280
+ day = now - timedelta(days=i)
281
+ day_start = day.replace(hour=0, minute=0, second=0, microsecond=0)
282
+ day_end = day_start + timedelta(days=1)
283
+
284
+ cost = await get_period_cost(db, day_start, day_end, goal_id)
285
+
286
+ trends.append(
287
+ CostTrendItem(
288
+ date=day_start.strftime("%a"), # Mon, Tue, etc.
289
+ cost=round(cost, 2),
290
+ )
291
+ )
292
+
293
+ return trends
294
+
295
+
296
+ # ============================================================================
297
+ # API Endpoints
298
+ # ============================================================================
299
+
300
+
301
+ @router.get("", response_model=DashboardResponse)
302
+ async def get_dashboard(
303
+ goal_id: str | None = Query(None, description="Filter by goal ID"),
304
+ daily_budget: float = Query(10.0, description="Daily budget limit"),
305
+ weekly_budget: float = Query(50.0, description="Weekly budget limit"),
306
+ monthly_budget: float = Query(150.0, description="Monthly budget limit"),
307
+ db: AsyncSession = Depends(get_db),
308
+ ) -> DashboardResponse:
309
+ """Get complete dashboard data with metrics and budget status."""
310
+ budget = await get_budget_status(
311
+ db, goal_id, daily_budget, weekly_budget, monthly_budget
312
+ )
313
+ sprint = await get_sprint_metrics(db, goal_id)
314
+ agent = await get_agent_metrics(db, goal_id)
315
+ cost_trend = await get_cost_trend(db, 7, goal_id)
316
+
317
+ return DashboardResponse(
318
+ budget=budget, sprint=sprint, agent=agent, cost_trend=cost_trend
319
+ )
320
+
321
+
322
+ @router.get("/budget", response_model=BudgetStatus)
323
+ async def get_budget(
324
+ goal_id: str | None = Query(None, description="Filter by goal ID"),
325
+ daily_budget: float = Query(10.0),
326
+ weekly_budget: float = Query(50.0),
327
+ monthly_budget: float = Query(150.0),
328
+ db: AsyncSession = Depends(get_db),
329
+ ) -> BudgetStatus:
330
+ """Get current budget status."""
331
+ return await get_budget_status(
332
+ db, goal_id, daily_budget, weekly_budget, monthly_budget
333
+ )
334
+
335
+
336
+ @router.get("/sprint", response_model=SprintMetrics)
337
+ async def get_sprint(
338
+ goal_id: str | None = Query(None, description="Filter by goal ID"),
339
+ db: AsyncSession = Depends(get_db),
340
+ ) -> SprintMetrics:
341
+ """Get sprint progress metrics."""
342
+ return await get_sprint_metrics(db, goal_id)
343
+
344
+
345
+ @router.get("/agent-metrics", response_model=AgentMetrics)
346
+ async def get_agents(
347
+ goal_id: str | None = Query(None, description="Filter by goal ID"),
348
+ db: AsyncSession = Depends(get_db),
349
+ ) -> AgentMetrics:
350
+ """Get AI agent usage metrics."""
351
+ return await get_agent_metrics(db, goal_id)