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,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})>"
|