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,486 @@
1
+ """Service layer for Ticket operations with state machine enforcement."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from collections import defaultdict
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ from sqlalchemy.orm import selectinload
12
+
13
+ from app.database_sync import get_sync_db
14
+ from app.exceptions import InvalidStateTransitionError, ResourceNotFoundError
15
+ from app.models.board import Board
16
+ from app.models.ticket import Ticket
17
+ from app.models.ticket_event import TicketEvent
18
+ from app.schemas.ticket import TicketCreate, TicketResponse, TicketsByState
19
+ from app.services.webhook_service import fire_webhooks
20
+ from app.services.workspace_service import WorkspaceService
21
+ from app.state_machine import (
22
+ ActorType,
23
+ EventType,
24
+ TicketState,
25
+ is_terminal_state,
26
+ validate_transition,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Thread pool for running blocking workspace cleanup operations
32
+ _cleanup_executor = ThreadPoolExecutor(
33
+ max_workers=2, thread_name_prefix="workspace_cleanup"
34
+ )
35
+
36
+
37
+ class TicketService:
38
+ """Service class for Ticket business logic with state machine enforcement."""
39
+
40
+ def __init__(self, db: AsyncSession):
41
+ self.db = db
42
+
43
+ async def create_ticket(self, data: TicketCreate) -> Ticket:
44
+ """
45
+ Create a new ticket in the 'proposed' state.
46
+ Also creates an initial TicketEvent for the creation.
47
+
48
+ Args:
49
+ data: Ticket creation data
50
+
51
+ Returns:
52
+ The created Ticket instance
53
+ """
54
+ # Validate blocked_by_ticket_id if provided
55
+ blocked_by_title = None
56
+ if data.blocked_by_ticket_id:
57
+ result = await self.db.execute(
58
+ select(Ticket).where(Ticket.id == data.blocked_by_ticket_id)
59
+ )
60
+ blocker = result.scalar_one_or_none()
61
+ if not blocker:
62
+ raise ResourceNotFoundError(
63
+ "Blocking Ticket", data.blocked_by_ticket_id
64
+ )
65
+ blocked_by_title = blocker.title
66
+
67
+ # Fetch board_id from the parent goal
68
+ from app.models.goal import Goal
69
+
70
+ goal = await self.db.get(Goal, data.goal_id)
71
+ if not goal:
72
+ raise ResourceNotFoundError("Goal", data.goal_id)
73
+
74
+ # Create the ticket
75
+ ticket = Ticket(
76
+ goal_id=data.goal_id,
77
+ board_id=goal.board_id,
78
+ title=data.title,
79
+ description=data.description,
80
+ state=TicketState.PROPOSED.value,
81
+ priority=data.priority,
82
+ blocked_by_ticket_id=data.blocked_by_ticket_id,
83
+ )
84
+ self.db.add(ticket)
85
+ await self.db.flush()
86
+
87
+ # Create the initial event
88
+ event = TicketEvent(
89
+ ticket_id=ticket.id,
90
+ event_type=EventType.CREATED.value,
91
+ from_state=None,
92
+ to_state=TicketState.PROPOSED.value,
93
+ actor_type=data.actor_type.value,
94
+ actor_id=data.actor_id,
95
+ reason="Ticket created",
96
+ payload_json=json.dumps(
97
+ {
98
+ "title": data.title,
99
+ "description": data.description,
100
+ "goal_id": data.goal_id,
101
+ "priority": data.priority,
102
+ "blocked_by_ticket_id": data.blocked_by_ticket_id,
103
+ "blocked_by_title": blocked_by_title,
104
+ }
105
+ ),
106
+ )
107
+ self.db.add(event)
108
+ await self.db.flush()
109
+ await self.db.refresh(ticket)
110
+
111
+ return ticket
112
+
113
+ async def get_ticket_by_id(self, ticket_id: str) -> Ticket:
114
+ """
115
+ Get a ticket by its ID.
116
+
117
+ Args:
118
+ ticket_id: The UUID of the ticket
119
+
120
+ Returns:
121
+ The Ticket instance
122
+
123
+ Raises:
124
+ ResourceNotFoundError: If the ticket is not found
125
+ """
126
+ result = await self.db.execute(
127
+ select(Ticket)
128
+ .where(Ticket.id == ticket_id)
129
+ .options(
130
+ selectinload(Ticket.goal),
131
+ selectinload(Ticket.blocked_by), # Load blocker relationship
132
+ )
133
+ )
134
+ ticket = result.scalar_one_or_none()
135
+ if ticket is None:
136
+ raise ResourceNotFoundError("Ticket", ticket_id)
137
+ return ticket
138
+
139
+ async def transition_ticket(
140
+ self,
141
+ ticket_id: str,
142
+ to_state: TicketState,
143
+ actor_type: ActorType,
144
+ actor_id: str | None = None,
145
+ reason: str | None = None,
146
+ auto_verify: bool = True,
147
+ skip_cleanup: bool = False,
148
+ ) -> Ticket:
149
+ """
150
+ Transition a ticket to a new state.
151
+ Validates the transition and creates a TicketEvent atomically.
152
+
153
+ Args:
154
+ ticket_id: The UUID of the ticket
155
+ to_state: The target state
156
+ actor_type: The type of actor performing the transition
157
+ actor_id: Optional ID of the actor
158
+ reason: Optional reason for the transition
159
+ auto_verify: If True, auto-enqueue verify job when entering verifying state
160
+ skip_cleanup: If True, skip workspace cleanup for terminal states
161
+ (use when caller handles cleanup separately to avoid SQLite deadlocks)
162
+
163
+ Returns:
164
+ The updated Ticket instance
165
+
166
+ Raises:
167
+ ResourceNotFoundError: If the ticket is not found
168
+ InvalidStateTransitionError: If the transition is not valid
169
+ """
170
+ ticket = await self.get_ticket_by_id(ticket_id)
171
+ from_state = TicketState(ticket.state)
172
+
173
+ # Validate the transition
174
+ if not validate_transition(from_state, to_state):
175
+ raise InvalidStateTransitionError(from_state.value, to_state.value)
176
+
177
+ # Update the ticket state
178
+ ticket.state = to_state.value
179
+
180
+ # Create the transition event
181
+ event = TicketEvent(
182
+ ticket_id=ticket.id,
183
+ event_type=EventType.TRANSITIONED.value,
184
+ from_state=from_state.value,
185
+ to_state=to_state.value,
186
+ actor_type=actor_type.value,
187
+ actor_id=actor_id,
188
+ reason=reason,
189
+ payload_json=None,
190
+ )
191
+ self.db.add(event)
192
+
193
+ await self.db.flush()
194
+ await self.db.refresh(ticket)
195
+
196
+ # Trigger workspace cleanup for terminal states.
197
+ # Note: DONE can transition back to EXECUTING (human requests changes),
198
+ # in which case WorkspaceService.ensure_workspace() will recreate it.
199
+ if is_terminal_state(to_state) and not skip_cleanup:
200
+ await self._cleanup_workspace_async(ticket_id)
201
+
202
+ # Auto-trigger verification when entering verifying state
203
+ if auto_verify and to_state == TicketState.VERIFYING:
204
+ await self._enqueue_verify_job_async(ticket_id)
205
+
206
+ # Fire webhook notifications (best-effort, non-blocking)
207
+ try:
208
+ board = await self._get_board_for_ticket(ticket)
209
+ webhooks = (board.config or {}).get("webhooks", []) if board else []
210
+ if webhooks:
211
+ asyncio.ensure_future(
212
+ fire_webhooks(
213
+ webhooks,
214
+ ticket_id=ticket.id,
215
+ ticket_title=ticket.title,
216
+ board_id=ticket.board_id,
217
+ from_state=from_state.value,
218
+ to_state=to_state.value,
219
+ actor_type=actor_type.value,
220
+ actor_id=actor_id,
221
+ reason=reason,
222
+ )
223
+ )
224
+ except Exception:
225
+ logger.warning(
226
+ "Failed to dispatch webhooks for ticket %s", ticket_id, exc_info=True
227
+ )
228
+
229
+ return ticket
230
+
231
+ async def _get_board_for_ticket(self, ticket: Ticket) -> Board | None:
232
+ """Load the board for a ticket (for webhook config)."""
233
+ result = await self.db.execute(select(Board).where(Board.id == ticket.board_id))
234
+ return result.scalar_one_or_none()
235
+
236
+ async def _enqueue_verify_job_async(self, ticket_id: str) -> str | None:
237
+ """
238
+ Asynchronously enqueue a verify job for a ticket (idempotent).
239
+
240
+ Idempotency: Only creates a new verify job if there is no active
241
+ (queued or running) verify job for this ticket. This prevents
242
+ duplicate verify jobs from race conditions or retries.
243
+
244
+ Args:
245
+ ticket_id: The UUID of the ticket
246
+
247
+ Returns:
248
+ The job ID if created, None if skipped (already active).
249
+ """
250
+ from app.models.job import Job, JobKind, JobStatus
251
+
252
+ try:
253
+ # IDEMPOTENCY CHECK: Is there already an active verify job?
254
+ active_verify_result = await self.db.execute(
255
+ select(Job).where(
256
+ Job.ticket_id == ticket_id,
257
+ Job.kind == JobKind.VERIFY.value,
258
+ Job.status.in_([JobStatus.QUEUED.value, JobStatus.RUNNING.value]),
259
+ )
260
+ )
261
+ active_verify = active_verify_result.scalar_one_or_none()
262
+
263
+ if active_verify:
264
+ logger.info(
265
+ f"Skipping verify enqueue for ticket {ticket_id} - already has active job {active_verify.id}"
266
+ )
267
+ return None
268
+
269
+ # Create the job record
270
+ job = Job(
271
+ ticket_id=ticket_id,
272
+ kind=JobKind.VERIFY.value,
273
+ status=JobStatus.QUEUED.value,
274
+ )
275
+ self.db.add(job)
276
+ await self.db.flush()
277
+ await self.db.refresh(job)
278
+
279
+ # CRITICAL: Commit BEFORE dispatching to avoid SQLite deadlock.
280
+ # The SQLite in-process worker picks up tasks immediately and tries
281
+ # to write to the same DB. If we hold the write lock (uncommitted
282
+ # flush), the worker blocks → deadlock with the event loop.
283
+ await self.db.commit()
284
+
285
+ # Enqueue the verify task via unified dispatch
286
+ from app.services.task_dispatch import enqueue_task
287
+
288
+ task = enqueue_task("verify_ticket", args=[job.id])
289
+
290
+ # Store the task ID (new transaction after commit)
291
+ job.celery_task_id = task.id
292
+ await self.db.flush()
293
+
294
+ logger.info(f"Auto-enqueued verify job {job.id} for ticket {ticket_id}")
295
+ return job.id
296
+
297
+ except Exception as e:
298
+ logger.error(
299
+ f"Failed to auto-enqueue verify job for ticket {ticket_id}: {e}"
300
+ )
301
+ return None
302
+
303
+ async def _cleanup_workspace_async(self, ticket_id: str) -> None:
304
+ """
305
+ Clean up the workspace for a ticket asynchronously.
306
+
307
+ Runs the blocking git operations in a thread pool executor.
308
+
309
+ Args:
310
+ ticket_id: The UUID of the ticket
311
+ """
312
+ loop = asyncio.get_event_loop()
313
+ try:
314
+ await loop.run_in_executor(
315
+ _cleanup_executor,
316
+ self._cleanup_workspace_sync,
317
+ ticket_id,
318
+ )
319
+ except Exception as e:
320
+ # Log but don't fail the transition if cleanup fails
321
+ logger.warning(f"Failed to cleanup workspace for ticket {ticket_id}: {e}")
322
+
323
+ @staticmethod
324
+ def _cleanup_workspace_sync(ticket_id: str) -> bool:
325
+ """
326
+ Synchronously clean up the workspace for a ticket.
327
+
328
+ Args:
329
+ ticket_id: The UUID of the ticket
330
+
331
+ Returns:
332
+ True if cleanup was performed, False otherwise
333
+ """
334
+ try:
335
+ with get_sync_db() as db:
336
+ workspace_service = WorkspaceService(db)
337
+ return workspace_service.cleanup_worktree(ticket_id)
338
+ except Exception as e:
339
+ logger.warning(f"Workspace cleanup error for ticket {ticket_id}: {e}")
340
+ return False
341
+
342
+ async def get_board(self, board_id: str | None = None) -> list[TicketsByState]:
343
+ """
344
+ Get tickets grouped by state for the board view.
345
+ Tickets are ordered by sort_order first (if set), then priority
346
+ (descending, nulls last) within each state.
347
+
348
+ Args:
349
+ board_id: Optional board ID to filter tickets. If None, returns all tickets.
350
+
351
+ Returns:
352
+ List of TicketsByState objects
353
+ """
354
+ query = (
355
+ select(Ticket)
356
+ .options(selectinload(Ticket.goal)) # Eagerly load goal to avoid N+1
357
+ .options(selectinload(Ticket.blocked_by)) # Eagerly load blocker ticket
358
+ .order_by(
359
+ Ticket.sort_order.asc().nulls_last(),
360
+ Ticket.priority.desc().nulls_last(),
361
+ Ticket.created_at.desc(),
362
+ )
363
+ )
364
+
365
+ # Filter by board_id if provided
366
+ if board_id is not None:
367
+ query = query.where(Ticket.board_id == board_id)
368
+
369
+ result = await self.db.execute(query)
370
+ tickets = result.scalars().all()
371
+
372
+ # Group tickets by state
373
+ tickets_by_state: dict[str, list[Ticket]] = defaultdict(list)
374
+ for ticket in tickets:
375
+ tickets_by_state[ticket.state].append(ticket)
376
+
377
+ # Build response with all states (even empty ones)
378
+ columns = []
379
+ for state in TicketState:
380
+ # Convert tickets to response format and add blocker titles
381
+ ticket_list = tickets_by_state.get(state.value, [])
382
+ ticket_responses = []
383
+ for ticket in ticket_list:
384
+ ticket_dict = TicketResponse.model_validate(ticket).model_dump()
385
+ # Add blocker title if ticket is blocked
386
+ if ticket.blocked_by_ticket_id and ticket.blocked_by:
387
+ ticket_dict["blocked_by_ticket_title"] = ticket.blocked_by.title
388
+ # Add goal title if goal is loaded
389
+ if ticket.goal:
390
+ ticket_dict["goal_title"] = ticket.goal.title
391
+ ticket_responses.append(ticket_dict)
392
+
393
+ columns.append(
394
+ TicketsByState(
395
+ state=state,
396
+ tickets=ticket_responses,
397
+ )
398
+ )
399
+
400
+ return columns
401
+
402
+ async def get_ticket_events(self, ticket_id: str) -> list[TicketEvent]:
403
+ """
404
+ Get all events for a ticket.
405
+
406
+ Args:
407
+ ticket_id: The UUID of the ticket
408
+
409
+ Returns:
410
+ List of TicketEvent instances ordered by created_at
411
+
412
+ Raises:
413
+ ResourceNotFoundError: If the ticket is not found
414
+ """
415
+ # First verify the ticket exists
416
+ await self.get_ticket_by_id(ticket_id)
417
+
418
+ result = await self.db.execute(
419
+ select(TicketEvent)
420
+ .where(TicketEvent.ticket_id == ticket_id)
421
+ .order_by(TicketEvent.created_at.asc())
422
+ )
423
+ return list(result.scalars().all())
424
+
425
+ async def delete_all_tickets(self, board_id: str | None = None) -> int:
426
+ """
427
+ Delete all tickets from the database.
428
+
429
+ This will cascade delete all associated:
430
+ - Jobs
431
+ - Revisions (and their review comments/summaries)
432
+ - Ticket events
433
+ - Workspaces
434
+ - Evidence
435
+
436
+ Args:
437
+ board_id: Optional board ID to limit deletion to specific board
438
+
439
+ Returns:
440
+ Number of tickets deleted
441
+ """
442
+ from sqlalchemy import delete
443
+
444
+ # Build query
445
+ query = select(Ticket)
446
+ if board_id:
447
+ query = query.where(Ticket.board_id == board_id)
448
+
449
+ # Get all ticket IDs for workspace cleanup
450
+ result = await self.db.execute(query)
451
+ tickets = result.scalars().all()
452
+ ticket_ids = [t.id for t in tickets]
453
+ count = len(ticket_ids)
454
+
455
+ if count == 0:
456
+ return 0
457
+
458
+ # Clean up workspaces asynchronously (best effort)
459
+ cleanup_tasks = []
460
+ for ticket_id in ticket_ids:
461
+ task = asyncio.create_task(self._cleanup_workspace_async(ticket_id))
462
+ cleanup_tasks.append(task)
463
+
464
+ # Wait for cleanup with timeout (don't block deletion if cleanup fails)
465
+ if cleanup_tasks:
466
+ try:
467
+ await asyncio.wait_for(
468
+ asyncio.gather(*cleanup_tasks, return_exceptions=True), timeout=30.0
469
+ )
470
+ except TimeoutError:
471
+ logger.warning(
472
+ "Workspace cleanup timed out during bulk ticket deletion"
473
+ )
474
+
475
+ # Delete all tickets (cascade will handle related records)
476
+ delete_query = delete(Ticket)
477
+ if board_id:
478
+ delete_query = delete_query.where(Ticket.board_id == board_id)
479
+
480
+ await self.db.execute(delete_query)
481
+ await self.db.commit()
482
+
483
+ logger.info(
484
+ f"Deleted {count} tickets" + (f" from board {board_id}" if board_id else "")
485
+ )
486
+ return count