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,486 @@
1
+ """Draft Backend - FastAPI Application."""
2
+
3
+ import logging
4
+ import os
5
+ import traceback
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+
9
+ from dotenv import load_dotenv
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from starlette.middleware.base import BaseHTTPMiddleware
15
+
16
+ from app.database import init_db
17
+ from app.exceptions import (
18
+ ConfigurationError,
19
+ ConflictError,
20
+ DraftError,
21
+ InvalidStateTransitionError,
22
+ LLMAPIError,
23
+ ResourceNotFoundError,
24
+ ValidationError,
25
+ )
26
+ from app.middleware import (
27
+ IdempotencyMiddleware,
28
+ RateLimitMiddleware,
29
+ SecurityHeadersMiddleware,
30
+ TimeoutMiddleware,
31
+ )
32
+ from app.routers import (
33
+ board_legacy_router,
34
+ boards_router,
35
+ debug_router,
36
+ evidence_router,
37
+ goals_router,
38
+ jobs_router,
39
+ maintenance_router,
40
+ merge_router,
41
+ planner_router,
42
+ repos_router,
43
+ revisions_router,
44
+ tickets_router,
45
+ )
46
+ from app.routers.agents import router as agents_router
47
+ from app.routers.auth import router as auth_router
48
+ from app.routers.dashboard import router as dashboard_router
49
+ from app.routers.executors import router as executors_router
50
+ from app.routers.pull_requests import router as pull_requests_router
51
+ from app.routers.settings import router as settings_router
52
+ from app.routers.webhooks import router as webhooks_router
53
+ from app.routers.websocket import router as websocket_router
54
+
55
+ load_dotenv()
56
+
57
+ # Initialize Sentry error tracking (only if SENTRY_DSN is set)
58
+ _sentry_dsn = os.getenv("SENTRY_DSN")
59
+ if _sentry_dsn:
60
+ try:
61
+ import sentry_sdk
62
+ from sentry_sdk.integrations.fastapi import FastApiIntegration
63
+ from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
64
+
65
+ sentry_sdk.init(
66
+ dsn=_sentry_dsn,
67
+ environment=os.getenv("SENTRY_ENVIRONMENT", "development"),
68
+ traces_sample_rate=0.1,
69
+ integrations=[FastApiIntegration(), SqlalchemyIntegration()],
70
+ )
71
+ except ImportError:
72
+ pass # sentry-sdk not installed, skip
73
+
74
+ APP_NAME = "Draft"
75
+ APP_VERSION = "0.1.0"
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
81
+ """Limit request body size to prevent OOM attacks."""
82
+
83
+ def __init__(self, app, max_body_size: int = 10_000_000): # 10MB default
84
+ super().__init__(app)
85
+ self.max_body_size = max_body_size
86
+
87
+ async def dispatch(self, request: Request, call_next):
88
+ """Check content-length header before processing request."""
89
+ if request.method in ("POST", "PUT", "PATCH"):
90
+ content_length = request.headers.get("content-length")
91
+ if content_length and int(content_length) > self.max_body_size:
92
+ return JSONResponse(
93
+ status_code=413,
94
+ content={
95
+ "detail": f"Request body too large. Max size: {self.max_body_size / 1_000_000:.1f}MB",
96
+ "error_type": "payload_too_large",
97
+ },
98
+ )
99
+ return await call_next(request)
100
+
101
+
102
+ @asynccontextmanager
103
+ async def lifespan(app: FastAPI):
104
+ """Application lifespan manager - initializes database on startup."""
105
+ # Startup: Initialize database tables
106
+ await init_db()
107
+
108
+ # Start in-process background worker
109
+ from app.services.sqlite_worker import setup_worker
110
+
111
+ worker = setup_worker()
112
+ worker.start()
113
+ logger.info("Background worker started")
114
+
115
+ yield
116
+
117
+ # Shutdown
118
+ worker.stop()
119
+ logger.info("Application shutdown complete")
120
+
121
+
122
+ app = FastAPI(
123
+ title=APP_NAME,
124
+ version=APP_VERSION,
125
+ description="A local-first Draft application with state machine workflow",
126
+ lifespan=lifespan,
127
+ )
128
+
129
+ # Security headers (add first, applies to all responses)
130
+ app.add_middleware(SecurityHeadersMiddleware)
131
+
132
+ # Request timeout (600s global timeout, needed for long-running analysis)
133
+ app.add_middleware(TimeoutMiddleware, timeout_seconds=600)
134
+
135
+ # CORS configuration — supports both dev (vite on :5173) and production (same origin)
136
+ _frontend_url = os.getenv("FRONTEND_URL")
137
+ if not _frontend_url:
138
+ _frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
139
+ if _frontend_dist.exists():
140
+ _backend_port = os.getenv("PORT", "8000")
141
+ _frontend_url = f"http://localhost:{_backend_port}"
142
+ else:
143
+ _frontend_url = "http://localhost:5173"
144
+
145
+ app.add_middleware(
146
+ CORSMiddleware,
147
+ allow_origins=[_frontend_url, "http://localhost:5173", "http://localhost:8000"],
148
+ allow_credentials=True,
149
+ allow_methods=["*"],
150
+ allow_headers=["*"],
151
+ )
152
+
153
+ # Request size limits (prevent DoS)
154
+ app.add_middleware(RequestSizeLimitMiddleware, max_body_size=10_000_000)
155
+
156
+ # Rate limiting for LLM endpoints (10 req/min)
157
+ app.add_middleware(RateLimitMiddleware)
158
+
159
+ # Idempotency support for expensive operations
160
+ app.add_middleware(IdempotencyMiddleware)
161
+
162
+
163
+ # Exception handlers
164
+ @app.exception_handler(ResourceNotFoundError)
165
+ async def resource_not_found_handler(
166
+ request: Request, exc: ResourceNotFoundError
167
+ ) -> JSONResponse:
168
+ """Handle resource not found errors."""
169
+ return JSONResponse(
170
+ status_code=404,
171
+ content={
172
+ "detail": exc.message,
173
+ "error_type": "resource_not_found",
174
+ },
175
+ )
176
+
177
+
178
+ @app.exception_handler(InvalidStateTransitionError)
179
+ async def invalid_transition_handler(
180
+ request: Request, exc: InvalidStateTransitionError
181
+ ) -> JSONResponse:
182
+ """Handle invalid state transition errors."""
183
+ return JSONResponse(
184
+ status_code=400,
185
+ content={
186
+ "detail": exc.message,
187
+ "error_type": "invalid_state_transition",
188
+ "from_state": exc.from_state,
189
+ "to_state": exc.to_state,
190
+ },
191
+ )
192
+
193
+
194
+ @app.exception_handler(ValidationError)
195
+ async def validation_error_handler(
196
+ request: Request, exc: ValidationError
197
+ ) -> JSONResponse:
198
+ """Handle validation errors."""
199
+ return JSONResponse(
200
+ status_code=422,
201
+ content={
202
+ "detail": exc.message,
203
+ "error_type": "validation_error",
204
+ },
205
+ )
206
+
207
+
208
+ @app.exception_handler(ConflictError)
209
+ async def conflict_error_handler(request: Request, exc: ConflictError) -> JSONResponse:
210
+ """Handle conflict errors (e.g., duplicate operations, stale state)."""
211
+ return JSONResponse(
212
+ status_code=409,
213
+ content={
214
+ "detail": exc.message,
215
+ "error_type": "conflict",
216
+ },
217
+ )
218
+
219
+
220
+ @app.exception_handler(ConfigurationError)
221
+ async def configuration_error_handler(
222
+ request: Request, exc: ConfigurationError
223
+ ) -> JSONResponse:
224
+ """Handle configuration errors (e.g., missing API keys)."""
225
+ return JSONResponse(
226
+ status_code=400,
227
+ content={
228
+ "detail": exc.message,
229
+ "error_type": "configuration_error",
230
+ },
231
+ )
232
+
233
+
234
+ @app.exception_handler(LLMAPIError)
235
+ async def llm_api_error_handler(request: Request, exc: LLMAPIError) -> JSONResponse:
236
+ """Handle LLM API errors."""
237
+ return JSONResponse(
238
+ status_code=502,
239
+ content={
240
+ "detail": exc.message,
241
+ "error_type": "llm_api_error",
242
+ "provider": exc.provider,
243
+ },
244
+ )
245
+
246
+
247
+ @app.exception_handler(DraftError)
248
+ async def smart_kanban_error_handler(request: Request, exc: DraftError) -> JSONResponse:
249
+ """Handle generic Draft errors."""
250
+ return JSONResponse(
251
+ status_code=500,
252
+ content={
253
+ "detail": str(exc),
254
+ "error_type": "internal_error",
255
+ },
256
+ )
257
+
258
+
259
+ @app.exception_handler(Exception)
260
+ async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
261
+ """Catch-all handler that prevents information leakage (SECURITY).
262
+
263
+ In production, sanitizes errors to prevent exposing internal details.
264
+ In development, includes full traceback for debugging.
265
+ """
266
+ # Log full error internally (for debugging/monitoring)
267
+ logger.error(
268
+ f"Unhandled exception in {request.method} {request.url.path}",
269
+ exc_info=exc,
270
+ extra={
271
+ "path": request.url.path,
272
+ "method": request.method,
273
+ "client": request.client.host if request.client else None,
274
+ },
275
+ )
276
+
277
+ # Return sanitized error to client
278
+ if os.getenv("APP_ENV") == "production":
279
+ # PRODUCTION: Never expose internal details
280
+ return JSONResponse(
281
+ status_code=500,
282
+ content={
283
+ "detail": "Internal server error",
284
+ "error_type": "internal_error",
285
+ # NO stack trace or exception details
286
+ },
287
+ )
288
+ else:
289
+ # DEVELOPMENT: Include details for debugging
290
+ return JSONResponse(
291
+ status_code=500,
292
+ content={
293
+ "detail": str(exc),
294
+ "error_type": type(exc).__name__,
295
+ "traceback": traceback.format_exc(),
296
+ },
297
+ )
298
+
299
+
300
+ # Health check endpoints (for monitoring/load balancers)
301
+ @app.get("/health", tags=["monitoring"])
302
+ async def healthcheck() -> JSONResponse:
303
+ """Health check endpoint for load balancers and monitoring.
304
+
305
+ Checks:
306
+ - Basic service availability
307
+
308
+ Returns 200 OK if service is running.
309
+ """
310
+ from datetime import UTC, datetime
311
+
312
+ return JSONResponse(
313
+ status_code=200,
314
+ content={
315
+ "status": "healthy",
316
+ "timestamp": datetime.now(UTC).isoformat(),
317
+ "service": APP_NAME,
318
+ "version": APP_VERSION,
319
+ },
320
+ )
321
+
322
+
323
+ @app.get("/health/detailed", tags=["monitoring"])
324
+ async def healthcheck_detailed(request: Request) -> JSONResponse:
325
+ """Detailed health check with dependency checks.
326
+
327
+ Checks:
328
+ - Database connectivity
329
+ - Redis connectivity
330
+ - Disk space
331
+
332
+ Returns 200 if all healthy, 503 if any component unhealthy.
333
+ """
334
+ from datetime import UTC, datetime, timedelta
335
+
336
+ from sqlalchemy import func, select
337
+
338
+ from app.database import get_db
339
+
340
+ checks = {
341
+ "status": "healthy",
342
+ "timestamp": datetime.now(UTC).isoformat(),
343
+ "service": APP_NAME,
344
+ "version": APP_VERSION,
345
+ "checks": {},
346
+ }
347
+
348
+ # Check database
349
+ try:
350
+ async for db in get_db():
351
+ await db.execute(select(1))
352
+ checks["checks"]["database"] = "ok"
353
+ break
354
+ except Exception as e:
355
+ checks["status"] = "unhealthy"
356
+ checks["checks"]["database"] = f"error: {str(e)}"
357
+ logger.error(f"Database health check failed: {e}")
358
+
359
+ # Check disk space
360
+ try:
361
+ import shutil
362
+
363
+ stat = shutil.disk_usage("/")
364
+ free_percent = (stat.free / stat.total) * 100
365
+ checks["checks"]["disk_space"] = f"{free_percent:.1f}% free"
366
+ if free_percent < 10:
367
+ checks["status"] = "degraded"
368
+ logger.warning(f"Low disk space: {free_percent:.1f}% free")
369
+ except Exception as e:
370
+ checks["checks"]["disk_space"] = f"error: {str(e)}"
371
+ logger.error(f"Disk space check failed: {e}")
372
+
373
+ # Check worker health
374
+ try:
375
+ from app.services.sqlite_worker import _worker
376
+
377
+ worker_running = _worker is not None and _worker._running
378
+ checks["checks"]["worker"] = "running" if worker_running else "stopped"
379
+ if not worker_running:
380
+ checks["status"] = "degraded"
381
+ except Exception as e:
382
+ checks["checks"]["worker"] = f"error: {str(e)}"
383
+
384
+ # Check last planner tick time
385
+ try:
386
+ async for db in get_db():
387
+ from app.models.planner_lock import PlannerLock
388
+
389
+ lock_result = await db.execute(
390
+ select(PlannerLock).where(PlannerLock.lock_key == "planner_tick")
391
+ )
392
+ lock = lock_result.scalar_one_or_none()
393
+ if lock:
394
+ checks["checks"]["planner_lock"] = {
395
+ "held": True,
396
+ "acquired_at": lock.acquired_at.isoformat()
397
+ if lock.acquired_at
398
+ else None,
399
+ }
400
+ else:
401
+ checks["checks"]["planner_lock"] = {"held": False}
402
+
403
+ # Count stuck jobs (RUNNING longer than 30 minutes)
404
+ from app.models.job import Job, JobStatus
405
+
406
+ thirty_min_ago = datetime.now(UTC) - timedelta(minutes=30)
407
+ stuck_result = await db.execute(
408
+ select(func.count(Job.id)).where(
409
+ Job.status == JobStatus.RUNNING.value,
410
+ Job.started_at < thirty_min_ago,
411
+ )
412
+ )
413
+ stuck_count = stuck_result.scalar() or 0
414
+ checks["checks"]["stuck_jobs"] = stuck_count
415
+ if stuck_count > 0:
416
+ checks["status"] = "degraded"
417
+ break
418
+ except Exception as e:
419
+ checks["checks"]["worker_details"] = f"error: {str(e)}"
420
+
421
+ status_code = 200 if checks["status"] == "healthy" else 503
422
+ return JSONResponse(content=checks, status_code=status_code)
423
+
424
+
425
+ @app.get("/readiness", tags=["monitoring"])
426
+ async def readiness() -> JSONResponse:
427
+ """Readiness check for Kubernetes/container orchestration."""
428
+ return JSONResponse(status_code=200, content={"status": "ready"})
429
+
430
+
431
+ @app.get("/liveness", tags=["monitoring"])
432
+ async def liveness() -> JSONResponse:
433
+ """Liveness check for Kubernetes/container orchestration."""
434
+ return JSONResponse(status_code=200, content={"status": "alive"})
435
+
436
+
437
+ # Include routers
438
+ app.include_router(auth_router) # User registration and login
439
+ app.include_router(goals_router)
440
+ app.include_router(tickets_router)
441
+ app.include_router(boards_router) # New multi-board endpoints (/boards/...)
442
+ app.include_router(board_legacy_router) # Legacy kanban view (/board)
443
+ app.include_router(repos_router) # Repository discovery and management
444
+ app.include_router(jobs_router)
445
+ app.include_router(evidence_router)
446
+ app.include_router(planner_router)
447
+ app.include_router(revisions_router)
448
+ app.include_router(merge_router)
449
+ app.include_router(maintenance_router)
450
+ # Debug endpoints only available in development mode
451
+ if os.getenv("APP_ENV", "development") == "development":
452
+ app.include_router(debug_router)
453
+ app.include_router(agents_router) # AI agent management
454
+ app.include_router(dashboard_router) # Sprint dashboard and metrics
455
+ app.include_router(executors_router) # Executor plugin management
456
+ app.include_router(settings_router) # Global settings (draft.yaml)
457
+ app.include_router(websocket_router) # WebSocket real-time updates
458
+ app.include_router(pull_requests_router) # GitHub PR integration
459
+ app.include_router(webhooks_router) # Webhook notifications for ticket changes
460
+
461
+
462
+ @app.get("/version")
463
+ async def get_version():
464
+ """Return application name and version."""
465
+ return {"app": APP_NAME, "version": APP_VERSION}
466
+
467
+
468
+ # Serve pre-built frontend (production / npx mode).
469
+ # Must be AFTER all API routes so /health, /api/*, /ws/* take priority.
470
+ _frontend_dist_path = Path(__file__).parent.parent / "frontend" / "dist"
471
+ if _frontend_dist_path.exists():
472
+ # Serve static assets (js, css, images)
473
+ app.mount(
474
+ "/assets",
475
+ StaticFiles(directory=_frontend_dist_path / "assets"),
476
+ name="frontend-assets",
477
+ )
478
+
479
+ # SPA catch-all: serve index.html for all non-API, non-asset routes
480
+ @app.get("/{full_path:path}", include_in_schema=False)
481
+ async def serve_spa(full_path: str):
482
+ """Serve the SPA index.html for client-side routing."""
483
+ file_path = _frontend_dist_path / full_path
484
+ if file_path.is_file():
485
+ return FileResponse(file_path)
486
+ return FileResponse(_frontend_dist_path / "index.html")
@@ -0,0 +1,13 @@
1
+ """Middleware for the Kanban API."""
2
+
3
+ from app.middleware.idempotency import IdempotencyMiddleware
4
+ from app.middleware.rate_limit import RateLimitMiddleware
5
+ from app.middleware.security_headers import SecurityHeadersMiddleware
6
+ from app.middleware.timeout import TimeoutMiddleware
7
+
8
+ __all__ = [
9
+ "IdempotencyMiddleware",
10
+ "RateLimitMiddleware",
11
+ "SecurityHeadersMiddleware",
12
+ "TimeoutMiddleware",
13
+ ]