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