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,426 @@
1
+ """Idempotency middleware with pluggable backend (Redis or SQLite).
2
+
3
+ Guarantees exactly-once execution for LLM operations.
4
+
5
+ Behavior Contract (Deterministic):
6
+ 1. First request: acquires lock, executes, stores result with execution_id
7
+ 2. Concurrent requests: blocking wait up to WAIT_TIMEOUT_SECONDS for result
8
+ 3. If result appears within timeout: return it with X-Idempotency-Replayed
9
+ 4. If timeout: return 202 Accepted with execution_id for polling
10
+ 5. If same key + different body: return 409 Conflict
11
+
12
+ Key structure includes resource scope to prevent cross-goal/board collisions:
13
+ (client_id, route, resource_scope, idempotency_key)
14
+
15
+ Scope Precedence Rules (strict):
16
+ 1. Path param (e.g., goal_id from /goals/{goal_id}/...) takes precedence
17
+ 2. Body param used only if no path param
18
+ 3. If both exist and DIFFER: return 400 scope_mismatch
19
+ """
20
+
21
+ import asyncio
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ import time
26
+ import uuid
27
+ from typing import Any
28
+
29
+ from fastapi import Request, Response
30
+ from starlette.middleware.base import BaseHTTPMiddleware
31
+ from starlette.responses import JSONResponse
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Endpoints that support idempotency (expensive LLM operations that mutate state)
36
+ IDEMPOTENT_ENDPOINTS = {
37
+ "/goals/{goal_id}/generate-tickets",
38
+ "/goals/{goal_id}/reflect-on-tickets",
39
+ "/boards/{board_id}/analyze-codebase",
40
+ "/board/analyze-codebase", # Legacy
41
+ "/tickets/bulk-update-priority",
42
+ }
43
+
44
+ # TTL for cached responses (10 minutes)
45
+ CACHE_TTL_SECONDS = 600
46
+
47
+ # TTL for processing lock (2 minutes - should be enough for any LLM call)
48
+ LOCK_TTL_SECONDS = 120
49
+
50
+ # Blocking wait timeout (10 seconds max)
51
+ WAIT_TIMEOUT_SECONDS = 10
52
+ POLL_INTERVAL_MS = 100
53
+
54
+ # Redis key prefixes
55
+ REDIS_LOCK_PREFIX = "idemp:lock:"
56
+ REDIS_RESULT_PREFIX = "idemp:result:"
57
+
58
+
59
+ def _matches_pattern(path: str, patterns: set[str]) -> tuple[bool, dict[str, str]]:
60
+ """Check if path matches any pattern.
61
+
62
+ Returns (matches, extracted_params) where extracted_params contains
63
+ path parameters like {goal_id} -> actual value.
64
+ """
65
+ for pattern in patterns:
66
+ pattern_parts = pattern.split("/")
67
+ path_parts = path.split("/")
68
+
69
+ if len(pattern_parts) != len(path_parts):
70
+ continue
71
+
72
+ match = True
73
+ params = {}
74
+ for p_part, path_part in zip(pattern_parts, path_parts, strict=False):
75
+ if p_part.startswith("{") and p_part.endswith("}"):
76
+ param_name = p_part[1:-1]
77
+ params[param_name] = path_part
78
+ elif p_part != path_part:
79
+ match = False
80
+ break
81
+
82
+ if match:
83
+ return True, params
84
+ return False, {}
85
+
86
+
87
+ def _get_client_id(request: Request) -> str:
88
+ """Get client identifier for idempotency scoping."""
89
+ client_id = request.headers.get("X-Client-ID")
90
+ if client_id and len(client_id) <= 64:
91
+ return client_id
92
+
93
+ forwarded = request.headers.get("X-Forwarded-For")
94
+ if forwarded:
95
+ return f"ip:{forwarded.split(',')[0].strip()}"
96
+
97
+ return f"ip:{request.client.host if request.client else 'unknown'}"
98
+
99
+
100
+ def _compute_body_hash(body: bytes) -> str:
101
+ """Compute a hash of the request body."""
102
+ return hashlib.sha256(body).hexdigest()[:16]
103
+
104
+
105
+ def _extract_resource_scope(
106
+ path: str, body_dict: dict[str, Any], path_params: dict[str, str]
107
+ ) -> tuple[str, str | None]:
108
+ """Extract resource scope with strict precedence rules.
109
+
110
+ Returns:
111
+ (scope_string, error_message)
112
+ - If error_message is not None, there's a scope mismatch
113
+
114
+ Precedence:
115
+ 1. Path param takes precedence
116
+ 2. Body param used only if no path param
117
+ 3. If both exist and DIFFER: return error
118
+ """
119
+ path_goal_id = path_params.get("goal_id")
120
+ path_board_id = path_params.get("board_id")
121
+ body_goal_id = body_dict.get("goal_id")
122
+ body_board_id = body_dict.get("board_id")
123
+
124
+ # Check for scope mismatch (path vs body)
125
+ if path_goal_id and body_goal_id:
126
+ if path_goal_id != body_goal_id:
127
+ return (
128
+ "",
129
+ f"Scope mismatch: path goal_id '{path_goal_id}' differs from body goal_id '{body_goal_id}'",
130
+ )
131
+
132
+ if path_board_id and body_board_id:
133
+ if path_board_id != body_board_id:
134
+ return (
135
+ "",
136
+ f"Scope mismatch: path board_id '{path_board_id}' differs from body board_id '{body_board_id}'",
137
+ )
138
+
139
+ # Path takes precedence
140
+ if path_goal_id:
141
+ return f"goal:{path_goal_id}", None
142
+ if path_board_id:
143
+ return f"board:{path_board_id}", None
144
+
145
+ # Fall back to body
146
+ if body_goal_id:
147
+ return f"goal:{body_goal_id}", None
148
+ if body_board_id:
149
+ return f"board:{body_board_id}", None
150
+
151
+ # Board-level endpoints without explicit ID
152
+ if "/board/" in path or "/boards/" in path:
153
+ return "board:default", None
154
+
155
+ return "global", None
156
+
157
+
158
+ def _generate_execution_id() -> str:
159
+ """Generate a unique execution ID for tracking."""
160
+ return str(uuid.uuid4())
161
+
162
+
163
+ def _backend_available() -> bool:
164
+ """Check if the idempotency backend is available."""
165
+ return True # SQLite is always available
166
+
167
+
168
+ class IdempotencyMiddleware(BaseHTTPMiddleware):
169
+ """Atomic idempotency middleware using Redis SETNX or SQLite INSERT OR IGNORE.
170
+
171
+ Guarantees exactly-once execution with deterministic behavior:
172
+ 1. Try to acquire lock atomically
173
+ 2. If acquired: execute request, store result with execution_id
174
+ 3. If not acquired: blocking wait up to WAIT_TIMEOUT_SECONDS
175
+ 4. If result appears: return with X-Idempotency-Replayed
176
+ 5. If timeout: return 202 with execution_id for polling
177
+ 6. If body mismatch: return 409 Conflict
178
+ """
179
+
180
+ async def dispatch(self, request: Request, call_next) -> Response:
181
+ # Only handle POST requests to specific endpoints
182
+ if request.method != "POST":
183
+ return await call_next(request)
184
+
185
+ matches, path_params = _matches_pattern(request.url.path, IDEMPOTENT_ENDPOINTS)
186
+ if not matches:
187
+ return await call_next(request)
188
+
189
+ # Check for idempotency key header
190
+ idempotency_key = request.headers.get("Idempotency-Key")
191
+ if not idempotency_key:
192
+ # No key provided - still require backend for these endpoints
193
+ if not _backend_available():
194
+ return self._service_unavailable()
195
+ return await call_next(request)
196
+
197
+ # Validate key format
198
+ if len(idempotency_key) > 64:
199
+ return JSONResponse(
200
+ status_code=400,
201
+ content={"detail": "Idempotency-Key too long (max 64 chars)"},
202
+ )
203
+
204
+ # Backend REQUIRED - NO fallback
205
+ if not _backend_available():
206
+ return self._service_unavailable()
207
+
208
+ client_id = _get_client_id(request)
209
+
210
+ # Read request body
211
+ body = await request.body()
212
+ body_hash = _compute_body_hash(body)
213
+
214
+ # Parse body for scope extraction
215
+ try:
216
+ body_dict = json.loads(body) if body else {}
217
+ except json.JSONDecodeError:
218
+ body_dict = {}
219
+
220
+ # Extract scope with strict precedence
221
+ resource_scope, scope_error = _extract_resource_scope(
222
+ request.url.path, body_dict, path_params
223
+ )
224
+ if scope_error:
225
+ return JSONResponse(
226
+ status_code=400,
227
+ content={
228
+ "detail": scope_error,
229
+ "error_type": "scope_mismatch",
230
+ },
231
+ )
232
+
233
+ # Build cache key
234
+ base_key = f"{client_id}:{request.url.path}:{resource_scope}:{idempotency_key}"
235
+
236
+ # Generate execution_id for this attempt
237
+ execution_id = _generate_execution_id()
238
+
239
+ try:
240
+ return await self._dispatch_sqlite(
241
+ request,
242
+ call_next,
243
+ body,
244
+ body_hash,
245
+ base_key,
246
+ idempotency_key,
247
+ execution_id,
248
+ )
249
+
250
+ except Exception as e:
251
+ logger.error(f"Idempotency error: {e}")
252
+ return JSONResponse(
253
+ status_code=503,
254
+ content={
255
+ "detail": "Service temporarily unavailable due to cache error.",
256
+ "error_type": "service_unavailable",
257
+ },
258
+ )
259
+
260
+ # ─── SQLite backend ───
261
+
262
+ async def _dispatch_sqlite(
263
+ self,
264
+ request,
265
+ call_next,
266
+ body,
267
+ body_hash,
268
+ base_key,
269
+ idempotency_key,
270
+ execution_id,
271
+ ) -> Response:
272
+ from app.sqlite_kv import idempotency_try_acquire
273
+
274
+ lock_value = json.dumps(
275
+ {
276
+ "body_hash": body_hash,
277
+ "execution_id": execution_id,
278
+ "started_at": time.time(),
279
+ }
280
+ )
281
+
282
+ acquired = await asyncio.to_thread(
283
+ idempotency_try_acquire, base_key, lock_value, LOCK_TTL_SECONDS
284
+ )
285
+
286
+ if acquired:
287
+ return await self._execute_and_cache_sqlite(
288
+ request,
289
+ call_next,
290
+ body,
291
+ body_hash,
292
+ base_key,
293
+ idempotency_key,
294
+ execution_id,
295
+ )
296
+ else:
297
+ return await self._blocking_wait_sqlite(
298
+ base_key, body_hash, idempotency_key, execution_id
299
+ )
300
+
301
+ async def _execute_and_cache_sqlite(
302
+ self,
303
+ request,
304
+ call_next,
305
+ body,
306
+ body_hash,
307
+ cache_key,
308
+ idempotency_key,
309
+ execution_id,
310
+ ) -> Response:
311
+ from app.sqlite_kv import idempotency_release_lock, idempotency_store_result
312
+
313
+ async def receive():
314
+ return {"type": "http.request", "body": body}
315
+
316
+ request._receive = receive
317
+
318
+ try:
319
+ response = await call_next(request)
320
+
321
+ response_body = b""
322
+ async for chunk in response.body_iterator:
323
+ response_body += chunk
324
+
325
+ result_data = json.dumps(
326
+ {
327
+ "status_code": response.status_code,
328
+ "body": response_body.decode("utf-8"),
329
+ "body_hash": body_hash,
330
+ "execution_id": execution_id,
331
+ "completed_at": time.time(),
332
+ }
333
+ )
334
+ await asyncio.to_thread(
335
+ idempotency_store_result, cache_key, result_data, CACHE_TTL_SECONDS
336
+ )
337
+
338
+ logger.debug(
339
+ f"Executed and cached for key: {idempotency_key[:8]}... exec_id: {execution_id[:8]}..."
340
+ )
341
+
342
+ return Response(
343
+ content=response_body,
344
+ status_code=response.status_code,
345
+ media_type="application/json",
346
+ headers={"X-Execution-ID": execution_id},
347
+ )
348
+
349
+ except Exception:
350
+ await asyncio.to_thread(idempotency_release_lock, cache_key)
351
+ raise
352
+
353
+ async def _blocking_wait_sqlite(
354
+ self,
355
+ cache_key,
356
+ body_hash,
357
+ idempotency_key,
358
+ our_execution_id,
359
+ ) -> Response:
360
+ from app.sqlite_kv import idempotency_get_lock, idempotency_get_result
361
+
362
+ start_time = time.time()
363
+ original_execution_id: str | None = None
364
+
365
+ while time.time() - start_time < WAIT_TIMEOUT_SECONDS:
366
+ result_data = await asyncio.to_thread(idempotency_get_result, cache_key)
367
+ if result_data:
368
+ cached = json.loads(result_data)
369
+ if cached.get("body_hash") != body_hash:
370
+ return JSONResponse(
371
+ status_code=409,
372
+ content={
373
+ "detail": "Idempotency key already used with different request body",
374
+ "error_type": "idempotency_conflict",
375
+ "original_execution_id": cached.get("execution_id"),
376
+ },
377
+ )
378
+ return Response(
379
+ content=cached["body"].encode()
380
+ if isinstance(cached["body"], str)
381
+ else cached["body"],
382
+ status_code=cached["status_code"],
383
+ media_type="application/json",
384
+ headers={
385
+ "X-Idempotency-Replayed": "true",
386
+ "X-Execution-ID": cached.get("execution_id", "unknown"),
387
+ },
388
+ )
389
+
390
+ lock_data = await asyncio.to_thread(idempotency_get_lock, cache_key)
391
+ if not lock_data:
392
+ # Lock released but no result - original failed, allow retry
393
+ break
394
+ try:
395
+ lock_info = json.loads(lock_data)
396
+ original_execution_id = lock_info.get("execution_id")
397
+ except (json.JSONDecodeError, TypeError):
398
+ original_execution_id = "unknown"
399
+
400
+ await asyncio.sleep(POLL_INTERVAL_MS / 1000)
401
+
402
+ return JSONResponse(
403
+ status_code=202,
404
+ content={
405
+ "detail": "Request is being processed. Poll for result using execution_id.",
406
+ "error_type": "processing",
407
+ "execution_id": original_execution_id or our_execution_id,
408
+ "retry_after_seconds": 2,
409
+ },
410
+ headers={
411
+ "Retry-After": "2",
412
+ "X-Execution-ID": original_execution_id or our_execution_id,
413
+ },
414
+ )
415
+
416
+ def _service_unavailable(self) -> JSONResponse:
417
+ """Return 503 when backend is unavailable."""
418
+ return JSONResponse(
419
+ status_code=503,
420
+ content={
421
+ "detail": "Service temporarily unavailable. Backend is required for this operation.",
422
+ "error_type": "service_unavailable",
423
+ "retry_after_seconds": 30,
424
+ },
425
+ headers={"Retry-After": "30"},
426
+ )