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,312 @@
1
+ """Cost-based rate limiting middleware with pluggable backend (Redis or SQLite).
2
+
3
+ CRITICAL: Rate limiting gates on ESTIMATED cost BEFORE expensive work.
4
+ Actual cost is emitted as telemetry only (X-RateLimit-Actual-Cost header).
5
+
6
+ Cost estimation (pre-request):
7
+ - Base cost: 1 point per request
8
+ - +1 per focus_area
9
+ - +2 if include_readme=true
10
+ - +5 if depth > 1 (future)
11
+ - Caps from config add fixed overhead
12
+
13
+ This prevents "melt down first, then reject" under load.
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import time
20
+ from collections.abc import Callable
21
+
22
+ from fastapi import Request, Response
23
+ from starlette.middleware.base import BaseHTTPMiddleware
24
+ from starlette.responses import JSONResponse
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # Endpoints with rate limiting (expensive LLM operations)
30
+ RATE_LIMITED_ENDPOINTS = {
31
+ "/goals/{goal_id}/generate-tickets",
32
+ "/goals/{goal_id}/reflect-on-tickets",
33
+ "/boards/{board_id}/analyze-codebase",
34
+ "/board/analyze-codebase", # Legacy
35
+ "/planner/tick",
36
+ "/planner/start",
37
+ "/udar/{goal_id}/generate", # UDAR agent initial generation
38
+ "/udar/{goal_id}/replan", # UDAR agent incremental replanning
39
+ }
40
+
41
+ # Cost-based rate limit configuration
42
+ RATE_LIMIT_BUDGET = 100 # Cost points per window
43
+ RATE_LIMIT_WINDOW_SECONDS = 60 # Per minute
44
+
45
+ # Cost scoring - estimated BEFORE request executes
46
+ BASE_COST = 1
47
+ COST_PER_FOCUS_AREA = 2
48
+ COST_INCLUDE_README = 3
49
+ COST_ANALYZE_CODEBASE = 10 # Heavy operation
50
+ COST_GENERATE_TICKETS = 5
51
+ COST_REFLECT = 5
52
+ COST_PLANNER_TICK = 3
53
+ COST_UDAR_GENERATE = 8 # UDAR initial generation (1-2 LLM calls)
54
+ COST_UDAR_REPLAN = 3 # UDAR replanning (0-1 LLM calls)
55
+
56
+ # Redis key prefix
57
+ REDIS_KEY_PREFIX = "ratelimit:"
58
+
59
+
60
+ def _matches_pattern(path: str, patterns: set[str]) -> tuple[bool, str | None]:
61
+ """Check if path matches any pattern. Returns (matches, matched_pattern)."""
62
+ for pattern in patterns:
63
+ pattern_parts = pattern.split("/")
64
+ path_parts = path.split("/")
65
+
66
+ if len(pattern_parts) != len(path_parts):
67
+ continue
68
+
69
+ match = True
70
+ for p_part, path_part in zip(pattern_parts, path_parts, strict=False):
71
+ if p_part.startswith("{") and p_part.endswith("}"):
72
+ continue
73
+ if p_part != path_part:
74
+ match = False
75
+ break
76
+
77
+ if match:
78
+ return True, pattern
79
+ return False, None
80
+
81
+
82
+ def _get_client_id(request: Request) -> str:
83
+ """Get client identifier for rate limiting."""
84
+ client_id = request.headers.get("X-Client-ID")
85
+ if client_id and len(client_id) <= 64:
86
+ return client_id
87
+
88
+ forwarded = request.headers.get("X-Forwarded-For")
89
+ if forwarded:
90
+ return f"ip:{forwarded.split(',')[0].strip()}"
91
+
92
+ return f"ip:{request.client.host if request.client else 'unknown'}"
93
+
94
+
95
+ def _get_route_key(path: str) -> str:
96
+ """Normalize path to route key."""
97
+ import re
98
+
99
+ return re.sub(
100
+ r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
101
+ "{id}",
102
+ path,
103
+ flags=re.IGNORECASE,
104
+ )
105
+
106
+
107
+ def _estimate_request_cost(body: bytes, matched_pattern: str | None) -> int:
108
+ """Estimate cost BEFORE request executes based on request intent."""
109
+ cost = BASE_COST
110
+
111
+ if matched_pattern:
112
+ if "analyze-codebase" in matched_pattern:
113
+ cost += COST_ANALYZE_CODEBASE
114
+ elif "generate-tickets" in matched_pattern:
115
+ cost += COST_GENERATE_TICKETS
116
+ elif "reflect-on-tickets" in matched_pattern:
117
+ cost += COST_REFLECT
118
+ elif "planner" in matched_pattern:
119
+ cost += COST_PLANNER_TICK
120
+ elif "/udar/" in matched_pattern:
121
+ if "/generate" in matched_pattern:
122
+ cost += COST_UDAR_GENERATE
123
+ elif "/replan" in matched_pattern:
124
+ cost += COST_UDAR_REPLAN
125
+
126
+ try:
127
+ body_dict = json.loads(body) if body else {}
128
+ focus_areas = body_dict.get("focus_areas", [])
129
+ if focus_areas:
130
+ cost += len(focus_areas) * COST_PER_FOCUS_AREA
131
+ if body_dict.get("include_readme"):
132
+ cost += COST_INCLUDE_README
133
+ except (json.JSONDecodeError, TypeError):
134
+ pass
135
+
136
+ return cost
137
+
138
+
139
+ def _compute_actual_cost(response_body: bytes, estimated_cost: int) -> int:
140
+ """Compute actual cost from response (TELEMETRY ONLY, not for gating)."""
141
+ actual = estimated_cost
142
+
143
+ try:
144
+ response_dict = json.loads(response_body)
145
+ context_stats = response_dict.get("context_stats")
146
+
147
+ if context_stats:
148
+ files_scanned = context_stats.get("files_scanned", 0)
149
+ actual += files_scanned // 50
150
+
151
+ bytes_read = context_stats.get("bytes_read", 0)
152
+ actual += bytes_read // 10240
153
+
154
+ if context_stats.get("context_truncated"):
155
+ actual += 5
156
+
157
+ except (json.JSONDecodeError, TypeError, AttributeError):
158
+ pass
159
+
160
+ return actual
161
+
162
+
163
+ def _backend_available() -> bool:
164
+ """Check if the rate limit backend is available."""
165
+ return True # SQLite is always available
166
+
167
+
168
+ class RateLimitMiddleware(BaseHTTPMiddleware):
169
+ """Cost-based rate limiter with pluggable backend.
170
+
171
+ Flow:
172
+ 1. Estimate cost from request intent
173
+ 2. Check if estimated cost would exceed budget
174
+ 3. If over budget: reject with 429 BEFORE doing work
175
+ 4. If under budget: execute request
176
+ 5. Compute actual cost and emit as telemetry header
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ app,
182
+ budget: int = RATE_LIMIT_BUDGET,
183
+ window_seconds: int = RATE_LIMIT_WINDOW_SECONDS,
184
+ ):
185
+ super().__init__(app)
186
+ self.budget = budget
187
+ self.window_seconds = window_seconds
188
+
189
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
190
+ # Only rate-limit POST requests to specific endpoints
191
+ if request.method != "POST":
192
+ return await call_next(request)
193
+
194
+ matches, matched_pattern = _matches_pattern(
195
+ request.url.path, RATE_LIMITED_ENDPOINTS
196
+ )
197
+ if not matches:
198
+ return await call_next(request)
199
+
200
+ # Backend REQUIRED
201
+ if not _backend_available():
202
+ logger.error(
203
+ f"Backend unavailable for rate-limited endpoint: {request.url.path}"
204
+ )
205
+ return JSONResponse(
206
+ status_code=503,
207
+ content={
208
+ "detail": "Service temporarily unavailable. Backend is required for rate limiting.",
209
+ "error_type": "service_unavailable",
210
+ "retry_after_seconds": 30,
211
+ },
212
+ headers={"Retry-After": "30"},
213
+ )
214
+
215
+ client_id = _get_client_id(request)
216
+ route_key = _get_route_key(request.url.path)
217
+
218
+ # Read body to estimate cost BEFORE expensive work
219
+ body = await request.body()
220
+ estimated_cost = _estimate_request_cost(body, matched_pattern)
221
+
222
+ now = time.time()
223
+
224
+ try:
225
+ current_cost, oldest_time = await self._check_sqlite(
226
+ client_id, route_key, estimated_cost
227
+ )
228
+ except Exception as e:
229
+ logger.error(f"Rate limit check failed: {e}")
230
+ return JSONResponse(
231
+ status_code=503,
232
+ content={
233
+ "detail": "Service temporarily unavailable due to rate limit error.",
234
+ "error_type": "service_unavailable",
235
+ },
236
+ )
237
+
238
+ # GATE: Check if estimated cost would exceed budget BEFORE work
239
+ if current_cost + estimated_cost > self.budget:
240
+ retry_after = int(oldest_time + self.window_seconds - now)
241
+ retry_after = max(1, retry_after)
242
+
243
+ logger.warning(
244
+ f"Rate limit exceeded for {client_id}: "
245
+ f"{current_cost}/{self.budget} points, estimated +{estimated_cost}"
246
+ )
247
+
248
+ return JSONResponse(
249
+ status_code=429,
250
+ content={
251
+ "detail": f"Rate limit exceeded. Budget: {self.budget} points/min.",
252
+ "retry_after_seconds": retry_after,
253
+ "budget": self.budget,
254
+ "current_usage": current_cost,
255
+ "estimated_cost": estimated_cost,
256
+ },
257
+ headers={
258
+ "Retry-After": str(retry_after),
259
+ "X-RateLimit-Limit": str(self.budget),
260
+ "X-RateLimit-Remaining": str(max(0, self.budget - current_cost)),
261
+ "X-RateLimit-Reset": str(int(now + retry_after)),
262
+ },
263
+ )
264
+
265
+ # Reconstruct request with body
266
+ async def receive():
267
+ return {"type": "http.request", "body": body}
268
+
269
+ request._receive = receive
270
+
271
+ # Execute request (budget already reserved)
272
+ response = await call_next(request)
273
+
274
+ # Read response body for telemetry
275
+ response_body = b""
276
+ async for chunk in response.body_iterator:
277
+ response_body += chunk
278
+
279
+ # Compute actual cost for observability (telemetry only)
280
+ actual_cost = _compute_actual_cost(response_body, estimated_cost)
281
+ remaining = max(0, self.budget - current_cost - estimated_cost)
282
+
283
+ # Build response with rate limit headers, preserving original headers (including CORS)
284
+ new_response = Response(
285
+ content=response_body,
286
+ status_code=response.status_code,
287
+ media_type=response.media_type or "application/json",
288
+ )
289
+ # Copy original headers (preserves CORS headers)
290
+ for key, value in response.headers.items():
291
+ new_response.headers[key] = value
292
+ # Add rate limit headers
293
+ new_response.headers["X-RateLimit-Limit"] = str(self.budget)
294
+ new_response.headers["X-RateLimit-Remaining"] = str(remaining)
295
+ new_response.headers["X-RateLimit-Reset"] = str(int(now + self.window_seconds))
296
+ new_response.headers["X-RateLimit-Estimated-Cost"] = str(estimated_cost)
297
+ new_response.headers["X-RateLimit-Actual-Cost"] = str(actual_cost)
298
+
299
+ return new_response
300
+
301
+ # ─── SQLite backend ───
302
+
303
+ async def _check_sqlite(
304
+ self, client_id: str, route_key: str, estimated_cost: int
305
+ ) -> tuple[int, float]:
306
+ """Check and record rate limit via SQLite. Returns (current_cost, oldest_time)."""
307
+ from app.sqlite_kv import rate_limit_check_and_record
308
+
309
+ client_key = f"{client_id}:{route_key}"
310
+ return await asyncio.to_thread(
311
+ rate_limit_check_and_record, client_key, estimated_cost, self.window_seconds
312
+ )
@@ -0,0 +1,43 @@
1
+ """Security headers middleware."""
2
+
3
+ from fastapi import Request
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+
6
+
7
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
8
+ """Add security headers to all responses."""
9
+
10
+ async def dispatch(self, request: Request, call_next):
11
+ response = await call_next(request)
12
+
13
+ # Prevent clickjacking
14
+ response.headers["X-Frame-Options"] = "DENY"
15
+
16
+ # Prevent MIME sniffing
17
+ response.headers["X-Content-Type-Options"] = "nosniff"
18
+
19
+ # Enable XSS protection
20
+ response.headers["X-XSS-Protection"] = "1; mode=block"
21
+
22
+ # Strict transport security (HTTPS only)
23
+ if request.url.scheme == "https":
24
+ response.headers["Strict-Transport-Security"] = (
25
+ "max-age=31536000; includeSubDomains"
26
+ )
27
+
28
+ # Content Security Policy
29
+ response.headers["Content-Security-Policy"] = (
30
+ "default-src 'self'; "
31
+ "script-src 'self' 'unsafe-inline'; "
32
+ "style-src 'self' 'unsafe-inline';"
33
+ )
34
+
35
+ # Referrer policy
36
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
37
+
38
+ # Permissions policy
39
+ response.headers["Permissions-Policy"] = (
40
+ "geolocation=(), microphone=(), camera=()"
41
+ )
42
+
43
+ return response
@@ -0,0 +1,37 @@
1
+ """Request timeout middleware."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from fastapi import Request
7
+ from fastapi.responses import JSONResponse
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class TimeoutMiddleware(BaseHTTPMiddleware):
14
+ """Middleware that enforces a global timeout on all requests."""
15
+
16
+ def __init__(self, app, timeout_seconds: int = 120):
17
+ super().__init__(app)
18
+ self.timeout_seconds = timeout_seconds
19
+
20
+ async def dispatch(self, request: Request, call_next):
21
+ try:
22
+ response = await asyncio.wait_for(
23
+ call_next(request), timeout=self.timeout_seconds
24
+ )
25
+ return response
26
+ except TimeoutError:
27
+ logger.error(
28
+ f"Request timed out after {self.timeout_seconds}s: "
29
+ f"{request.method} {request.url.path}"
30
+ )
31
+ return JSONResponse(
32
+ status_code=504,
33
+ content={
34
+ "detail": f"Request timed out after {self.timeout_seconds} seconds",
35
+ "error_type": "timeout",
36
+ },
37
+ )
@@ -0,0 +1,56 @@
1
+ """SQLAlchemy models for Draft."""
2
+
3
+ from app.models.agent_conversation_history import AgentConversationHistory
4
+ from app.models.agent_session import AgentMessage, AgentSession
5
+ from app.models.analysis_cache import AnalysisCache
6
+ from app.models.base import Base
7
+ from app.models.board import Board
8
+ from app.models.board_repo import BoardRepo
9
+ from app.models.cost_budget import CostBudget
10
+ from app.models.evidence import Evidence
11
+ from app.models.goal import Goal
12
+ from app.models.idempotency_entry import IdempotencyEntry
13
+ from app.models.job import Job
14
+ from app.models.job_queue import JobQueueEntry
15
+ from app.models.kv_store import KVStoreEntry
16
+ from app.models.merge_checklist import MergeChecklist
17
+ from app.models.normalized_log import NormalizedLogEntry
18
+ from app.models.planner_lock import PlannerLock
19
+ from app.models.rate_limit_entry import RateLimitEntry
20
+ from app.models.repo import Repo
21
+ from app.models.review_comment import ReviewComment
22
+ from app.models.review_summary import ReviewSummary
23
+ from app.models.revision import Revision
24
+ from app.models.ticket import Ticket
25
+ from app.models.ticket_event import TicketEvent
26
+ from app.models.user import User
27
+ from app.models.workspace import Workspace
28
+
29
+ __all__ = [
30
+ "AgentConversationHistory",
31
+ "AgentMessage",
32
+ "AgentSession",
33
+ "AnalysisCache",
34
+ "Base",
35
+ "Board",
36
+ "BoardRepo",
37
+ "CostBudget",
38
+ "Evidence",
39
+ "Goal",
40
+ "IdempotencyEntry",
41
+ "Job",
42
+ "JobQueueEntry",
43
+ "KVStoreEntry",
44
+ "MergeChecklist",
45
+ "NormalizedLogEntry",
46
+ "PlannerLock",
47
+ "RateLimitEntry",
48
+ "Repo",
49
+ "ReviewComment",
50
+ "ReviewSummary",
51
+ "Revision",
52
+ "Ticket",
53
+ "TicketEvent",
54
+ "User",
55
+ "Workspace",
56
+ ]
@@ -0,0 +1,56 @@
1
+ """Agent conversation history model for UDAR agent memory.
2
+
3
+ Stores compressed checkpoints (summaries, not full messages) to support
4
+ UDAR agent state persistence.
5
+ """
6
+
7
+ import uuid
8
+ from datetime import datetime
9
+
10
+ from sqlalchemy import Column, DateTime, ForeignKey, String, Text
11
+ from sqlalchemy.orm import relationship
12
+
13
+ from app.models.base import Base
14
+
15
+
16
+ class AgentConversationHistory(Base):
17
+ """Agent conversation history for UDAR memory.
18
+
19
+ This model stores compressed conversation checkpoints for the UDAR agent.
20
+ Instead of storing full LLM conversation history (expensive), it stores
21
+ only summaries and metadata (lean storage).
22
+
23
+ Attributes:
24
+ id: Unique identifier (ULID)
25
+ goal_id: Goal this conversation belongs to
26
+ checkpoint_id: Unique checkpoint identifier
27
+ messages_json: Empty by default (lean storage optimization)
28
+ metadata_json: Compressed summary (tickets proposed, reasoning summary, etc.)
29
+ created_at: When checkpoint was created
30
+ updated_at: When checkpoint was last updated
31
+ goal: Related Goal object
32
+ """
33
+
34
+ __tablename__ = "agent_conversation_history"
35
+
36
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
37
+ goal_id = Column(
38
+ String(36),
39
+ ForeignKey("goals.id", ondelete="CASCADE"),
40
+ nullable=False,
41
+ index=True,
42
+ )
43
+ checkpoint_id = Column(String(100), nullable=False)
44
+ messages_json = Column(Text, nullable=True) # Empty by default
45
+ metadata_json = Column(Text, nullable=False) # Compressed summary
46
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
47
+ updated_at = Column(
48
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
49
+ )
50
+
51
+ # Relationships
52
+ goal = relationship("Goal", back_populates="agent_conversation_history")
53
+
54
+ def __repr__(self) -> str:
55
+ """String representation."""
56
+ return f"<AgentConversationHistory(id={self.id}, goal_id={self.goal_id}, checkpoint={self.checkpoint_id})>"
@@ -0,0 +1,127 @@
1
+ """Agent session model for conversation continuity.
2
+
3
+ Tracks agent sessions to enable:
4
+ - Follow-up prompts within the same conversation
5
+ - Session resume after interruptions
6
+ - Cost tracking per session
7
+ """
8
+
9
+ from datetime import datetime
10
+ from uuid import uuid4
11
+
12
+ from sqlalchemy import (
13
+ JSON,
14
+ Boolean,
15
+ Column,
16
+ DateTime,
17
+ Float,
18
+ ForeignKey,
19
+ Integer,
20
+ String,
21
+ Text,
22
+ )
23
+ from sqlalchemy.orm import relationship
24
+
25
+ from app.models.base import Base
26
+
27
+
28
+ class AgentSession(Base):
29
+ """Tracks an AI agent conversation session."""
30
+
31
+ __tablename__ = "agent_sessions"
32
+
33
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
34
+ ticket_id = Column(
35
+ String(36),
36
+ ForeignKey("tickets.id", ondelete="CASCADE"),
37
+ nullable=False,
38
+ index=True,
39
+ )
40
+ job_id = Column(
41
+ String(36),
42
+ ForeignKey("jobs.id", ondelete="SET NULL"),
43
+ nullable=True,
44
+ index=True,
45
+ )
46
+
47
+ # Agent identification
48
+ agent_type = Column(String(50), nullable=False) # claude, amp, cursor, etc.
49
+ agent_session_id = Column(
50
+ String(255), nullable=True
51
+ ) # External session ID from agent
52
+
53
+ # Session state
54
+ is_active = Column(Boolean, default=True, nullable=False)
55
+ turn_count = Column(Integer, default=0, nullable=False)
56
+
57
+ # Token tracking for cost calculation
58
+ total_input_tokens = Column(Integer, default=0, nullable=False)
59
+ total_output_tokens = Column(Integer, default=0, nullable=False)
60
+ estimated_cost_usd = Column(Float, default=0.0, nullable=False)
61
+
62
+ # Last message for context
63
+ last_prompt = Column(Text, nullable=True)
64
+ last_response_summary = Column(Text, nullable=True)
65
+
66
+ # Metadata
67
+ metadata_ = Column("metadata", JSON, nullable=True) # Agent-specific metadata
68
+
69
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
70
+ updated_at = Column(
71
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
72
+ )
73
+ ended_at = Column(DateTime, nullable=True)
74
+
75
+ # Relationships
76
+ ticket = relationship("Ticket", back_populates="agent_sessions")
77
+ messages = relationship(
78
+ "AgentMessage", back_populates="session", cascade="all, delete-orphan"
79
+ )
80
+
81
+ def add_turn(self, input_tokens: int, output_tokens: int, cost: float = 0.0):
82
+ """Record a conversation turn."""
83
+ self.turn_count += 1
84
+ self.total_input_tokens += input_tokens
85
+ self.total_output_tokens += output_tokens
86
+ self.estimated_cost_usd += cost
87
+ self.updated_at = datetime.utcnow()
88
+
89
+ def end_session(self):
90
+ """Mark session as ended."""
91
+ self.is_active = False
92
+ self.ended_at = datetime.utcnow()
93
+
94
+
95
+ class AgentMessage(Base):
96
+ """Individual message in an agent session."""
97
+
98
+ __tablename__ = "agent_messages"
99
+
100
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
101
+ session_id = Column(
102
+ String(36),
103
+ ForeignKey("agent_sessions.id", ondelete="CASCADE"),
104
+ nullable=False,
105
+ index=True,
106
+ )
107
+
108
+ # Message content
109
+ role = Column(String(20), nullable=False) # user, assistant, system, tool
110
+ content = Column(Text, nullable=False)
111
+
112
+ # Token counts
113
+ input_tokens = Column(Integer, default=0, nullable=False)
114
+ output_tokens = Column(Integer, default=0, nullable=False)
115
+
116
+ # Tool use tracking
117
+ tool_name = Column(String(100), nullable=True)
118
+ tool_input = Column(JSON, nullable=True)
119
+ tool_output = Column(Text, nullable=True)
120
+
121
+ # Metadata
122
+ metadata_ = Column("metadata", JSON, nullable=True)
123
+
124
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
125
+
126
+ # Relationships
127
+ session = relationship("AgentSession", back_populates="messages")
@@ -0,0 +1,49 @@
1
+ """AnalysisCache model for caching codebase analysis results."""
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import DateTime, String, Text, func
6
+ from sqlalchemy.orm import Mapped, mapped_column
7
+
8
+ from app.models.base import Base
9
+
10
+
11
+ class AnalysisCache(Base):
12
+ """Cache table for codebase analysis results.
13
+
14
+ This provides idempotency for expensive LLM-based codebase analysis.
15
+ Entries expire after a configurable TTL (default 10 minutes).
16
+
17
+ The cache key is a hash of:
18
+ - Repository root path
19
+ - Focus areas (sorted)
20
+
21
+ This allows repeated analysis requests within the TTL window to
22
+ return cached results without making expensive LLM calls.
23
+ """
24
+
25
+ __tablename__ = "analysis_cache"
26
+
27
+ id: Mapped[str] = mapped_column(
28
+ String(64), # SHA256 hash (32 hex chars, but allowing extra)
29
+ primary_key=True,
30
+ )
31
+ result_json: Mapped[str] = mapped_column(
32
+ Text,
33
+ nullable=False,
34
+ doc="JSON-serialized analysis result",
35
+ )
36
+ created_at: Mapped[datetime] = mapped_column(
37
+ DateTime,
38
+ server_default=func.now(),
39
+ nullable=False,
40
+ )
41
+ expires_at: Mapped[datetime] = mapped_column(
42
+ DateTime,
43
+ nullable=False,
44
+ index=True,
45
+ doc="When this cache entry expires",
46
+ )
47
+
48
+ def __repr__(self) -> str:
49
+ return f"<AnalysisCache(id={self.id}, expires_at={self.expires_at})>"
@@ -0,0 +1,9 @@
1
+ """SQLAlchemy declarative base for all models."""
2
+
3
+ from sqlalchemy.orm import DeclarativeBase
4
+
5
+
6
+ class Base(DeclarativeBase):
7
+ """Base class for all SQLAlchemy models."""
8
+
9
+ pass