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,326 @@
1
+ """Service for Board operations and authorization.
2
+
3
+ CRITICAL: Board is the primary permission boundary.
4
+ - All goals, tickets, jobs, workspaces belong to a board
5
+ - All filesystem operations use board.repo_root (NEVER global config)
6
+ - All mutating endpoints must validate board_id ownership
7
+ """
8
+
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from app.models import Board, Goal, Job, Ticket, Workspace
16
+ from app.schemas.board import BoardCreate, BoardUpdate
17
+
18
+
19
+ class BoardService:
20
+ """Service for managing boards and enforcing board boundaries."""
21
+
22
+ def __init__(self, db: AsyncSession):
23
+ self.db = db
24
+
25
+ @staticmethod
26
+ def get_default_board_config() -> dict:
27
+ """Get full default configuration for new boards.
28
+
29
+ Returns a complete DraftConfig as a dict so that the DB
30
+ is the single source of truth (no YAML needed at runtime).
31
+
32
+ Key defaults:
33
+ - executor_model: "sonnet-4.5" (fast and cost-effective)
34
+ - timeout: 300 (5 minutes, reasonable for most tasks)
35
+ """
36
+ from app.services.config_service import DraftConfig
37
+
38
+ config = DraftConfig()
39
+ full = config.to_dict()
40
+ # Override with our preferred defaults
41
+ full["execute_config"]["executor_model"] = "sonnet-4.5"
42
+ full["execute_config"]["timeout"] = 300
43
+ return full
44
+
45
+ async def create_board(
46
+ self, data: BoardCreate, owner_id: str | None = None
47
+ ) -> Board:
48
+ """Create a new board with sensible default configuration.
49
+
50
+ CRITICAL: repo_root becomes the authoritative path for all
51
+ filesystem operations on this board.
52
+
53
+ The board is initialized with default config to prevent falling back
54
+ to YAML config which may have non-optimal defaults (e.g., "auto" model).
55
+
56
+ If template_id is provided, applies template configuration and creates
57
+ starter goals (unless create_starter_goals=False).
58
+ """
59
+ # Validate repo_root exists and is a git repo
60
+ repo_path = Path(data.repo_root).resolve()
61
+ if not repo_path.exists():
62
+ raise ValueError(f"repo_root does not exist: {data.repo_root}")
63
+ if not repo_path.is_dir():
64
+ raise ValueError(f"repo_root is not a directory: {data.repo_root}")
65
+ if not (repo_path / ".git").exists():
66
+ raise ValueError(f"repo_root is not a git repository: {data.repo_root}")
67
+
68
+ # Apply template config if template_id provided
69
+ board_config = self.get_default_board_config()
70
+ if data.template_id:
71
+ from app.templates import get_template
72
+
73
+ template = get_template(data.template_id)
74
+ if not template:
75
+ raise ValueError(f"Invalid template_id: {data.template_id}")
76
+
77
+ # Merge template config with defaults (template takes precedence)
78
+ if template.get("config"):
79
+ from app.services.config_service import deep_merge_dicts
80
+
81
+ board_config = deep_merge_dicts(board_config, template["config"])
82
+
83
+ board = Board(
84
+ id=str(uuid.uuid4()),
85
+ name=data.name,
86
+ description=data.description,
87
+ repo_root=str(repo_path), # Store resolved absolute path
88
+ default_branch=data.default_branch,
89
+ config=board_config,
90
+ owner_id=owner_id,
91
+ )
92
+ self.db.add(board)
93
+ await self.db.commit()
94
+ await self.db.refresh(board)
95
+
96
+ # Create starter goals if template provided and requested
97
+ if data.template_id and data.create_starter_goals:
98
+ from app.templates import get_template
99
+
100
+ template = get_template(data.template_id)
101
+ if template and template.get("starter_goals"):
102
+ for goal_data in template["starter_goals"]:
103
+ goal = Goal(
104
+ id=str(uuid.uuid4()),
105
+ board_id=board.id,
106
+ title=goal_data["title"],
107
+ description=goal_data["description"],
108
+ )
109
+ self.db.add(goal)
110
+
111
+ await self.db.commit()
112
+
113
+ return board
114
+
115
+ async def get_board_by_id(self, board_id: str) -> Board:
116
+ """Get a board by its ID."""
117
+ result = await self.db.execute(select(Board).where(Board.id == board_id))
118
+ board = result.scalar_one_or_none()
119
+ if not board:
120
+ raise ValueError(f"Board not found: {board_id}")
121
+ return board
122
+
123
+ async def get_boards(self, owner_id: str | None = None) -> list[Board]:
124
+ """Get all boards, optionally filtered by owner.
125
+
126
+ When owner_id is provided, returns only boards owned by that user.
127
+ When owner_id is None, returns all boards (backward compatible).
128
+ """
129
+ query = select(Board)
130
+ if owner_id is not None:
131
+ query = query.where(Board.owner_id == owner_id)
132
+ result = await self.db.execute(query)
133
+ return list(result.scalars().all())
134
+
135
+ async def update_board(self, board_id: str, data: BoardUpdate) -> Board:
136
+ """Update a board."""
137
+ board = await self.get_board_by_id(board_id)
138
+
139
+ update_data = data.model_dump(exclude_unset=True)
140
+ for field, value in update_data.items():
141
+ setattr(board, field, value)
142
+
143
+ await self.db.commit()
144
+ await self.db.refresh(board)
145
+ return board
146
+
147
+ async def delete_board(self, board_id: str) -> None:
148
+ """Delete a board and all its children (cascades)."""
149
+ board = await self.get_board_by_id(board_id)
150
+ await self.db.delete(board)
151
+ await self.db.commit()
152
+
153
+ async def initialize_board_config(self, board_id: str) -> Board:
154
+ """Initialize config for a board that has config=null.
155
+
156
+ This is useful for migrating existing boards that were created
157
+ before auto-initialization was implemented.
158
+
159
+ If board already has config, this is a no-op.
160
+ """
161
+ board = await self.get_board_by_id(board_id)
162
+
163
+ if board.config is None:
164
+ board.config = self.get_default_board_config()
165
+ await self.db.commit()
166
+ await self.db.refresh(board)
167
+
168
+ return board
169
+
170
+ async def initialize_all_board_configs(self) -> dict:
171
+ """Initialize config for all boards that have config=null.
172
+
173
+ Returns a summary of boards that were updated.
174
+ """
175
+ boards = await self.get_boards()
176
+ updated = []
177
+ skipped = []
178
+
179
+ for board in boards:
180
+ if board.config is None:
181
+ board.config = self.get_default_board_config()
182
+ updated.append(board.id)
183
+ else:
184
+ skipped.append(board.id)
185
+
186
+ if updated:
187
+ await self.db.commit()
188
+
189
+ return {
190
+ "total": len(boards),
191
+ "updated": len(updated),
192
+ "skipped": len(skipped),
193
+ "updated_board_ids": updated,
194
+ }
195
+
196
+ async def get_repo_root(self, board_id: str) -> Path:
197
+ """Get the repo_root path for a board.
198
+
199
+ CRITICAL: This is the ONLY authoritative way to get a repo path.
200
+ NEVER accept paths from client requests.
201
+ NEVER use global config repo_root when board_id is available.
202
+ """
203
+ board = await self.get_board_by_id(board_id)
204
+ return Path(board.repo_root).resolve()
205
+
206
+ # =========================================================================
207
+ # Authorization helpers - use these to enforce board boundaries
208
+ # =========================================================================
209
+
210
+ async def verify_goal_in_board(self, goal_id: str, board_id: str) -> Goal:
211
+ """Verify a goal belongs to a board.
212
+
213
+ Raises ValueError if goal doesn't exist or doesn't belong to board.
214
+ """
215
+ result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
216
+ goal = result.scalar_one_or_none()
217
+ if not goal:
218
+ raise ValueError(f"Goal not found: {goal_id}")
219
+
220
+ if goal.board_id and goal.board_id != board_id:
221
+ raise ValueError(
222
+ f"Goal {goal_id} belongs to board {goal.board_id}, not {board_id}"
223
+ )
224
+
225
+ return goal
226
+
227
+ async def verify_ticket_in_board(self, ticket_id: str, board_id: str) -> Ticket:
228
+ """Verify a ticket belongs to a board.
229
+
230
+ Raises ValueError if ticket doesn't exist or doesn't belong to board.
231
+ """
232
+ result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
233
+ ticket = result.scalar_one_or_none()
234
+ if not ticket:
235
+ raise ValueError(f"Ticket not found: {ticket_id}")
236
+
237
+ if ticket.board_id and ticket.board_id != board_id:
238
+ raise ValueError(
239
+ f"Ticket {ticket_id} belongs to board {ticket.board_id}, not {board_id}"
240
+ )
241
+
242
+ return ticket
243
+
244
+ async def verify_tickets_in_board(
245
+ self, ticket_ids: list[str], board_id: str
246
+ ) -> list[Ticket]:
247
+ """Verify multiple tickets belong to a board.
248
+
249
+ Raises ValueError if any ticket doesn't exist or doesn't belong.
250
+ """
251
+ tickets = []
252
+ for ticket_id in ticket_ids:
253
+ ticket = await self.verify_ticket_in_board(ticket_id, board_id)
254
+ tickets.append(ticket)
255
+ return tickets
256
+
257
+ async def verify_job_in_board(self, job_id: str, board_id: str) -> Job:
258
+ """Verify a job belongs to a board."""
259
+ result = await self.db.execute(select(Job).where(Job.id == job_id))
260
+ job = result.scalar_one_or_none()
261
+ if not job:
262
+ raise ValueError(f"Job not found: {job_id}")
263
+
264
+ if job.board_id and job.board_id != board_id:
265
+ raise ValueError(
266
+ f"Job {job_id} belongs to board {job.board_id}, not {board_id}"
267
+ )
268
+
269
+ return job
270
+
271
+ async def verify_workspace_in_board(
272
+ self, workspace_id: str, board_id: str
273
+ ) -> Workspace:
274
+ """Verify a workspace belongs to a board."""
275
+ result = await self.db.execute(
276
+ select(Workspace).where(Workspace.id == workspace_id)
277
+ )
278
+ workspace = result.scalar_one_or_none()
279
+ if not workspace:
280
+ raise ValueError(f"Workspace not found: {workspace_id}")
281
+
282
+ if workspace.board_id and workspace.board_id != board_id:
283
+ raise ValueError(
284
+ f"Workspace {workspace_id} belongs to board {workspace.board_id}, "
285
+ f"not {board_id}"
286
+ )
287
+
288
+ return workspace
289
+
290
+ async def verify_path_under_repo_root(
291
+ self, path: str | Path, board_id: str
292
+ ) -> Path:
293
+ """Verify a path is under the board's repo_root.
294
+
295
+ CRITICAL: Use this to validate any filesystem paths before operations.
296
+ Prevents directory traversal attacks.
297
+ """
298
+ repo_root = await self.get_repo_root(board_id)
299
+ target_path = Path(path).resolve()
300
+
301
+ try:
302
+ target_path.relative_to(repo_root)
303
+ except ValueError:
304
+ raise ValueError(
305
+ f"Path {target_path} is not under board repo_root {repo_root}"
306
+ )
307
+
308
+ return target_path
309
+
310
+ async def get_board_for_goal(self, goal_id: str) -> Board | None:
311
+ """Get the board that owns a goal (if any)."""
312
+ result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
313
+ goal = result.scalar_one_or_none()
314
+ if not goal or not goal.board_id:
315
+ return None
316
+
317
+ return await self.get_board_by_id(goal.board_id)
318
+
319
+ async def get_board_for_ticket(self, ticket_id: str) -> Board | None:
320
+ """Get the board that owns a ticket (if any)."""
321
+ result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
322
+ ticket = result.scalar_one_or_none()
323
+ if not ticket or not ticket.board_id:
324
+ return None
325
+
326
+ return await self.get_board_by_id(ticket.board_id)