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,440 @@
1
+ """End-to-end autonomous delivery pipeline orchestration.
2
+
3
+ This service orchestrates the complete workflow:
4
+ Goal → Tickets → Execute → Verify → PR → Review → Merge
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from sqlalchemy import select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ from sqlalchemy.orm import selectinload
14
+
15
+ from app.exceptions import DraftError
16
+ from app.models.goal import Goal
17
+ from app.models.job import Job
18
+ from app.models.ticket import Ticket
19
+ from app.services.reliability_wrapper import ReliabilityWrapper, RetryConfig
20
+ from app.services.safe_autopilot import (
21
+ GateAction,
22
+ SafeAutopilot,
23
+ create_default_autopilot,
24
+ )
25
+ from app.state_machine import JobStatus, TicketState
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class PipelineResult:
32
+ """Result of running the full delivery pipeline."""
33
+
34
+ status: str # "ready_for_merge", "blocked", "in_progress"
35
+ reason: str | None = None
36
+ tickets_completed: list[str] = None
37
+ tickets_blocked: list[str] = None
38
+ pr_url: str | None = None
39
+ checklist_id: str | None = None
40
+ evidence: dict[str, Any] = None
41
+ total_cost_usd: float = 0.0
42
+
43
+ def __post_init__(self):
44
+ if self.tickets_completed is None:
45
+ self.tickets_completed = []
46
+ if self.tickets_blocked is None:
47
+ self.tickets_blocked = []
48
+ if self.evidence is None:
49
+ self.evidence = {}
50
+
51
+
52
+ class PipelineError(DraftError):
53
+ """Error during pipeline execution."""
54
+
55
+ pass
56
+
57
+
58
+ class DeliveryPipeline:
59
+ """
60
+ Orchestrates the complete autonomous delivery workflow.
61
+
62
+ This is the "autopilot" that takes a goal and delivers merge-ready code.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ db: AsyncSession,
68
+ retry_config: RetryConfig | None = None,
69
+ autopilot: SafeAutopilot | None = None,
70
+ ):
71
+ self.db = db
72
+ self.reliability_wrapper = ReliabilityWrapper(
73
+ db=db, retry_config=retry_config or RetryConfig(max_retries=3)
74
+ )
75
+ self.autopilot = autopilot or create_default_autopilot(db)
76
+
77
+ async def run_full_pipeline(
78
+ self, goal_id: str, auto_approve: bool = False, dry_run: bool = False
79
+ ) -> PipelineResult:
80
+ """Run the complete pipeline from goal to merge-ready state.
81
+
82
+ Args:
83
+ goal_id: The goal to deliver
84
+ auto_approve: If True, automatically approve all steps (YOLO mode)
85
+ dry_run: If True, simulate but don't actually execute
86
+
87
+ Returns:
88
+ PipelineResult with status and details
89
+ """
90
+ logger.info(
91
+ f"Starting delivery pipeline for goal {goal_id} (auto_approve={auto_approve}, dry_run={dry_run})"
92
+ )
93
+
94
+ try:
95
+ # Stage 1: Validate goal exists and get tickets
96
+ goal, tickets = await self._validate_and_load(goal_id)
97
+
98
+ if not tickets:
99
+ return PipelineResult(
100
+ status="blocked",
101
+ reason="No tickets found for this goal. Generate tickets first.",
102
+ )
103
+
104
+ # Stage 2: Topologically sort tickets by dependencies
105
+ sorted_tickets = self._topological_sort(tickets)
106
+ logger.info(f"Execution order: {[t.id for t in sorted_tickets]}")
107
+
108
+ # Stage 3: Execute tickets in order with safety gates
109
+ completed = []
110
+ blocked = []
111
+
112
+ for ticket in sorted_tickets:
113
+ if dry_run:
114
+ logger.info(
115
+ f"[DRY RUN] Would execute ticket {ticket.id}: {ticket.title}"
116
+ )
117
+ completed.append(ticket.id)
118
+ continue
119
+
120
+ # Execute ticket
121
+ result = await self._execute_with_retry(ticket, max_retries=2)
122
+
123
+ if result["status"] == "success":
124
+ completed.append(ticket.id)
125
+
126
+ # Check safety gates after successful execution
127
+ can_continue, gate_results = await self.autopilot.should_continue(
128
+ ticket
129
+ )
130
+
131
+ if not can_continue:
132
+ # Find blocking or pausing gates
133
+ blocking_gates = [
134
+ r
135
+ for r in gate_results
136
+ if not r.passed
137
+ and r.action in [GateAction.BLOCK, GateAction.PAUSE]
138
+ ]
139
+
140
+ reasons = [f"{r.gate_name}: {r.reason}" for r in blocking_gates]
141
+
142
+ logger.warning(
143
+ f"Safety gates triggered for ticket {ticket.id}: {', '.join(reasons)}"
144
+ )
145
+
146
+ if not auto_approve:
147
+ return PipelineResult(
148
+ status="blocked",
149
+ reason=f"Safety gates failed: {', '.join(reasons)}",
150
+ tickets_completed=completed,
151
+ tickets_blocked=[ticket.id],
152
+ )
153
+
154
+ # Log any alert-level gate failures
155
+ alert_gates = [
156
+ r
157
+ for r in gate_results
158
+ if not r.passed and r.action == GateAction.ALERT
159
+ ]
160
+ for alert in alert_gates:
161
+ logger.warning(
162
+ f"Gate alert for ticket {ticket.id}: {alert.reason}"
163
+ )
164
+
165
+ else:
166
+ blocked.append(ticket.id)
167
+ # If one ticket fails and not auto_approve, stop here
168
+ if not auto_approve:
169
+ return PipelineResult(
170
+ status="blocked",
171
+ reason=f"Ticket {ticket.id} failed: {result.get('reason')}",
172
+ tickets_completed=completed,
173
+ tickets_blocked=blocked,
174
+ )
175
+
176
+ # Stage 4: Verify all tickets passed
177
+ if not dry_run:
178
+ verification = await self._verify_all(tickets)
179
+ if not verification["passed"]:
180
+ return PipelineResult(
181
+ status="blocked",
182
+ reason=f"Verification failed: {verification['failures']}",
183
+ tickets_completed=completed,
184
+ tickets_blocked=blocked,
185
+ )
186
+
187
+ # Stage 5: Collect evidence for review
188
+ evidence = await self._collect_evidence(tickets)
189
+
190
+ # Stage 6: Calculate total cost
191
+ total_cost = await self._calculate_total_cost(goal_id)
192
+
193
+ return PipelineResult(
194
+ status="ready_for_merge",
195
+ tickets_completed=completed,
196
+ evidence=evidence,
197
+ total_cost_usd=total_cost,
198
+ )
199
+
200
+ except Exception as e:
201
+ logger.exception(f"Pipeline failed for goal {goal_id}")
202
+ raise PipelineError(f"Pipeline execution failed: {str(e)}") from e
203
+
204
+ async def _validate_and_load(self, goal_id: str) -> tuple[Goal, list[Ticket]]:
205
+ """Validate goal exists and load all its tickets."""
206
+ result = await self.db.execute(
207
+ select(Goal).where(Goal.id == goal_id).options(selectinload(Goal.tickets))
208
+ )
209
+ goal = result.scalar_one_or_none()
210
+
211
+ if not goal:
212
+ raise PipelineError(f"Goal {goal_id} not found")
213
+
214
+ # Get all tickets for this goal
215
+ ticket_result = await self.db.execute(
216
+ select(Ticket)
217
+ .where(Ticket.goal_id == goal_id)
218
+ .options(selectinload(Ticket.jobs))
219
+ )
220
+ tickets = list(ticket_result.scalars().all())
221
+
222
+ return goal, tickets
223
+
224
+ def _topological_sort(self, tickets: list[Ticket]) -> list[Ticket]:
225
+ """Sort tickets by dependencies (topological order).
226
+
227
+ Tickets with no dependencies come first.
228
+ Blocked tickets come after their blockers.
229
+ """
230
+ # Build dependency graph
231
+ ticket_map = {t.id: t for t in tickets}
232
+ in_degree = {t.id: 0 for t in tickets}
233
+ graph = {t.id: [] for t in tickets}
234
+
235
+ for ticket in tickets:
236
+ if ticket.blocked_by_ticket_id:
237
+ if ticket.blocked_by_ticket_id in graph:
238
+ graph[ticket.blocked_by_ticket_id].append(ticket.id)
239
+ in_degree[ticket.id] += 1
240
+
241
+ # Kahn's algorithm for topological sort
242
+ queue = [tid for tid, degree in in_degree.items() if degree == 0]
243
+ sorted_ids = []
244
+
245
+ while queue:
246
+ # Sort by priority within the queue
247
+ queue.sort(key=lambda tid: ticket_map[tid].priority or 0, reverse=True)
248
+ current = queue.pop(0)
249
+ sorted_ids.append(current)
250
+
251
+ for neighbor in graph[current]:
252
+ in_degree[neighbor] -= 1
253
+ if in_degree[neighbor] == 0:
254
+ queue.append(neighbor)
255
+
256
+ # Check for cycles
257
+ if len(sorted_ids) != len(tickets):
258
+ logger.warning(
259
+ "Cycle detected in ticket dependencies, using original order"
260
+ )
261
+ return tickets
262
+
263
+ return [ticket_map[tid] for tid in sorted_ids]
264
+
265
+ async def _execute_with_retry(
266
+ self, ticket: Ticket, max_retries: int = 2
267
+ ) -> dict[str, Any]:
268
+ """Execute a ticket with automatic retry, checkpointing, and recovery.
269
+
270
+ Uses ReliabilityWrapper for robust execution with:
271
+ - Exponential backoff retry
272
+ - Checkpointing for resume capability
273
+ - Intelligent error classification
274
+
275
+ Returns:
276
+ Dict with status and reason
277
+ """
278
+ from app.services.job_service import JobService
279
+
280
+ # Check if ticket is in correct state
281
+ if ticket.state not in [TicketState.PLANNED.value, TicketState.BLOCKED.value]:
282
+ return {
283
+ "status": "skipped",
284
+ "reason": f"Ticket in state {ticket.state}, not ready for execution",
285
+ }
286
+
287
+ job_service = JobService(self.db)
288
+
289
+ async def execute_ticket_with_job():
290
+ """Inner function that creates job and executes ticket."""
291
+ # Create execution job
292
+ job = await job_service.create_job(
293
+ ticket_id=ticket.id, job_type="execute", board_id=ticket.board_id
294
+ )
295
+
296
+ logger.info(f"Executing ticket {ticket.id} with job {job.id}")
297
+
298
+ # TODO: Actually call the Celery task and wait for completion
299
+ # For now, just mark as success if job created
300
+
301
+ return {"status": "success", "job_id": job.id}
302
+
303
+ try:
304
+ # Execute with reliability wrapper (automatic retry, checkpointing)
305
+ result = await self.reliability_wrapper.execute_with_reliability(
306
+ func=execute_ticket_with_job,
307
+ ticket_id=ticket.id,
308
+ job_id=None, # Job created inside function
309
+ checkpoint_key=f"pipeline:execute:{ticket.id}",
310
+ )
311
+
312
+ return result
313
+
314
+ except Exception as e:
315
+ logger.error(f"Ticket {ticket.id} execution failed after all retries: {e}")
316
+ return {"status": "failed", "reason": str(e)}
317
+
318
+ async def _verify_all(self, tickets: list[Ticket]) -> dict[str, Any]:
319
+ """Run verification for all tickets and aggregate results.
320
+
321
+ Returns:
322
+ Dict with passed flag and list of failures
323
+ """
324
+ from app.services.job_service import JobService
325
+
326
+ JobService(self.db)
327
+ failures = []
328
+
329
+ for ticket in tickets:
330
+ # Get most recent verification job
331
+ result = await self.db.execute(
332
+ select(Job)
333
+ .where(Job.ticket_id == ticket.id)
334
+ .where(Job.job_type == "verify")
335
+ .order_by(Job.created_at.desc())
336
+ .limit(1)
337
+ )
338
+ verify_job = result.scalar_one_or_none()
339
+
340
+ if not verify_job:
341
+ failures.append(f"Ticket {ticket.id}: No verification run")
342
+ continue
343
+
344
+ if verify_job.status != JobStatus.SUCCEEDED.value:
345
+ failures.append(f"Ticket {ticket.id}: Verification {verify_job.status}")
346
+
347
+ return {"passed": len(failures) == 0, "failures": failures}
348
+
349
+ async def _collect_evidence(self, tickets: list[Ticket]) -> dict[str, Any]:
350
+ """Collect all evidence (diffs, tests, logs) for tickets.
351
+
352
+ Returns:
353
+ Dict with evidence summary
354
+ """
355
+ evidence = {
356
+ "total_tickets": len(tickets),
357
+ "files_changed": [],
358
+ "tests_run": 0,
359
+ "tests_passed": 0,
360
+ "diffs": [],
361
+ }
362
+
363
+ for ticket in tickets:
364
+ # Get all jobs for this ticket
365
+ result = await self.db.execute(
366
+ select(Job)
367
+ .where(Job.ticket_id == ticket.id)
368
+ .options(selectinload(Job.evidence))
369
+ )
370
+ jobs = list(result.scalars().all())
371
+
372
+ for job in jobs:
373
+ if job.evidence:
374
+ evidence["diffs"].extend(
375
+ [
376
+ {
377
+ "ticket_id": ticket.id,
378
+ "job_id": job.id,
379
+ "evidence_id": e.id,
380
+ "type": e.evidence_type,
381
+ }
382
+ for e in job.evidence
383
+ ]
384
+ )
385
+
386
+ return evidence
387
+
388
+ async def _calculate_total_cost(self, goal_id: str) -> float:
389
+ """Calculate total LLM API cost for all tickets in goal.
390
+
391
+ Returns:
392
+ Total cost in USD
393
+ """
394
+ # TODO: Implement cost tracking aggregation
395
+ # For now, return 0
396
+ return 0.0
397
+
398
+
399
+ async def get_pipeline_status(db: AsyncSession, goal_id: str) -> dict[str, Any]:
400
+ """Get the current status of the delivery pipeline for a goal.
401
+
402
+ Returns:
403
+ Dict with pipeline status, progress, and blocking issues
404
+ """
405
+ result = await db.execute(select(Ticket).where(Ticket.goal_id == goal_id))
406
+ tickets = list(result.scalars().all())
407
+
408
+ if not tickets:
409
+ return {"status": "not_started", "reason": "No tickets generated yet"}
410
+
411
+ state_counts = {}
412
+ for ticket in tickets:
413
+ state = ticket.state
414
+ state_counts[state] = state_counts.get(state, 0) + 1
415
+
416
+ total = len(tickets)
417
+ completed = state_counts.get(TicketState.DONE.value, 0)
418
+ blocked = state_counts.get(TicketState.BLOCKED.value, 0)
419
+ executing = state_counts.get(TicketState.EXECUTING.value, 0) + state_counts.get(
420
+ TicketState.VERIFYING.value, 0
421
+ )
422
+
423
+ if blocked > 0:
424
+ status = "blocked"
425
+ elif executing > 0:
426
+ status = "in_progress"
427
+ elif completed == total:
428
+ status = "ready_for_merge"
429
+ else:
430
+ status = "ready_to_execute"
431
+
432
+ return {
433
+ "status": status,
434
+ "total_tickets": total,
435
+ "completed": completed,
436
+ "blocked": blocked,
437
+ "executing": executing,
438
+ "progress_percent": int((completed / total) * 100) if total > 0 else 0,
439
+ "state_breakdown": state_counts,
440
+ }