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,113 @@
1
+ """Service layer for Goal operations."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.exceptions import ResourceNotFoundError
7
+ from app.models.goal import Goal
8
+ from app.schemas.goal import GoalCreate, GoalUpdate
9
+
10
+
11
+ class GoalService:
12
+ """Service class for Goal business logic."""
13
+
14
+ def __init__(self, db: AsyncSession):
15
+ self.db = db
16
+
17
+ async def create_goal(self, data: GoalCreate) -> Goal:
18
+ """
19
+ Create a new goal.
20
+
21
+ Args:
22
+ data: Goal creation data
23
+
24
+ Returns:
25
+ The created Goal instance
26
+ """
27
+ # If board_id provided, verify the board exists
28
+ if data.board_id:
29
+ from app.models.board import Board
30
+
31
+ result = await self.db.execute(
32
+ select(Board).where(Board.id == data.board_id)
33
+ )
34
+ if not result.scalar_one_or_none():
35
+ raise ValueError(f"Board not found: {data.board_id}")
36
+
37
+ goal = Goal(
38
+ title=data.title,
39
+ description=data.description,
40
+ board_id=data.board_id,
41
+ autonomy_enabled=data.autonomy_enabled,
42
+ auto_approve_tickets=data.auto_approve_tickets,
43
+ auto_approve_revisions=data.auto_approve_revisions,
44
+ auto_merge=data.auto_merge,
45
+ auto_approve_followups=data.auto_approve_followups,
46
+ max_auto_approvals=data.max_auto_approvals,
47
+ )
48
+ self.db.add(goal)
49
+ await self.db.flush()
50
+ await self.db.refresh(goal)
51
+ return goal
52
+
53
+ async def get_goals(self, board_id: str | None = None) -> list[Goal]:
54
+ """
55
+ Get all goals, optionally filtered by board.
56
+
57
+ Args:
58
+ board_id: If provided, only return goals for this board
59
+
60
+ Returns:
61
+ List of Goal instances
62
+ """
63
+ stmt = select(Goal)
64
+ if board_id:
65
+ stmt = stmt.where(Goal.board_id == board_id)
66
+ result = await self.db.execute(stmt.order_by(Goal.created_at.desc()))
67
+ return list(result.scalars().all())
68
+
69
+ async def update_goal(self, goal_id: str, data: GoalUpdate) -> Goal:
70
+ """Update a goal with partial data.
71
+
72
+ Args:
73
+ goal_id: The UUID of the goal
74
+ data: Fields to update (None fields are skipped)
75
+
76
+ Returns:
77
+ The updated Goal instance
78
+
79
+ Raises:
80
+ ResourceNotFoundError: If the goal is not found
81
+ """
82
+ result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
83
+ goal = result.scalar_one_or_none()
84
+ if goal is None:
85
+ raise ResourceNotFoundError("Goal", goal_id)
86
+
87
+ update_data = data.model_dump(exclude_unset=True)
88
+ for field, value in update_data.items():
89
+ if value is not None:
90
+ setattr(goal, field, value)
91
+
92
+ await self.db.flush()
93
+ await self.db.refresh(goal)
94
+ return goal
95
+
96
+ async def get_goal_by_id(self, goal_id: str) -> Goal:
97
+ """
98
+ Get a goal by its ID.
99
+
100
+ Args:
101
+ goal_id: The UUID of the goal
102
+
103
+ Returns:
104
+ The Goal instance
105
+
106
+ Raises:
107
+ ResourceNotFoundError: If the goal is not found
108
+ """
109
+ result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
110
+ goal = result.scalar_one_or_none()
111
+ if goal is None:
112
+ raise ResourceNotFoundError("Goal", goal_id)
113
+ return goal
@@ -0,0 +1,423 @@
1
+ """Service layer for Job operations."""
2
+
3
+ import os
4
+ from datetime import UTC, datetime, timedelta
5
+ from pathlib import Path
6
+
7
+ from sqlalchemy import func, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from sqlalchemy.orm import selectinload
10
+
11
+ from app.exceptions import ResourceNotFoundError, ValidationError
12
+ from app.models.job import Job, JobKind, JobStatus
13
+ from app.models.ticket import Ticket
14
+ from app.schemas.job import QueuedJobResponse, QueueStatusResponse
15
+ from app.services.workspace_service import WorkspaceService
16
+
17
+ # Base directory for fallback logs (relative to backend directory)
18
+ FALLBACK_LOGS_DIR = Path(__file__).parent.parent.parent / "logs"
19
+
20
+ # Maximum log file size to read (2MB)
21
+ MAX_LOG_BYTES = 2_000_000
22
+
23
+ # Job rate limiting (prevent spam/runaway tickets)
24
+ MAX_JOBS_PER_TICKET_PER_HOUR = 10
25
+ MAX_EXECUTE_JOBS_PER_TICKET_PER_DAY = 50
26
+
27
+
28
+ def _safe_read_file(base_path: Path, allowed_root: Path, relpath: str) -> str | None:
29
+ """Safely read a file, enforcing it is under allowed_root.
30
+
31
+ Args:
32
+ base_path: Base path to prepend to relpath
33
+ allowed_root: Root directory that file must be under
34
+ relpath: Relative path to the file
35
+
36
+ Returns:
37
+ File content if safe and exists, None otherwise
38
+ """
39
+ rel = Path(relpath)
40
+
41
+ # Reject absolute paths
42
+ if rel.is_absolute():
43
+ return None
44
+
45
+ # Resolve paths to canonical form
46
+ allowed_canonical = allowed_root.resolve(strict=False)
47
+ target = (base_path / rel).resolve(strict=False)
48
+
49
+ # Enforce target is under allowed_root
50
+ try:
51
+ common = os.path.commonpath([str(target), str(allowed_canonical)])
52
+ except ValueError:
53
+ return None
54
+
55
+ if common != str(allowed_canonical):
56
+ return None
57
+
58
+ if not target.is_file():
59
+ return None
60
+
61
+ try:
62
+ size = target.stat().st_size
63
+ if size > MAX_LOG_BYTES:
64
+ with target.open("rb") as f:
65
+ data = f.read(MAX_LOG_BYTES)
66
+ return data.decode("utf-8", errors="replace") + "\n\n[truncated]"
67
+ return target.read_text(encoding="utf-8", errors="replace")
68
+ except OSError:
69
+ return None
70
+
71
+
72
+ def _safe_read_absolute(target: Path) -> str | None:
73
+ """Safely read an absolute file path with size cap."""
74
+ if not target.is_file():
75
+ return None
76
+ try:
77
+ size = target.stat().st_size
78
+ if size > MAX_LOG_BYTES:
79
+ with target.open("rb") as f:
80
+ data = f.read(MAX_LOG_BYTES)
81
+ return data.decode("utf-8", errors="replace") + "\n\n[truncated]"
82
+ return target.read_text(encoding="utf-8", errors="replace")
83
+ except OSError:
84
+ return None
85
+
86
+
87
+ class JobService:
88
+ """Service class for Job business logic."""
89
+
90
+ def __init__(self, db: AsyncSession):
91
+ self.db = db
92
+
93
+ async def create_job(
94
+ self, ticket_id: str, kind: JobKind, variant: str | None = None
95
+ ) -> Job:
96
+ """
97
+ Create a new job and enqueue the corresponding Celery task.
98
+
99
+ Includes rate limiting to prevent runaway tickets.
100
+
101
+ Args:
102
+ ticket_id: The UUID of the ticket
103
+ kind: The kind of job (execute or verify)
104
+ variant: Optional execution variant (default, plan, qa, review)
105
+
106
+ Returns:
107
+ The created Job instance with celery_task_id set
108
+
109
+ Raises:
110
+ ResourceNotFoundError: If the ticket is not found
111
+ ValidationError: If rate limit exceeded
112
+ """
113
+ # Verify the ticket exists and get its board_id
114
+ # CRITICAL: Use SELECT FOR UPDATE to prevent race conditions in rate limiting
115
+ # This locks the ticket row until transaction commits, serializing job creation
116
+ result = await self.db.execute(
117
+ select(Ticket).where(Ticket.id == ticket_id).with_for_update()
118
+ )
119
+ ticket = result.scalar_one_or_none()
120
+ if ticket is None:
121
+ raise ResourceNotFoundError("Ticket", ticket_id)
122
+
123
+ # RATE LIMITING: Check hourly limit (protected by row lock above)
124
+ one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
125
+ hourly_result = await self.db.execute(
126
+ select(func.count(Job.id))
127
+ .where(Job.ticket_id == ticket_id)
128
+ .where(Job.kind == kind.value)
129
+ .where(Job.created_at >= one_hour_ago)
130
+ )
131
+ recent_jobs_hour = hourly_result.scalar()
132
+
133
+ if recent_jobs_hour >= MAX_JOBS_PER_TICKET_PER_HOUR:
134
+ raise ValidationError(
135
+ f"Rate limit exceeded: {recent_jobs_hour} {kind.value} jobs in past hour. "
136
+ f"Max {MAX_JOBS_PER_TICKET_PER_HOUR} per hour per ticket."
137
+ )
138
+
139
+ # RATE LIMITING: Check daily limit for EXECUTE jobs (expensive)
140
+ if kind == JobKind.EXECUTE:
141
+ one_day_ago = datetime.now(UTC) - timedelta(days=1)
142
+ daily_result = await self.db.execute(
143
+ select(func.count(Job.id))
144
+ .where(Job.ticket_id == ticket_id)
145
+ .where(Job.kind == JobKind.EXECUTE.value)
146
+ .where(Job.created_at >= one_day_ago)
147
+ )
148
+ recent_jobs_day = daily_result.scalar()
149
+
150
+ if recent_jobs_day >= MAX_EXECUTE_JOBS_PER_TICKET_PER_DAY:
151
+ raise ValidationError(
152
+ f"Daily execute limit exceeded: {recent_jobs_day} execute jobs in past 24h. "
153
+ f"Max {MAX_EXECUTE_JOBS_PER_TICKET_PER_DAY} per day per ticket."
154
+ )
155
+
156
+ # Create the job record with board_id from ticket for permission scoping
157
+ job = Job(
158
+ ticket_id=ticket_id,
159
+ board_id=ticket.board_id, # Inherit board_id from ticket
160
+ kind=kind.value,
161
+ status=JobStatus.QUEUED.value,
162
+ )
163
+ self.db.add(job)
164
+ await self.db.flush()
165
+ await self.db.refresh(job)
166
+
167
+ # CRITICAL: Commit the job BEFORE enqueuing Celery task
168
+ # This ensures the Celery worker (sync session) can see the job
169
+ # Without this, async/sync session isolation causes "Job not found" errors
170
+ await self.db.commit()
171
+
172
+ # Enqueue the task via unified dispatch (supports SQLite and Celery backends)
173
+ from app.services.task_dispatch import enqueue_task
174
+
175
+ task_names = {
176
+ JobKind.EXECUTE: "execute_ticket",
177
+ JobKind.VERIFY: "verify_ticket",
178
+ JobKind.RESUME: "resume_ticket",
179
+ }
180
+ task_name = task_names.get(kind)
181
+ if not task_name:
182
+ raise ValueError(f"Unknown job kind: {kind}")
183
+ task = enqueue_task(task_name, args=[job.id])
184
+
185
+ # Store the task ID for later reference (e.g., cancellation)
186
+ job.celery_task_id = task.id
187
+
188
+ # Commit again to save the celery_task_id
189
+ await self.db.commit()
190
+ await self.db.refresh(job)
191
+
192
+ return job
193
+
194
+ async def get_job_by_id(self, job_id: str) -> Job:
195
+ """
196
+ Get a job by its ID.
197
+
198
+ Args:
199
+ job_id: The UUID of the job
200
+
201
+ Returns:
202
+ The Job instance
203
+
204
+ Raises:
205
+ ResourceNotFoundError: If the job is not found
206
+ """
207
+ result = await self.db.execute(
208
+ select(Job).where(Job.id == job_id).options(selectinload(Job.ticket))
209
+ )
210
+ job = result.scalar_one_or_none()
211
+ if job is None:
212
+ raise ResourceNotFoundError("Job", job_id)
213
+ return job
214
+
215
+ async def get_jobs_for_ticket(self, ticket_id: str) -> list[Job]:
216
+ """
217
+ Get all jobs for a ticket.
218
+
219
+ Args:
220
+ ticket_id: The UUID of the ticket
221
+
222
+ Returns:
223
+ List of Job instances ordered by created_at descending
224
+
225
+ Raises:
226
+ ResourceNotFoundError: If the ticket is not found
227
+ """
228
+ # Verify the ticket exists
229
+ result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
230
+ ticket = result.scalar_one_or_none()
231
+ if ticket is None:
232
+ raise ResourceNotFoundError("Ticket", ticket_id)
233
+
234
+ # Get all jobs for the ticket
235
+ result = await self.db.execute(
236
+ select(Job)
237
+ .where(Job.ticket_id == ticket_id)
238
+ .order_by(Job.created_at.desc())
239
+ )
240
+ return list(result.scalars().all())
241
+
242
+ async def cancel_job(self, job_id: str) -> Job:
243
+ """
244
+ Cancel a job (actively kills running subprocesses).
245
+
246
+ This will:
247
+ 1. Mark the job as canceled in the database
248
+ 2. Kill any running subprocess for this job
249
+ 3. Attempt to revoke the Celery task
250
+
251
+ Args:
252
+ job_id: The UUID of the job
253
+
254
+ Returns:
255
+ The updated Job instance
256
+
257
+ Raises:
258
+ ResourceNotFoundError: If the job is not found
259
+ """
260
+ import asyncio
261
+ import logging
262
+
263
+ logger = logging.getLogger(__name__)
264
+ job = await self.get_job_by_id(job_id)
265
+
266
+ # Only cancel if not already in a terminal state
267
+ if job.status in [
268
+ JobStatus.SUCCEEDED.value,
269
+ JobStatus.FAILED.value,
270
+ JobStatus.CANCELED.value,
271
+ ]:
272
+ return job
273
+
274
+ # Mark as canceled in database FIRST (so worker polls see it)
275
+ job.status = JobStatus.CANCELED.value
276
+ await self.db.flush()
277
+
278
+ # Kill any running subprocess
279
+ try:
280
+ from app.worker import kill_job_process
281
+
282
+ killed = await asyncio.to_thread(kill_job_process, job_id)
283
+ if killed:
284
+ logger.info(f"Successfully killed subprocess for job {job_id}")
285
+ else:
286
+ logger.warning(f"No active subprocess found for job {job_id}")
287
+ except Exception as e:
288
+ logger.error(f"Failed to kill subprocess for job {job_id}: {e}")
289
+
290
+ await self.db.refresh(job)
291
+ return job
292
+
293
+ def read_job_logs(self, log_path: str | None) -> str | None:
294
+ """
295
+ Read the log content for a job (synchronous version).
296
+
297
+ Security:
298
+ - Reads from central data dir, legacy .draft/, or backend/logs/
299
+ - Validates canonical path is under allowed directory
300
+ - Caps file size to prevent memory exhaustion
301
+
302
+ Args:
303
+ log_path: The path to the log file (absolute or relative)
304
+
305
+ Returns:
306
+ The log content as a string, or None if no logs available
307
+ """
308
+ if not log_path:
309
+ return None
310
+
311
+ from app.data_dir import get_data_dir
312
+
313
+ # If it's an absolute path under the central data dir, read directly
314
+ log_p = Path(log_path)
315
+ if log_p.is_absolute():
316
+ data_dir = get_data_dir()
317
+ try:
318
+ log_p.resolve().relative_to(data_dir.resolve())
319
+ if log_p.is_file():
320
+ return _safe_read_absolute(log_p)
321
+ except ValueError:
322
+ pass
323
+
324
+ # Try central data dir (for new logs)
325
+ data_dir = get_data_dir()
326
+ content = _safe_read_file(data_dir, data_dir / "logs", log_path)
327
+ if content is not None:
328
+ return content
329
+
330
+ # Try repo root (legacy .draft/ logs)
331
+ repo_path = WorkspaceService.get_repo_path()
332
+ draft_root = repo_path / ".draft"
333
+ content = _safe_read_file(repo_path, draft_root, log_path)
334
+ if content is not None:
335
+ return content
336
+
337
+ # Fall back to backend/logs/ directory (for legacy fallback logs)
338
+ backend_root = Path(__file__).parent.parent.parent
339
+ content = _safe_read_file(backend_root, FALLBACK_LOGS_DIR, log_path)
340
+ return content
341
+
342
+ async def read_job_logs_async(self, log_path: str | None) -> str | None:
343
+ """
344
+ Read the log content for a job (async version - non-blocking).
345
+
346
+ Wraps file I/O in asyncio.to_thread() to avoid blocking the event loop.
347
+
348
+ Security:
349
+ - Only reads files under <repo_root>/.draft/ or backend/logs/
350
+ - Rejects absolute paths
351
+ - Validates canonical path is under allowed directory
352
+ - Caps file size to prevent memory exhaustion
353
+
354
+ Args:
355
+ log_path: The relative path to the log file
356
+
357
+ Returns:
358
+ The log content as a string, or None if no logs available
359
+ """
360
+ import asyncio
361
+
362
+ return await asyncio.to_thread(self.read_job_logs, log_path)
363
+
364
+ async def get_queue_status(self) -> QueueStatusResponse:
365
+ """
366
+ Get the current queue status showing running and queued jobs.
367
+
368
+ Returns:
369
+ QueueStatusResponse with running and queued jobs including ticket info
370
+ """
371
+ # Get running jobs (ordered by started_at)
372
+ running_result = await self.db.execute(
373
+ select(Job)
374
+ .where(Job.status == JobStatus.RUNNING.value)
375
+ .options(selectinload(Job.ticket))
376
+ .order_by(Job.started_at.asc())
377
+ )
378
+ running_jobs = list(running_result.scalars().all())
379
+
380
+ # Get queued jobs (ordered by created_at - FIFO)
381
+ queued_result = await self.db.execute(
382
+ select(Job)
383
+ .where(Job.status == JobStatus.QUEUED.value)
384
+ .options(selectinload(Job.ticket))
385
+ .order_by(Job.created_at.asc())
386
+ )
387
+ queued_jobs = list(queued_result.scalars().all())
388
+
389
+ # Build response
390
+ running_responses = [
391
+ QueuedJobResponse(
392
+ id=job.id,
393
+ ticket_id=job.ticket_id,
394
+ ticket_title=job.ticket.title if job.ticket else "Unknown",
395
+ kind=JobKind(job.kind),
396
+ status=JobStatus(job.status),
397
+ created_at=job.created_at,
398
+ started_at=job.started_at,
399
+ queue_position=None, # Running jobs have no queue position
400
+ )
401
+ for job in running_jobs
402
+ ]
403
+
404
+ queued_responses = [
405
+ QueuedJobResponse(
406
+ id=job.id,
407
+ ticket_id=job.ticket_id,
408
+ ticket_title=job.ticket.title if job.ticket else "Unknown",
409
+ kind=JobKind(job.kind),
410
+ status=JobStatus(job.status),
411
+ created_at=job.created_at,
412
+ started_at=job.started_at,
413
+ queue_position=idx + 1, # 1-based position
414
+ )
415
+ for idx, job in enumerate(queued_jobs)
416
+ ]
417
+
418
+ return QueueStatusResponse(
419
+ running=running_responses,
420
+ queued=queued_responses,
421
+ total_running=len(running_responses),
422
+ total_queued=len(queued_responses),
423
+ )