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,574 @@
1
+ """API router for Goal endpoints."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
6
+ from fastapi.responses import JSONResponse
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.database import get_db
10
+ from app.schemas.common import PaginatedResponse
11
+ from app.schemas.goal import (
12
+ AutonomySettings,
13
+ AutonomyStatusResponse,
14
+ GoalCreate,
15
+ GoalListResponse,
16
+ GoalResponse,
17
+ GoalUpdate,
18
+ )
19
+ from app.schemas.planner import (
20
+ GenerateTicketsRequest,
21
+ GenerateTicketsResponse,
22
+ ReflectionResult,
23
+ )
24
+ from app.services.goal_service import GoalService
25
+ from app.services.ticket_generation_service import TicketGenerationService
26
+ from app.services.udar_planner_service import UDARPlannerService
27
+ from app.utils.ignored_fields import add_ignored_fields_header, check_ignored_fields
28
+
29
+ router = APIRouter(prefix="/goals", tags=["goals"])
30
+
31
+
32
+ @router.post(
33
+ "",
34
+ response_model=GoalResponse,
35
+ status_code=status.HTTP_201_CREATED,
36
+ summary="Create a new goal",
37
+ )
38
+ async def create_goal(
39
+ data: GoalCreate,
40
+ db: AsyncSession = Depends(get_db),
41
+ ) -> GoalResponse:
42
+ """Create a new goal."""
43
+ service = GoalService(db)
44
+ goal = await service.create_goal(data)
45
+ return GoalResponse.model_validate(goal)
46
+
47
+
48
+ @router.get(
49
+ "",
50
+ summary="List all goals",
51
+ )
52
+ async def list_goals(
53
+ board_id: str | None = None,
54
+ page: int | None = Query(
55
+ None, ge=1, description="Page number (1-based). Omit for all results."
56
+ ),
57
+ limit: int | None = Query(
58
+ None, ge=1, le=200, description="Items per page. Omit for all results."
59
+ ),
60
+ db: AsyncSession = Depends(get_db),
61
+ ) -> GoalListResponse | PaginatedResponse[GoalResponse]:
62
+ """Get all goals, optionally filtered by board_id.
63
+
64
+ **Pagination (optional):**
65
+ - If `page` and `limit` are provided, returns paginated response.
66
+ - If omitted, returns all goals (backward compatible).
67
+ """
68
+ service = GoalService(db)
69
+ goals = await service.get_goals(board_id=board_id)
70
+ all_responses = [GoalResponse.model_validate(g) for g in goals]
71
+
72
+ # If pagination params are provided, return paginated response
73
+ if page is not None and limit is not None:
74
+ total = len(all_responses)
75
+ offset = (page - 1) * limit
76
+ page_items = all_responses[offset : offset + limit]
77
+ return PaginatedResponse[GoalResponse](
78
+ items=page_items,
79
+ total=total,
80
+ page=page,
81
+ limit=limit,
82
+ )
83
+
84
+ # Backward compatible: return all
85
+ return GoalListResponse(
86
+ goals=all_responses,
87
+ total=len(all_responses),
88
+ )
89
+
90
+
91
+ @router.get(
92
+ "/{goal_id}",
93
+ response_model=GoalResponse,
94
+ summary="Get a goal by ID",
95
+ )
96
+ async def get_goal(
97
+ goal_id: str,
98
+ db: AsyncSession = Depends(get_db),
99
+ ) -> GoalResponse:
100
+ """Get a goal by its ID."""
101
+ service = GoalService(db)
102
+ goal = await service.get_goal_by_id(goal_id)
103
+ return GoalResponse.model_validate(goal)
104
+
105
+
106
+ @router.patch(
107
+ "/{goal_id}",
108
+ response_model=GoalResponse,
109
+ summary="Update a goal",
110
+ )
111
+ async def update_goal(
112
+ goal_id: str,
113
+ data: GoalUpdate,
114
+ db: AsyncSession = Depends(get_db),
115
+ ) -> GoalResponse:
116
+ """Update a goal with partial data. Supports updating autonomy settings."""
117
+ service = GoalService(db)
118
+ goal = await service.update_goal(goal_id, data)
119
+ await db.commit()
120
+ return GoalResponse.model_validate(goal)
121
+
122
+
123
+ @router.patch(
124
+ "/{goal_id}/autonomy",
125
+ response_model=GoalResponse,
126
+ summary="Update autonomy settings for a goal",
127
+ )
128
+ async def update_autonomy(
129
+ goal_id: str,
130
+ data: AutonomySettings,
131
+ db: AsyncSession = Depends(get_db),
132
+ ) -> GoalResponse:
133
+ """Update autonomy settings for a goal. Accepts partial updates."""
134
+ service = GoalService(db)
135
+ update_data = GoalUpdate(**data.model_dump())
136
+ goal = await service.update_goal(goal_id, update_data)
137
+ await db.commit()
138
+ return GoalResponse.model_validate(goal)
139
+
140
+
141
+ @router.delete(
142
+ "/{goal_id}",
143
+ status_code=status.HTTP_204_NO_CONTENT,
144
+ summary="Delete a goal and all its tickets",
145
+ )
146
+ async def delete_goal(
147
+ goal_id: str,
148
+ db: AsyncSession = Depends(get_db),
149
+ ) -> None:
150
+ """Delete a goal and cascade delete all associated tickets, jobs, evidence, etc."""
151
+ from sqlalchemy import delete as sql_delete
152
+
153
+ from app.models.goal import Goal
154
+
155
+ service = GoalService(db)
156
+ await service.get_goal_by_id(goal_id) # Verify exists
157
+ await db.execute(sql_delete(Goal).where(Goal.id == goal_id))
158
+ await db.commit()
159
+
160
+
161
+ @router.get(
162
+ "/{goal_id}/autonomy/status",
163
+ response_model=AutonomyStatusResponse,
164
+ summary="Get autonomy status for a goal",
165
+ )
166
+ async def get_autonomy_status(
167
+ goal_id: str,
168
+ db: AsyncSession = Depends(get_db),
169
+ ) -> AutonomyStatusResponse:
170
+ """Get autonomy status including settings, approval count, and budget info."""
171
+ service = GoalService(db)
172
+ goal = await service.get_goal_by_id(goal_id)
173
+
174
+ # Check budget remaining using CostTrackingService
175
+ budget_remaining = None
176
+ if goal.budget:
177
+ if goal.budget.total_budget is not None:
178
+ from app.services.cost_tracking_service import CostTrackingService
179
+
180
+ cost_service = CostTrackingService(db)
181
+ spent = await cost_service.get_goal_cost(goal_id)
182
+ budget_remaining = max(0.0, goal.budget.total_budget - spent)
183
+
184
+ return AutonomyStatusResponse(
185
+ goal_id=goal.id,
186
+ autonomy_enabled=goal.autonomy_enabled,
187
+ auto_approve_tickets=goal.auto_approve_tickets,
188
+ auto_approve_revisions=goal.auto_approve_revisions,
189
+ auto_merge=goal.auto_merge,
190
+ auto_approve_followups=goal.auto_approve_followups,
191
+ max_auto_approvals=goal.max_auto_approvals,
192
+ auto_approval_count=goal.auto_approval_count,
193
+ budget_remaining=budget_remaining,
194
+ )
195
+
196
+
197
+ @router.get(
198
+ "/{goal_id}/generate-tickets/stream",
199
+ summary="Generate tickets with streaming progress (SSE)",
200
+ )
201
+ async def generate_tickets_stream(
202
+ goal_id: str,
203
+ ):
204
+ """
205
+ Generate tickets with real-time streaming feedback using Server-Sent Events (SSE).
206
+
207
+ Uses its own DB session to survive client disconnects (EventSource reconnects).
208
+
209
+ The stream sends JSON events with the following types:
210
+ - status: Progress updates like "Analyzing codebase...", "Generating tickets..."
211
+ - agent_output: Real-time output from the agent CLI
212
+ - ticket: Each ticket as it's created
213
+ - complete: Final summary when done
214
+ - error: If something goes wrong
215
+ """
216
+ import asyncio
217
+ import json as json_lib
218
+ import logging
219
+
220
+ from fastapi.responses import StreamingResponse
221
+
222
+ from app.database import async_session_maker
223
+
224
+ logger = logging.getLogger(__name__)
225
+
226
+ async def event_generator():
227
+ try:
228
+ from app.services.cursor_log_normalizer import CursorLogNormalizer
229
+
230
+ # Send initial status
231
+ yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Starting ticket generation...'})}\n\n"
232
+ await asyncio.sleep(0.05)
233
+
234
+ # Use our own DB session (not request-scoped) so it survives SSE disconnects
235
+ async with async_session_maker() as db:
236
+ # Load config from goal's board (DB is source of truth)
237
+ from sqlalchemy import select as sa_select
238
+
239
+ from app.models.board import Board
240
+ from app.models.goal import Goal
241
+ from app.services.config_service import DraftConfig
242
+
243
+ yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Loading goal and board configuration...'})}\n\n"
244
+
245
+ goal_result = await db.execute(
246
+ sa_select(Goal).where(Goal.id == goal_id)
247
+ )
248
+ goal_obj = goal_result.scalar_one_or_none()
249
+ if not goal_obj:
250
+ yield f"data: {json_lib.dumps({'type': 'error', 'message': f'Goal not found: {goal_id}'})}\n\n"
251
+ return
252
+
253
+ board_config_dict = None
254
+ if goal_obj and goal_obj.board_id:
255
+ board_result = await db.execute(
256
+ sa_select(Board).where(Board.id == goal_obj.board_id)
257
+ )
258
+ board_obj = board_result.scalar_one_or_none()
259
+ if board_obj and board_obj.config:
260
+ board_config_dict = board_obj.config
261
+
262
+ config = DraftConfig.from_board_config(board_config_dict)
263
+
264
+ yield f"data: {json_lib.dumps({'type': 'status', 'message': f'Using model: {config.planner_config.model}'})}\n\n"
265
+
266
+ service = TicketGenerationService(db, config=config.planner_config)
267
+
268
+ # Create a queue for streaming agent output
269
+ output_queue: asyncio.Queue = asyncio.Queue()
270
+ loop = asyncio.get_running_loop()
271
+
272
+ # Normalizer to parse CLI JSON output into structured entries
273
+ normalizer = CursorLogNormalizer()
274
+
275
+ def stream_callback(line: str):
276
+ """Called from subprocess thread when agent outputs a line."""
277
+ try:
278
+ loop.call_soon_threadsafe(
279
+ output_queue.put_nowait, ("agent_output", line)
280
+ )
281
+ except Exception:
282
+ pass
283
+
284
+ yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Launching agent subprocess...'})}\n\n"
285
+
286
+ # Start generation task
287
+ generation_task = asyncio.create_task(
288
+ service.generate_from_goal(
289
+ goal_id=goal_id,
290
+ include_readme=False,
291
+ validate_tickets=config.planner_config.features.validate_tickets,
292
+ stream_callback=stream_callback,
293
+ )
294
+ )
295
+
296
+ def _normalize_and_yield(line: str):
297
+ """Parse a raw CLI line into normalized entries."""
298
+ entries = normalizer.process_line(line)
299
+ results = []
300
+ for entry in entries:
301
+ entry_data = {
302
+ "entry_type": entry.entry_type.value,
303
+ "content": entry.content,
304
+ "sequence": entry.sequence,
305
+ "tool_name": entry.tool_name,
306
+ "action_type": entry.action_type.value
307
+ if entry.action_type
308
+ else None,
309
+ "tool_status": entry.tool_status.value
310
+ if entry.tool_status
311
+ else None,
312
+ "metadata": entry.metadata or {},
313
+ "timestamp": None,
314
+ }
315
+ results.append(
316
+ f"data: {json_lib.dumps({'type': 'agent_normalized', 'entry': entry_data})}\n\n"
317
+ )
318
+ return results
319
+
320
+ # Stream agent output as it comes in
321
+ while not generation_task.done():
322
+ try:
323
+ msg_type, data = await asyncio.wait_for(
324
+ output_queue.get(), timeout=0.1
325
+ )
326
+ if msg_type == "agent_output":
327
+ normalized_chunks = _normalize_and_yield(data)
328
+ if normalized_chunks:
329
+ for chunk in normalized_chunks:
330
+ yield chunk
331
+ else:
332
+ yield f"data: {json_lib.dumps({'type': 'agent_output', 'message': data})}\n\n"
333
+ except TimeoutError:
334
+ continue
335
+
336
+ # Get final result
337
+ try:
338
+ result = await generation_task
339
+ except Exception as e:
340
+ logger.error(f"Ticket generation failed: {e}", exc_info=True)
341
+ yield f"data: {json_lib.dumps({'type': 'error', 'message': str(e)})}\n\n"
342
+ return
343
+
344
+ # Drain any remaining messages
345
+ while not output_queue.empty():
346
+ msg_type, data = await output_queue.get()
347
+ if msg_type == "agent_output":
348
+ normalized_chunks = _normalize_and_yield(data)
349
+ if normalized_chunks:
350
+ for chunk in normalized_chunks:
351
+ yield chunk
352
+ else:
353
+ yield f"data: {json_lib.dumps({'type': 'agent_output', 'message': data})}\n\n"
354
+
355
+ # Flush any remaining buffered entries from normalizer
356
+ for entry in normalizer.finalize():
357
+ entry_data = {
358
+ "entry_type": entry.entry_type.value,
359
+ "content": entry.content,
360
+ "sequence": entry.sequence,
361
+ "tool_name": entry.tool_name,
362
+ "action_type": entry.action_type.value
363
+ if entry.action_type
364
+ else None,
365
+ "tool_status": entry.tool_status.value
366
+ if entry.tool_status
367
+ else None,
368
+ "metadata": entry.metadata or {},
369
+ "timestamp": None,
370
+ }
371
+ yield f"data: {json_lib.dumps({'type': 'agent_normalized', 'entry': entry_data})}\n\n"
372
+
373
+ # Stream each created ticket
374
+ if result.tickets:
375
+ yield f"data: {json_lib.dumps({'type': 'status', 'message': f'Created {len(result.tickets)} ticket(s)'})}\n\n"
376
+ for ticket in result.tickets:
377
+ desc = ticket.description or ""
378
+ desc_short = desc[:150] + "..." if len(desc) > 150 else desc
379
+ ticket_data = {
380
+ "id": ticket.id,
381
+ "title": ticket.title,
382
+ "priority": ticket.priority,
383
+ "description": desc_short,
384
+ "blocked_by_title": getattr(
385
+ ticket, "blocked_by_title", None
386
+ ),
387
+ }
388
+ yield f"data: {json_lib.dumps({'type': 'ticket', 'ticket': ticket_data})}\n\n"
389
+ await asyncio.sleep(0.05)
390
+ else:
391
+ yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Agent finished but generated no tickets.'})}\n\n"
392
+
393
+ # Send completion (always — even for 0 tickets so frontend gets onComplete)
394
+ yield f"data: {json_lib.dumps({'type': 'complete', 'count': len(result.tickets)})}\n\n"
395
+
396
+ except ValueError as e:
397
+ yield f"data: {json_lib.dumps({'type': 'error', 'message': str(e)})}\n\n"
398
+ except Exception as e:
399
+ logger.error(f"Ticket generation stream error: {e}", exc_info=True)
400
+ yield f"data: {json_lib.dumps({'type': 'error', 'message': f'Generation failed: {str(e)}'})}\n\n"
401
+
402
+ return StreamingResponse(
403
+ event_generator(),
404
+ media_type="text/event-stream",
405
+ headers={
406
+ "Cache-Control": "no-cache",
407
+ "Connection": "keep-alive",
408
+ "X-Accel-Buffering": "no",
409
+ },
410
+ )
411
+
412
+
413
+ @router.post(
414
+ "/{goal_id}/generate-tickets",
415
+ summary="Generate proposed tickets using LLM planner",
416
+ )
417
+ async def generate_tickets(
418
+ goal_id: str,
419
+ raw_request: Request,
420
+ db: AsyncSession = Depends(get_db),
421
+ ):
422
+ """
423
+ Generate proposed tickets for a goal using AI planner.
424
+
425
+ The planner analyzes the goal and repository context to generate
426
+ 2-5 specific, actionable tickets with verification commands.
427
+
428
+ **Security:** Repository path is inferred from server config (draft.yaml),
429
+ NOT from client request. The `workspace_path` field is deprecated and ignored.
430
+ If sent, it will appear in X-Ignored-Fields response header.
431
+
432
+ **New in v2:** Tickets now include priority buckets (P0-P3) which are
433
+ normalized to numeric priorities (P0=90, P1=70, P2=50, P3=30).
434
+
435
+ Requires LLM API key environment variables (OPENAI_API_KEY, etc.).
436
+ """
437
+ import json
438
+
439
+ # Parse raw body to check for ignored fields
440
+ body = await raw_request.body()
441
+ try:
442
+ raw_body = json.loads(body) if body else {}
443
+ except json.JSONDecodeError:
444
+ raw_body = {}
445
+
446
+ # Check for ignored/deprecated fields
447
+ allowed_fields = {"include_readme"}
448
+ ignored_fields = check_ignored_fields(raw_request, raw_body, allowed_fields)
449
+
450
+ # Parse into Pydantic model
451
+ request = GenerateTicketsRequest(
452
+ **{k: v for k, v in raw_body.items() if k in allowed_fields}
453
+ )
454
+
455
+ # Get config from goal's board (DB is source of truth)
456
+ from sqlalchemy import select as sa_select
457
+
458
+ from app.models.board import Board
459
+ from app.models.goal import Goal
460
+ from app.services.config_service import DraftConfig
461
+
462
+ goal_result = await db.execute(sa_select(Goal).where(Goal.id == goal_id))
463
+ goal_obj = goal_result.scalar_one_or_none()
464
+ if not goal_obj:
465
+ raise HTTPException(status_code=404, detail=f"Goal not found: {goal_id}")
466
+
467
+ board_config_dict = None
468
+ repo_root = None
469
+ if goal_obj.board_id:
470
+ board_result = await db.execute(
471
+ sa_select(Board).where(Board.id == goal_obj.board_id)
472
+ )
473
+ board_obj = board_result.scalar_one_or_none()
474
+ if board_obj:
475
+ if board_obj.config:
476
+ board_config_dict = board_obj.config
477
+ repo_root = Path(board_obj.repo_root).resolve()
478
+
479
+ config = DraftConfig.from_board_config(board_config_dict)
480
+
481
+ if not repo_root or not repo_root.exists():
482
+ raise HTTPException(
483
+ status_code=500,
484
+ detail=f"Board repo_root does not exist: {repo_root}",
485
+ )
486
+
487
+ # Check if UDAR agent is enabled (Phase 2 feature flag)
488
+ if config.planner_config.udar.enabled:
489
+ # Use UDAR agent for adaptive ticket generation
490
+ udar_service = UDARPlannerService(db)
491
+ try:
492
+ udar_result = await udar_service.generate_from_goal(goal_id=goal_id)
493
+ # Convert UDAR result to expected format
494
+ result_tickets = [
495
+ {
496
+ "id": t.get("id"),
497
+ "title": t.get("title"),
498
+ "description": t.get("description"),
499
+ "priority": t.get("priority", 50),
500
+ "state": "proposed",
501
+ }
502
+ for t in udar_result.get("tickets", [])
503
+ ]
504
+ result = type("obj", (object,), {"tickets": result_tickets})()
505
+ except ValueError as e:
506
+ raise HTTPException(status_code=404, detail=str(e))
507
+ else:
508
+ # Use ticket generation service with board config
509
+ service = TicketGenerationService(db, config=config.planner_config)
510
+ try:
511
+ result = await service.generate_from_goal(
512
+ goal_id=goal_id,
513
+ repo_root=repo_root,
514
+ include_readme=request.include_readme,
515
+ validate_tickets=config.planner_config.features.validate_tickets,
516
+ )
517
+ except ValueError as e:
518
+ error_msg = str(e)
519
+ if (
520
+ "API key" in error_msg
521
+ or "credentials" in error_msg
522
+ or "unavailable" in error_msg
523
+ ):
524
+ raise HTTPException(status_code=503, detail=error_msg)
525
+ raise HTTPException(status_code=404, detail=error_msg)
526
+
527
+ # Build response
528
+ if len(result.tickets) == 0:
529
+ response_data = GenerateTicketsResponse(
530
+ tickets=[],
531
+ goal_id=goal_id,
532
+ )
533
+ else:
534
+ response_data = GenerateTicketsResponse(
535
+ tickets=result.tickets,
536
+ goal_id=goal_id,
537
+ )
538
+
539
+ # Build response with X-Ignored-Fields header if applicable
540
+ response = JSONResponse(content=response_data.model_dump())
541
+ add_ignored_fields_header(response, ignored_fields)
542
+
543
+ return response
544
+
545
+
546
+ @router.post(
547
+ "/{goal_id}/reflect-on-tickets",
548
+ response_model=ReflectionResult,
549
+ summary="Reflect on proposed tickets for quality and coverage",
550
+ )
551
+ async def reflect_on_tickets(
552
+ goal_id: str,
553
+ db: AsyncSession = Depends(get_db),
554
+ ) -> ReflectionResult:
555
+ """
556
+ Evaluate proposed tickets for a goal using AI reflection.
557
+
558
+ This endpoint analyzes the PROPOSED tickets for a goal and returns:
559
+ - **overall_quality**: "good", "needs_work", or "insufficient"
560
+ - **quality_notes**: Detailed assessment of ticket quality
561
+ - **coverage_gaps**: Areas not covered by current tickets
562
+ - **suggested_changes**: Recommended priority adjustments
563
+
564
+ **Important:** This endpoint does NOT apply changes. To apply suggested
565
+ priority changes, use `POST /tickets/bulk-update-priority` with the
566
+ suggested ticket IDs and new priority buckets.
567
+
568
+ This allows humans to review suggestions before applying them.
569
+ """
570
+ service = TicketGenerationService(db)
571
+ try:
572
+ return await service.reflect_on_proposals(goal_id)
573
+ except ValueError as e:
574
+ raise HTTPException(status_code=404, detail=str(e))