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,136 @@
1
+ """Database retry decorator for SQLite BUSY errors.
2
+
3
+ SQLite has concurrency limitations and can throw BUSY errors when multiple
4
+ processes/threads access the database. This module provides retry logic
5
+ with exponential backoff.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import sqlite3
11
+ from collections.abc import Callable
12
+ from functools import wraps
13
+ from typing import TypeVar
14
+
15
+ from sqlalchemy.exc import OperationalError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def with_db_retry(
23
+ max_retries: int = 3,
24
+ base_backoff: float = 0.1,
25
+ max_backoff: float = 2.0,
26
+ ):
27
+ """Decorator to retry async DB operations on SQLite BUSY errors.
28
+
29
+ Args:
30
+ max_retries: Maximum number of retry attempts
31
+ base_backoff: Base backoff time in seconds
32
+ max_backoff: Maximum backoff time in seconds
33
+
34
+ Usage:
35
+ @with_db_retry(max_retries=3)
36
+ async def my_db_operation(self, ...):
37
+ # ... database operations ...
38
+ await self.db.commit()
39
+ """
40
+
41
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
42
+ @wraps(func)
43
+ async def wrapper(*args, **kwargs):
44
+ last_exception = None
45
+
46
+ for attempt in range(max_retries):
47
+ try:
48
+ return await func(*args, **kwargs)
49
+ except (sqlite3.OperationalError, OperationalError) as e:
50
+ last_exception = e
51
+ error_msg = str(e).lower()
52
+
53
+ # Only retry on BUSY/locked errors
54
+ if "database is locked" in error_msg or "busy" in error_msg:
55
+ if attempt < max_retries - 1:
56
+ # Exponential backoff with cap
57
+ wait = min(base_backoff * (2**attempt), max_backoff)
58
+ logger.warning(
59
+ f"DB locked in {func.__name__}, "
60
+ f"retry {attempt + 1}/{max_retries} after {wait:.2f}s"
61
+ )
62
+ await asyncio.sleep(wait)
63
+ continue
64
+ else:
65
+ logger.error(
66
+ f"DB locked in {func.__name__}, "
67
+ f"exhausted {max_retries} retries"
68
+ )
69
+ # For other OperationalErrors, don't retry
70
+ raise
71
+
72
+ # If we get here, all retries exhausted
73
+ raise last_exception
74
+
75
+ return wrapper
76
+
77
+ return decorator
78
+
79
+
80
+ def with_db_retry_sync(
81
+ max_retries: int = 3,
82
+ base_backoff: float = 0.1,
83
+ max_backoff: float = 2.0,
84
+ ):
85
+ """Decorator to retry sync DB operations on SQLite BUSY errors.
86
+
87
+ Args:
88
+ max_retries: Maximum number of retry attempts
89
+ base_backoff: Base backoff time in seconds
90
+ max_backoff: Maximum backoff time in seconds
91
+
92
+ Usage:
93
+ @with_db_retry_sync(max_retries=3)
94
+ def my_db_operation(self, ...):
95
+ # ... database operations ...
96
+ db.commit()
97
+ """
98
+ import time
99
+
100
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
101
+ @wraps(func)
102
+ def wrapper(*args, **kwargs):
103
+ last_exception = None
104
+
105
+ for attempt in range(max_retries):
106
+ try:
107
+ return func(*args, **kwargs)
108
+ except (sqlite3.OperationalError, OperationalError) as e:
109
+ last_exception = e
110
+ error_msg = str(e).lower()
111
+
112
+ # Only retry on BUSY/locked errors
113
+ if "database is locked" in error_msg or "busy" in error_msg:
114
+ if attempt < max_retries - 1:
115
+ # Exponential backoff with cap
116
+ wait = min(base_backoff * (2**attempt), max_backoff)
117
+ logger.warning(
118
+ f"DB locked in {func.__name__}, "
119
+ f"retry {attempt + 1}/{max_retries} after {wait:.2f}s"
120
+ )
121
+ time.sleep(wait)
122
+ continue
123
+ else:
124
+ logger.error(
125
+ f"DB locked in {func.__name__}, "
126
+ f"exhausted {max_retries} retries"
127
+ )
128
+ # For other OperationalErrors, don't retry
129
+ raise
130
+
131
+ # If we get here, all retries exhausted
132
+ raise last_exception
133
+
134
+ return wrapper
135
+
136
+ return decorator
@@ -0,0 +1,123 @@
1
+ """Utility for tracking and logging ignored request fields.
2
+
3
+ SECURITY: Only echo KNOWN deprecated fields in X-Ignored-Fields header.
4
+ Arbitrary unknown fields are logged internally but NOT echoed to client
5
+ (prevents using this as an echo channel).
6
+
7
+ When deprecated fields are sent:
8
+ 1. Add X-Ignored-Fields header with ONLY known deprecated fields
9
+ 2. Log once per client_id per day (avoid spam)
10
+ 3. Unknown fields: log internally only, do NOT echo
11
+ """
12
+
13
+ import logging
14
+ from datetime import date
15
+ from typing import Any
16
+
17
+ from fastapi import Request, Response
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Track which clients have been warned today: {(client_id, field): date}
22
+ _warned_today: dict[tuple[str, str], date] = {}
23
+
24
+ # ALLOWLIST: Only these deprecated fields are echoed in X-Ignored-Fields
25
+ # This prevents using the header as an echo channel for arbitrary data
26
+ KNOWN_DEPRECATED_FIELDS = frozenset(
27
+ {
28
+ "workspace_path", # Removed for security - use board.repo_root instead
29
+ "repo_path", # Alias for workspace_path
30
+ }
31
+ )
32
+
33
+
34
+ def get_client_id(request: Request) -> str:
35
+ """Get client identifier from request."""
36
+ client_id = request.headers.get("X-Client-ID")
37
+ if client_id and len(client_id) <= 64:
38
+ return client_id
39
+ forwarded = request.headers.get("X-Forwarded-For")
40
+ if forwarded:
41
+ return f"ip:{forwarded.split(',')[0].strip()}"
42
+ return f"ip:{request.client.host if request.client else 'unknown'}"
43
+
44
+
45
+ def check_ignored_fields(
46
+ request: Request,
47
+ raw_body: dict[str, Any],
48
+ allowed_fields: set[str],
49
+ ) -> list[str]:
50
+ """Check for ignored/deprecated fields in request body.
51
+
52
+ SECURITY: Only KNOWN_DEPRECATED_FIELDS are returned for echoing.
53
+ Unknown extra fields are logged internally but NOT returned.
54
+
55
+ Args:
56
+ request: The FastAPI request
57
+ raw_body: Parsed request body dict
58
+ allowed_fields: Fields that are actually used by the endpoint
59
+
60
+ Returns:
61
+ List of KNOWN deprecated field names that were sent (safe to echo)
62
+ """
63
+ if not raw_body:
64
+ return []
65
+
66
+ sent_fields = set(raw_body.keys())
67
+ all_ignored = sent_fields - allowed_fields
68
+
69
+ if not all_ignored:
70
+ return []
71
+
72
+ client_id = get_client_id(request)
73
+ today = date.today()
74
+
75
+ # Split into known deprecated (safe to echo) and unknown (log only)
76
+ known_deprecated_sent = all_ignored & KNOWN_DEPRECATED_FIELDS
77
+ unknown_sent = all_ignored - KNOWN_DEPRECATED_FIELDS
78
+
79
+ # Log known deprecated fields (once per client per day)
80
+ for field in known_deprecated_sent:
81
+ cache_key = (client_id, field)
82
+ if _warned_today.get(cache_key) != today:
83
+ logger.warning(
84
+ f"Client {client_id} sent deprecated field '{field}' - "
85
+ f"this field is ignored for security. "
86
+ f"Please remove it from your requests."
87
+ )
88
+ _warned_today[cache_key] = today
89
+
90
+ # Log unknown fields internally only (do NOT echo to client)
91
+ if unknown_sent:
92
+ # Only log once per client per day to avoid spam
93
+ cache_key = (client_id, "__unknown_fields__")
94
+ if _warned_today.get(cache_key) != today:
95
+ logger.info(
96
+ f"Client {client_id} sent unknown fields: {sorted(unknown_sent)} - "
97
+ f"these are silently ignored."
98
+ )
99
+ _warned_today[cache_key] = today
100
+
101
+ # Return ONLY known deprecated fields (safe to echo)
102
+ return sorted(known_deprecated_sent)
103
+
104
+
105
+ def add_ignored_fields_header(response: Response, ignored_fields: list[str]) -> None:
106
+ """Add X-Ignored-Fields header to response.
107
+
108
+ SECURITY: Only adds header if ignored_fields contains known deprecated fields.
109
+ The ignored_fields list should come from check_ignored_fields() which already
110
+ filters to KNOWN_DEPRECATED_FIELDS only.
111
+ """
112
+ if ignored_fields:
113
+ # Double-check: only include known deprecated fields
114
+ safe_fields = [f for f in ignored_fields if f in KNOWN_DEPRECATED_FIELDS]
115
+ if safe_fields:
116
+ response.headers["X-Ignored-Fields"] = ", ".join(safe_fields)
117
+
118
+
119
+ def cleanup_old_warnings() -> None:
120
+ """Clean up warning cache for old dates."""
121
+ global _warned_today
122
+ today = date.today()
123
+ _warned_today = {k: v for k, v in _warned_today.items() if v == today}
@@ -0,0 +1,54 @@
1
+ """Input validation utilities for API requests."""
2
+
3
+ import uuid
4
+
5
+ from fastapi import HTTPException
6
+
7
+
8
+ def validate_uuid(value: str, field_name: str = "ID") -> str:
9
+ """Validate that a string is a valid UUID format.
10
+
11
+ Args:
12
+ value: String to validate
13
+ field_name: Field name for error message
14
+
15
+ Returns:
16
+ The validated UUID string (normalized)
17
+
18
+ Raises:
19
+ HTTPException: 400 if not a valid UUID
20
+ """
21
+ try:
22
+ # Parse and normalize UUID
23
+ parsed = uuid.UUID(value)
24
+ return str(parsed)
25
+ except (ValueError, AttributeError, TypeError):
26
+ raise HTTPException(
27
+ status_code=400,
28
+ detail=f"Invalid {field_name}: must be a valid UUID (got: {value})",
29
+ )
30
+
31
+
32
+ def validate_uuids(values: list[str], field_name: str = "IDs") -> list[str]:
33
+ """Validate a list of UUIDs.
34
+
35
+ Args:
36
+ values: List of UUID strings to validate
37
+ field_name: Field name for error message
38
+
39
+ Returns:
40
+ List of validated UUID strings
41
+
42
+ Raises:
43
+ HTTPException: 400 if any UUID is invalid
44
+ """
45
+ validated = []
46
+ for value in values:
47
+ try:
48
+ validated.append(validate_uuid(value, field_name))
49
+ except HTTPException:
50
+ raise HTTPException(
51
+ status_code=400,
52
+ detail=f"Invalid {field_name}: '{value}' is not a valid UUID",
53
+ )
54
+ return validated
@@ -0,0 +1,5 @@
1
+ """WebSocket infrastructure for real-time updates."""
2
+
3
+ from app.websocket.manager import ConnectionManager, broadcast_sync, manager
4
+
5
+ __all__ = ["ConnectionManager", "manager", "broadcast_sync"]
@@ -0,0 +1,179 @@
1
+ """WebSocket connection manager for real-time updates.
2
+
3
+ This module manages WebSocket connections and provides channel-based
4
+ broadcasting for real-time updates to clients.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+
10
+ from fastapi import WebSocket
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ConnectionManager:
16
+ """Manage WebSocket connections for real-time updates.
17
+
18
+ Supports channel-based broadcasting where clients can subscribe to
19
+ specific channels (e.g., job:{job_id}, board:{board_id}) to receive
20
+ targeted updates.
21
+ """
22
+
23
+ def __init__(self):
24
+ # Map of channel -> set of websockets
25
+ self.connections: dict[str, set[WebSocket]] = {}
26
+ self._lock = asyncio.Lock()
27
+
28
+ async def connect(self, websocket: WebSocket, channel: str):
29
+ """Accept and register a new WebSocket connection to a channel.
30
+
31
+ Args:
32
+ websocket: The WebSocket connection to register
33
+ channel: The channel to subscribe to (e.g., "job:123", "board:abc")
34
+ """
35
+ await websocket.accept()
36
+ async with self._lock:
37
+ if channel not in self.connections:
38
+ self.connections[channel] = set()
39
+ self.connections[channel].add(websocket)
40
+ logger.info(f"WebSocket connected to channel: {channel}")
41
+
42
+ async def disconnect(self, websocket: WebSocket, channel: str):
43
+ """Unregister a WebSocket connection from a channel.
44
+
45
+ Args:
46
+ websocket: The WebSocket connection to remove
47
+ channel: The channel to unsubscribe from
48
+ """
49
+ async with self._lock:
50
+ if channel in self.connections:
51
+ self.connections[channel].discard(websocket)
52
+ if not self.connections[channel]:
53
+ # Remove empty channel
54
+ del self.connections[channel]
55
+ logger.info(f"WebSocket disconnected from channel: {channel}")
56
+
57
+ async def broadcast(self, channel: str, message: dict):
58
+ """Send message to all connections on a channel.
59
+
60
+ Args:
61
+ channel: The channel to broadcast to
62
+ message: The message dict to send (will be JSON serialized)
63
+
64
+ Automatically cleans up dead connections that fail to receive messages.
65
+ """
66
+ if channel not in self.connections:
67
+ return
68
+
69
+ dead_connections = set()
70
+ for connection in list(self.connections[channel]):
71
+ try:
72
+ await connection.send_json(message)
73
+ except Exception as e:
74
+ logger.warning(
75
+ f"Failed to send message to WebSocket on channel {channel}: {e}"
76
+ )
77
+ dead_connections.add(connection)
78
+
79
+ # Clean up dead connections
80
+ if dead_connections:
81
+ async with self._lock:
82
+ if channel in self.connections:
83
+ self.connections[channel] -= dead_connections
84
+ if not self.connections[channel]:
85
+ del self.connections[channel]
86
+ logger.info(
87
+ f"Cleaned up {len(dead_connections)} dead connections from {channel}"
88
+ )
89
+
90
+ async def broadcast_to_all(self, message: dict):
91
+ """Broadcast message to all connections across all channels.
92
+
93
+ Args:
94
+ message: The message dict to send to all connected clients
95
+ """
96
+ for channel in list(self.connections.keys()):
97
+ await self.broadcast(channel, message)
98
+
99
+ def get_connection_count(self, channel: str = None) -> int:
100
+ """Get count of active connections.
101
+
102
+ Args:
103
+ channel: Optional channel to count connections for.
104
+ If None, returns total across all channels.
105
+
106
+ Returns:
107
+ Number of active connections
108
+ """
109
+ if channel:
110
+ return len(self.connections.get(channel, set()))
111
+ return sum(len(conns) for conns in self.connections.values())
112
+
113
+ def get_channels(self) -> list[str]:
114
+ """Get list of all active channels.
115
+
116
+ Returns:
117
+ List of channel names with active connections
118
+ """
119
+ return list(self.connections.keys())
120
+
121
+ async def broadcast_board_state(self, board_id: str, board_state: dict):
122
+ """Broadcast board state as JSON patch to connected clients.
123
+
124
+ On first call for a board, sends a full snapshot. On subsequent calls,
125
+ computes and sends an RFC 6902 JSON patch. Sends nothing if no change.
126
+
127
+ Args:
128
+ board_id: The board ID
129
+ board_state: Full board state dict
130
+ """
131
+ channel = f"board:{board_id}"
132
+ if channel not in self.connections:
133
+ return
134
+
135
+ from app.websocket.state_tracker import get_tracker
136
+
137
+ tracker = get_tracker(board_id)
138
+
139
+ if not tracker.has_state:
140
+ message = tracker.get_snapshot_message(board_state)
141
+ else:
142
+ message = tracker.compute_patch(board_state)
143
+ if message is None:
144
+ return # No changes
145
+
146
+ await self.broadcast(channel, message)
147
+
148
+
149
+ # Global connection manager instance
150
+ manager = ConnectionManager()
151
+
152
+
153
+ # Helper functions for sync code (like Celery workers)
154
+ def broadcast_sync(channel: str, message: dict):
155
+ """Broadcast message from synchronous code.
156
+
157
+ This is a helper for sync contexts (like Celery workers) that need to
158
+ broadcast WebSocket messages. It schedules the broadcast on the event loop.
159
+
160
+ Args:
161
+ channel: The channel to broadcast to
162
+ message: The message dict to send
163
+
164
+ Note: This uses asyncio.create_task() which requires an active event loop.
165
+ If called from a thread without a loop, the broadcast will be skipped.
166
+ """
167
+ try:
168
+ import asyncio
169
+
170
+ loop = asyncio.get_event_loop()
171
+ if loop.is_running():
172
+ asyncio.create_task(manager.broadcast(channel, message))
173
+ else:
174
+ # If no loop is running, we can't broadcast
175
+ logger.debug(
176
+ f"Skipping WebSocket broadcast to {channel} - no event loop running"
177
+ )
178
+ except Exception as e:
179
+ logger.debug(f"Failed to broadcast WebSocket message to {channel}: {e}")
@@ -0,0 +1,113 @@
1
+ """Board state tracker for computing JSON patches.
2
+
3
+ Tracks the last-known board state per connection and computes RFC 6902
4
+ JSON patches between states, enabling incremental updates over WebSocket.
5
+
6
+ Protocol:
7
+ 1. On connect: send full snapshot {"type": "snapshot", "data": ..., "seq": 0}
8
+ 2. On change: send patch {"type": "patch", "ops": [...], "seq": N}
9
+ 3. On gap: client sends {"type": "resync"} → server resends snapshot
10
+ """
11
+
12
+ import copy
13
+ import logging
14
+ from typing import Any
15
+
16
+ import jsonpatch
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class BoardStateTracker:
22
+ """Tracks board state and computes JSON patches for incremental updates.
23
+
24
+ One tracker per board; stores the last-known state and a sequence counter.
25
+ Thread-safe for single-event-loop usage (async context).
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ self._state: dict[str, Any] | None = None
30
+ self._seq: int = 0
31
+
32
+ @property
33
+ def seq(self) -> int:
34
+ return self._seq
35
+
36
+ @property
37
+ def has_state(self) -> bool:
38
+ return self._state is not None
39
+
40
+ def set_state(self, state: dict[str, Any]) -> None:
41
+ """Set the current board state (used for initial snapshot)."""
42
+ self._state = copy.deepcopy(state)
43
+
44
+ def get_snapshot_message(self, state: dict[str, Any]) -> dict[str, Any]:
45
+ """Build a snapshot message and update internal state.
46
+
47
+ Args:
48
+ state: Full board state dict.
49
+
50
+ Returns:
51
+ Message dict: {"type": "snapshot", "data": ..., "seq": 0}
52
+ """
53
+ self._state = copy.deepcopy(state)
54
+ self._seq = 0
55
+ return {
56
+ "type": "snapshot",
57
+ "data": state,
58
+ "seq": self._seq,
59
+ }
60
+
61
+ def compute_patch(self, new_state: dict[str, Any]) -> dict[str, Any] | None:
62
+ """Compute a JSON patch between the stored state and new_state.
63
+
64
+ If the patch is empty (no changes), returns None.
65
+
66
+ Args:
67
+ new_state: The new board state dict.
68
+
69
+ Returns:
70
+ Patch message dict or None if no changes:
71
+ {"type": "patch", "ops": [...], "seq": N}
72
+ """
73
+ if self._state is None:
74
+ # No previous state → send snapshot instead
75
+ return self.get_snapshot_message(new_state)
76
+
77
+ try:
78
+ patch = jsonpatch.make_patch(self._state, new_state)
79
+ ops = patch.patch
80
+
81
+ if not ops:
82
+ return None
83
+
84
+ self._seq += 1
85
+ self._state = copy.deepcopy(new_state)
86
+
87
+ return {
88
+ "type": "patch",
89
+ "ops": ops,
90
+ "seq": self._seq,
91
+ }
92
+
93
+ except Exception as e:
94
+ logger.warning(
95
+ f"Failed to compute JSON patch: {e}, falling back to snapshot"
96
+ )
97
+ return self.get_snapshot_message(new_state)
98
+
99
+
100
+ # Per-board trackers keyed by board_id
101
+ _trackers: dict[str, BoardStateTracker] = {}
102
+
103
+
104
+ def get_tracker(board_id: str) -> BoardStateTracker:
105
+ """Get or create a state tracker for a board."""
106
+ if board_id not in _trackers:
107
+ _trackers[board_id] = BoardStateTracker()
108
+ return _trackers[board_id]
109
+
110
+
111
+ def remove_tracker(board_id: str) -> None:
112
+ """Remove a tracker when no more connections exist for a board."""
113
+ _trackers.pop(board_id, None)