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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- 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
|
+
]
|