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,293 @@
1
+ """Cost tracking service for AI agent usage.
2
+
3
+ Tracks and aggregates costs across:
4
+ - Individual tickets
5
+ - Goals/sprints
6
+ - Time periods (daily, weekly, monthly)
7
+
8
+ Helps individual developers stay within budget and understand spending patterns.
9
+ """
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta
14
+
15
+ from sqlalchemy import func, select
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from app.models.agent_session import AgentSession
19
+ from app.models.ticket import Ticket
20
+ from app.services.agent_registry import AGENT_REGISTRY, AgentType
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class CostSummary:
27
+ """Cost summary for a period or entity."""
28
+
29
+ total_cost_usd: float
30
+ total_input_tokens: int
31
+ total_output_tokens: int
32
+ session_count: int
33
+ avg_cost_per_session: float
34
+ cost_by_agent: dict[str, float]
35
+ cost_by_goal: dict[str, float]
36
+ daily_costs: list[tuple[str, float]] # (date, cost) pairs
37
+
38
+
39
+ @dataclass
40
+ class BudgetStatus:
41
+ """Budget tracking status."""
42
+
43
+ daily_budget: float | None
44
+ daily_spent: float
45
+ daily_remaining: float
46
+ weekly_budget: float | None
47
+ weekly_spent: float
48
+ weekly_remaining: float
49
+ monthly_budget: float | None
50
+ monthly_spent: float
51
+ monthly_remaining: float
52
+ is_over_budget: bool
53
+ warning_threshold_reached: bool # 80% of any budget
54
+
55
+
56
+ class CostTrackingService:
57
+ """Service for tracking and analyzing AI agent costs."""
58
+
59
+ # Default budgets (can be overridden in config)
60
+ DEFAULT_DAILY_BUDGET = 10.0 # $10/day
61
+ DEFAULT_WEEKLY_BUDGET = 50.0 # $50/week
62
+ DEFAULT_MONTHLY_BUDGET = 150.0 # $150/month
63
+ WARNING_THRESHOLD = 0.8 # Warn at 80%
64
+
65
+ def __init__(self, db: AsyncSession):
66
+ self.db = db
67
+
68
+ async def calculate_cost(
69
+ self, agent_type: AgentType, input_tokens: int, output_tokens: int
70
+ ) -> float:
71
+ """Calculate cost for given token usage."""
72
+ config = AGENT_REGISTRY.get(agent_type)
73
+ if not config or not config.cost_per_1k_input:
74
+ return 0.0
75
+
76
+ input_cost = (input_tokens / 1000) * config.cost_per_1k_input
77
+ output_cost = (output_tokens / 1000) * (
78
+ config.cost_per_1k_output or config.cost_per_1k_input
79
+ )
80
+
81
+ return round(input_cost + output_cost, 6)
82
+
83
+ async def get_period_cost(
84
+ self, start_date: datetime, end_date: datetime, goal_id: str | None = None
85
+ ) -> float:
86
+ """Get total cost for a time period, optionally filtered by goal."""
87
+ query = select(func.sum(AgentSession.estimated_cost_usd)).where(
88
+ AgentSession.created_at >= start_date, AgentSession.created_at < end_date
89
+ )
90
+
91
+ if goal_id:
92
+ query = query.join(Ticket).where(Ticket.goal_id == goal_id)
93
+
94
+ result = await self.db.execute(query)
95
+ total = result.scalar()
96
+ return float(total or 0)
97
+
98
+ async def get_daily_cost(self, date: datetime | None = None) -> float:
99
+ """Get cost for a specific day (defaults to today)."""
100
+ if date is None:
101
+ date = datetime.utcnow()
102
+
103
+ start = date.replace(hour=0, minute=0, second=0, microsecond=0)
104
+ end = start + timedelta(days=1)
105
+
106
+ return await self.get_period_cost(start, end)
107
+
108
+ async def get_weekly_cost(self, date: datetime | None = None) -> float:
109
+ """Get cost for the week containing the given date."""
110
+ if date is None:
111
+ date = datetime.utcnow()
112
+
113
+ # Start of week (Monday)
114
+ start = date - timedelta(days=date.weekday())
115
+ start = start.replace(hour=0, minute=0, second=0, microsecond=0)
116
+ end = start + timedelta(weeks=1)
117
+
118
+ return await self.get_period_cost(start, end)
119
+
120
+ async def get_monthly_cost(self, date: datetime | None = None) -> float:
121
+ """Get cost for the month containing the given date."""
122
+ if date is None:
123
+ date = datetime.utcnow()
124
+
125
+ start = date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
126
+ if start.month == 12:
127
+ end = start.replace(year=start.year + 1, month=1)
128
+ else:
129
+ end = start.replace(month=start.month + 1)
130
+
131
+ return await self.get_period_cost(start, end)
132
+
133
+ async def get_budget_status(
134
+ self,
135
+ daily_budget: float | None = None,
136
+ weekly_budget: float | None = None,
137
+ monthly_budget: float | None = None,
138
+ ) -> BudgetStatus:
139
+ """Get current budget status across all time periods."""
140
+ daily_budget = daily_budget or self.DEFAULT_DAILY_BUDGET
141
+ weekly_budget = weekly_budget or self.DEFAULT_WEEKLY_BUDGET
142
+ monthly_budget = monthly_budget or self.DEFAULT_MONTHLY_BUDGET
143
+
144
+ daily_spent = await self.get_daily_cost()
145
+ weekly_spent = await self.get_weekly_cost()
146
+ monthly_spent = await self.get_monthly_cost()
147
+
148
+ daily_remaining = max(0, daily_budget - daily_spent)
149
+ weekly_remaining = max(0, weekly_budget - weekly_spent)
150
+ monthly_remaining = max(0, monthly_budget - monthly_spent)
151
+
152
+ is_over_budget = (
153
+ daily_spent > daily_budget
154
+ or weekly_spent > weekly_budget
155
+ or monthly_spent > monthly_budget
156
+ )
157
+
158
+ warning_reached = (
159
+ daily_spent >= daily_budget * self.WARNING_THRESHOLD
160
+ or weekly_spent >= weekly_budget * self.WARNING_THRESHOLD
161
+ or monthly_spent >= monthly_budget * self.WARNING_THRESHOLD
162
+ )
163
+
164
+ return BudgetStatus(
165
+ daily_budget=daily_budget,
166
+ daily_spent=round(daily_spent, 2),
167
+ daily_remaining=round(daily_remaining, 2),
168
+ weekly_budget=weekly_budget,
169
+ weekly_spent=round(weekly_spent, 2),
170
+ weekly_remaining=round(weekly_remaining, 2),
171
+ monthly_budget=monthly_budget,
172
+ monthly_spent=round(monthly_spent, 2),
173
+ monthly_remaining=round(monthly_remaining, 2),
174
+ is_over_budget=is_over_budget,
175
+ warning_threshold_reached=warning_reached,
176
+ )
177
+
178
+ async def get_cost_summary(
179
+ self, days: int = 30, goal_id: str | None = None
180
+ ) -> CostSummary:
181
+ """Get detailed cost summary for the last N days."""
182
+ end_date = datetime.utcnow()
183
+ start_date = end_date - timedelta(days=days)
184
+
185
+ # Base query
186
+ base_query = select(AgentSession).where(
187
+ AgentSession.created_at >= start_date, AgentSession.created_at < end_date
188
+ )
189
+
190
+ if goal_id:
191
+ base_query = base_query.join(Ticket).where(Ticket.goal_id == goal_id)
192
+
193
+ result = await self.db.execute(base_query)
194
+ sessions = result.scalars().all()
195
+
196
+ # Aggregate data
197
+ total_cost = sum(s.estimated_cost_usd for s in sessions)
198
+ total_input = sum(s.total_input_tokens for s in sessions)
199
+ total_output = sum(s.total_output_tokens for s in sessions)
200
+ session_count = len(sessions)
201
+
202
+ # Cost by agent
203
+ cost_by_agent: dict[str, float] = {}
204
+ for session in sessions:
205
+ agent = session.agent_type
206
+ cost_by_agent[agent] = (
207
+ cost_by_agent.get(agent, 0) + session.estimated_cost_usd
208
+ )
209
+
210
+ # Cost by goal (need to join with tickets)
211
+ cost_by_goal: dict[str, float] = {}
212
+ for session in sessions:
213
+ if session.ticket and session.ticket.goal_id:
214
+ goal_id = str(session.ticket.goal_id)
215
+ cost_by_goal[goal_id] = (
216
+ cost_by_goal.get(goal_id, 0) + session.estimated_cost_usd
217
+ )
218
+
219
+ # Daily breakdown
220
+ daily_costs: dict[str, float] = {}
221
+ for session in sessions:
222
+ day_key = session.created_at.strftime("%Y-%m-%d")
223
+ daily_costs[day_key] = (
224
+ daily_costs.get(day_key, 0) + session.estimated_cost_usd
225
+ )
226
+
227
+ daily_list = sorted(daily_costs.items())
228
+
229
+ return CostSummary(
230
+ total_cost_usd=round(total_cost, 2),
231
+ total_input_tokens=total_input,
232
+ total_output_tokens=total_output,
233
+ session_count=session_count,
234
+ avg_cost_per_session=round(total_cost / max(1, session_count), 4),
235
+ cost_by_agent={k: round(v, 2) for k, v in cost_by_agent.items()},
236
+ cost_by_goal={k: round(v, 2) for k, v in cost_by_goal.items()},
237
+ daily_costs=[(d, round(c, 2)) for d, c in daily_list],
238
+ )
239
+
240
+ async def get_ticket_cost(self, ticket_id: str) -> float:
241
+ """Get total cost for a specific ticket."""
242
+ query = select(func.sum(AgentSession.estimated_cost_usd)).where(
243
+ AgentSession.ticket_id == ticket_id
244
+ )
245
+ result = await self.db.execute(query)
246
+ total = result.scalar()
247
+ return float(total or 0)
248
+
249
+ async def get_goal_cost(self, goal_id: str) -> float:
250
+ """Get total cost for all tickets in a goal."""
251
+ query = (
252
+ select(func.sum(AgentSession.estimated_cost_usd))
253
+ .join(Ticket)
254
+ .where(Ticket.goal_id == goal_id)
255
+ )
256
+ result = await self.db.execute(query)
257
+ total = result.scalar()
258
+ return float(total or 0)
259
+
260
+ async def estimate_remaining_cost(
261
+ self, goal_id: str, avg_cost_per_ticket: float | None = None
262
+ ) -> dict[str, float]:
263
+ """Estimate remaining cost to complete a goal."""
264
+ # Get incomplete tickets
265
+ query = (
266
+ select(func.count())
267
+ .select_from(Ticket)
268
+ .where(
269
+ Ticket.goal_id == goal_id,
270
+ Ticket.state.in_(["todo", "planned", "executing", "blocked"]),
271
+ )
272
+ )
273
+ result = await self.db.execute(query)
274
+ remaining_tickets = result.scalar() or 0
275
+
276
+ # Get average cost per ticket if not provided
277
+ if avg_cost_per_ticket is None:
278
+ summary = await self.get_cost_summary(days=30, goal_id=goal_id)
279
+ if summary.session_count > 0:
280
+ avg_cost_per_ticket = summary.avg_cost_per_session
281
+ else:
282
+ avg_cost_per_ticket = 0.50 # Default estimate
283
+
284
+ spent = await self.get_goal_cost(goal_id)
285
+ estimated_remaining = remaining_tickets * avg_cost_per_ticket
286
+
287
+ return {
288
+ "spent": round(spent, 2),
289
+ "estimated_remaining": round(estimated_remaining, 2),
290
+ "estimated_total": round(spent + estimated_remaining, 2),
291
+ "remaining_tickets": remaining_tickets,
292
+ "avg_cost_per_ticket": round(avg_cost_per_ticket, 4),
293
+ }