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,448 @@
1
+ """API router for Job endpoints."""
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
4
+ from fastapi.responses import PlainTextResponse
5
+ from sqlalchemy import func, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sse_starlette.sse import EventSourceResponse
8
+
9
+ from app.database import get_db
10
+ from app.exceptions import ResourceNotFoundError
11
+ from app.models.job import Job, JobKind
12
+ from app.schemas.common import PaginatedResponse
13
+ from app.schemas.job import (
14
+ CancelJobResponse,
15
+ JobCreateResponse,
16
+ JobDetailResponse,
17
+ JobResponse,
18
+ JobStatus,
19
+ QueueStatusResponse,
20
+ )
21
+ from app.services.job_service import JobService
22
+ from app.services.log_normalizer import LogNormalizerService
23
+ from app.services.log_stream_service import LogLevel, log_stream_service
24
+
25
+ router = APIRouter(prefix="/jobs", tags=["jobs"])
26
+
27
+
28
+ @router.get(
29
+ "",
30
+ response_model=PaginatedResponse[JobResponse],
31
+ summary="List historical jobs with filters",
32
+ )
33
+ async def list_jobs(
34
+ ticket_id: str | None = Query(None, description="Filter by ticket ID"),
35
+ job_status: JobStatus | None = Query(
36
+ None, alias="status", description="Filter by job status"
37
+ ),
38
+ kind: str | None = Query(None, description="Filter by job kind"),
39
+ board_id: str | None = Query(None, description="Filter by board ID"),
40
+ page: int = Query(1, ge=1, description="Page number (1-based)"),
41
+ limit: int = Query(50, ge=1, le=200, description="Items per page"),
42
+ db: AsyncSession = Depends(get_db),
43
+ ) -> PaginatedResponse[JobResponse]:
44
+ """
45
+ List historical jobs with optional filters and pagination.
46
+
47
+ **Filters:**
48
+ - `ticket_id`: Filter by ticket
49
+ - `status`: Filter by status (queued, running, succeeded, failed, canceled)
50
+ - `kind`: Filter by kind (execute, verify, resume)
51
+ - `board_id`: Filter by board
52
+
53
+ **Pagination:**
54
+ - `page`: Page number (1-based, default 1)
55
+ - `limit`: Items per page (default 50, max 200)
56
+
57
+ Results are ordered by creation time descending (newest first).
58
+ """
59
+ query = select(Job)
60
+ count_query = select(func.count(Job.id))
61
+
62
+ # Apply filters
63
+ if ticket_id is not None:
64
+ query = query.where(Job.ticket_id == ticket_id)
65
+ count_query = count_query.where(Job.ticket_id == ticket_id)
66
+ if job_status is not None:
67
+ query = query.where(Job.status == job_status.value)
68
+ count_query = count_query.where(Job.status == job_status.value)
69
+ if kind is not None:
70
+ query = query.where(Job.kind == kind)
71
+ count_query = count_query.where(Job.kind == kind)
72
+ if board_id is not None:
73
+ query = query.where(Job.board_id == board_id)
74
+ count_query = count_query.where(Job.board_id == board_id)
75
+
76
+ # Get total count
77
+ total_result = await db.execute(count_query)
78
+ total = total_result.scalar() or 0
79
+
80
+ # Apply ordering and pagination
81
+ offset = (page - 1) * limit
82
+ query = query.order_by(Job.created_at.desc()).offset(offset).limit(limit)
83
+
84
+ result = await db.execute(query)
85
+ jobs = result.scalars().all()
86
+
87
+ items = [
88
+ JobResponse(
89
+ id=job.id,
90
+ ticket_id=job.ticket_id,
91
+ kind=job.kind_enum,
92
+ status=job.status_enum,
93
+ created_at=job.created_at,
94
+ started_at=job.started_at,
95
+ finished_at=job.finished_at,
96
+ exit_code=job.exit_code,
97
+ log_path=job.log_path,
98
+ )
99
+ for job in jobs
100
+ ]
101
+
102
+ return PaginatedResponse[JobResponse](
103
+ items=items,
104
+ total=total,
105
+ page=page,
106
+ limit=limit,
107
+ )
108
+
109
+
110
+ @router.get(
111
+ "/queue",
112
+ response_model=QueueStatusResponse,
113
+ summary="Get queue status with running and queued jobs",
114
+ )
115
+ async def get_queue_status(
116
+ db: AsyncSession = Depends(get_db),
117
+ ) -> QueueStatusResponse:
118
+ """
119
+ Get the current queue status showing which agents/jobs are running
120
+ and which jobs are waiting in the queue.
121
+
122
+ Returns:
123
+ - running: List of currently running jobs with ticket info
124
+ - queued: List of queued jobs in order (first = next to run)
125
+ """
126
+ service = JobService(db)
127
+ return await service.get_queue_status()
128
+
129
+
130
+ @router.get(
131
+ "/{job_id}",
132
+ response_model=JobDetailResponse,
133
+ summary="Get a job by ID",
134
+ )
135
+ async def get_job(
136
+ job_id: str,
137
+ db: AsyncSession = Depends(get_db),
138
+ ) -> JobDetailResponse:
139
+ """
140
+ Get a job by its ID, including log content if available.
141
+ """
142
+ service = JobService(db)
143
+ job = await service.get_job_by_id(job_id)
144
+ # Use async version to avoid blocking event loop during file I/O
145
+ logs = await service.read_job_logs_async(job.log_path)
146
+
147
+ return JobDetailResponse(
148
+ id=job.id,
149
+ ticket_id=job.ticket_id,
150
+ kind=job.kind_enum,
151
+ status=job.status_enum,
152
+ created_at=job.created_at,
153
+ started_at=job.started_at,
154
+ finished_at=job.finished_at,
155
+ exit_code=job.exit_code,
156
+ log_path=job.log_path,
157
+ logs=logs,
158
+ )
159
+
160
+
161
+ @router.get(
162
+ "/{job_id}/logs",
163
+ response_class=PlainTextResponse,
164
+ summary="Get raw logs for a job",
165
+ )
166
+ async def get_job_logs(
167
+ job_id: str,
168
+ db: AsyncSession = Depends(get_db),
169
+ ) -> PlainTextResponse:
170
+ """
171
+ Get the raw log content for a job as plain text.
172
+ """
173
+ service = JobService(db)
174
+ job = await service.get_job_by_id(job_id)
175
+ # Use async version to avoid blocking event loop during file I/O
176
+ logs = await service.read_job_logs_async(job.log_path)
177
+
178
+ if logs is None:
179
+ return PlainTextResponse(content="No logs available yet.", status_code=200)
180
+
181
+ return PlainTextResponse(content=logs)
182
+
183
+
184
+ @router.get(
185
+ "/{job_id}/logs/stream",
186
+ summary="Stream logs in real-time via SSE",
187
+ )
188
+ async def stream_job_logs(
189
+ job_id: str,
190
+ db: AsyncSession = Depends(get_db),
191
+ ) -> EventSourceResponse:
192
+ """
193
+ Stream job logs in real-time using Server-Sent Events (SSE).
194
+
195
+ This provides instant feedback during job execution - similar to
196
+ vibe-kanban's WebSocket streaming but using SSE for simplicity.
197
+
198
+ Events:
199
+ - stdout: Standard output from executor
200
+ - stderr: Standard error from executor
201
+ - info: Informational messages
202
+ - error: Error messages
203
+ - finished: Job has completed
204
+
205
+ The stream will:
206
+ 1. First send all historical messages (catch-up)
207
+ 2. Then stream live updates as they happen
208
+ 3. Close when job finishes or client disconnects
209
+
210
+ Example client usage (JavaScript):
211
+ const es = new EventSource('/api/jobs/{job_id}/logs/stream');
212
+ es.addEventListener('stdout', (e) => console.log(e.data));
213
+ es.addEventListener('finished', () => es.close());
214
+ """
215
+ # Verify job exists
216
+ service = JobService(db)
217
+ await service.get_job_by_id(job_id)
218
+
219
+ async def event_generator():
220
+ """Generate SSE events from log stream."""
221
+ import json
222
+
223
+ try:
224
+ async for msg in log_stream_service.subscribe(job_id):
225
+ # For progress events, include metadata as JSON
226
+ if msg.level == LogLevel.PROGRESS:
227
+ data = json.dumps(
228
+ {
229
+ "content": msg.content,
230
+ "progress_pct": msg.progress_pct,
231
+ "stage": msg.stage,
232
+ }
233
+ )
234
+ else:
235
+ data = msg.content
236
+
237
+ yield {
238
+ "event": msg.level.value,
239
+ "data": data,
240
+ }
241
+ except Exception as e:
242
+ yield {
243
+ "event": "error",
244
+ "data": f"Stream error: {str(e)}",
245
+ }
246
+
247
+ return EventSourceResponse(
248
+ event_generator(),
249
+ ping=15, # Send keepalive every 15 seconds
250
+ )
251
+
252
+
253
+ @router.post(
254
+ "/{job_id}/cancel",
255
+ response_model=CancelJobResponse,
256
+ summary="Cancel a job (best-effort)",
257
+ )
258
+ async def cancel_job(
259
+ job_id: str,
260
+ db: AsyncSession = Depends(get_db),
261
+ ) -> CancelJobResponse:
262
+ """
263
+ Cancel a job (best-effort).
264
+
265
+ This will mark the job as canceled in the database and attempt to
266
+ revoke the Celery task. If the task is already running, it may
267
+ complete before the cancellation takes effect.
268
+ """
269
+ service = JobService(db)
270
+ job = await service.cancel_job(job_id)
271
+
272
+ # Determine message based on original vs new status
273
+ if job.status == JobStatus.CANCELED.value:
274
+ message = "Job cancellation requested"
275
+ else:
276
+ message = f"Job already in terminal state: {job.status}"
277
+
278
+ return CancelJobResponse(
279
+ id=job.id,
280
+ status=job.status_enum,
281
+ message=message,
282
+ )
283
+
284
+
285
+ @router.post(
286
+ "/{job_id}/retry",
287
+ response_model=JobCreateResponse,
288
+ status_code=status.HTTP_201_CREATED,
289
+ summary="Retry a failed job",
290
+ )
291
+ async def retry_job(
292
+ job_id: str,
293
+ db: AsyncSession = Depends(get_db),
294
+ ) -> JobCreateResponse:
295
+ """
296
+ Retry a failed job by creating a new job with the same kind.
297
+
298
+ This will:
299
+ 1. Verify the original job exists and is in a terminal state (FAILED/CANCELED)
300
+ 2. Create a new job with the same kind for the same ticket
301
+ 3. Enqueue the new job to Celery
302
+
303
+ Note: This creates a NEW job (new ID), it does not reuse the old job.
304
+ """
305
+ service = JobService(db)
306
+
307
+ try:
308
+ original_job = await service.get_job_by_id(job_id)
309
+ except ResourceNotFoundError:
310
+ raise HTTPException(
311
+ status_code=status.HTTP_404_NOT_FOUND,
312
+ detail=f"Job {job_id} not found",
313
+ )
314
+
315
+ # Verify job is in terminal state
316
+ if original_job.status not in [JobStatus.FAILED.value, JobStatus.CANCELED.value]:
317
+ raise HTTPException(
318
+ status_code=status.HTTP_409_CONFLICT,
319
+ detail=f"Can only retry FAILED or CANCELED jobs. Current status: {original_job.status}",
320
+ )
321
+
322
+ # Create new job with same kind
323
+ try:
324
+ new_job = await service.create_job(
325
+ ticket_id=original_job.ticket_id,
326
+ kind=JobKind(original_job.kind),
327
+ )
328
+ await db.commit()
329
+ except Exception as e:
330
+ raise HTTPException(
331
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
332
+ detail=f"Failed to create retry job: {str(e)}",
333
+ )
334
+
335
+ return JobCreateResponse(
336
+ id=new_job.id,
337
+ ticket_id=new_job.ticket_id,
338
+ kind=new_job.kind_enum,
339
+ status=new_job.status_enum,
340
+ created_at=new_job.created_at,
341
+ started_at=new_job.started_at,
342
+ finished_at=new_job.finished_at,
343
+ exit_code=new_job.exit_code,
344
+ log_path=new_job.log_path,
345
+ celery_task_id=new_job.celery_task_id,
346
+ )
347
+
348
+
349
+ @router.get(
350
+ "/{job_id}/normalized-logs",
351
+ summary="Get normalized, structured logs for a job",
352
+ )
353
+ async def get_normalized_logs(
354
+ job_id: str,
355
+ db: AsyncSession = Depends(get_db),
356
+ ) -> list[dict]:
357
+ """
358
+ Get normalized, structured logs for a job.
359
+
360
+ Returns a list of structured log entries parsed from raw agent output.
361
+ Each entry has a semantic type (thinking, file_edit, command_run, etc.)
362
+ and structured metadata for rich UI rendering.
363
+
364
+ If normalized logs don't exist yet, returns an empty list.
365
+ """
366
+ service = JobService(db)
367
+
368
+ # Verify job exists
369
+ try:
370
+ await service.get_job_by_id(job_id)
371
+ except ResourceNotFoundError:
372
+ raise HTTPException(
373
+ status_code=status.HTTP_404_NOT_FOUND,
374
+ detail=f"Job {job_id} not found",
375
+ )
376
+
377
+ # Get normalized logs
378
+ normalizer = LogNormalizerService()
379
+ logs = await normalizer.get_normalized_logs(db, job_id)
380
+
381
+ return [log.to_dict() for log in logs]
382
+
383
+
384
+ @router.post(
385
+ "/{job_id}/normalize-logs",
386
+ summary="Parse and normalize logs for a job",
387
+ )
388
+ async def normalize_logs(
389
+ job_id: str,
390
+ agent_type: str = "claude",
391
+ db: AsyncSession = Depends(get_db),
392
+ ) -> dict:
393
+ """
394
+ Parse raw logs and store normalized entries.
395
+
396
+ This endpoint manually triggers log normalization. Normally this happens
397
+ automatically after job completion, but this can be used to:
398
+ - Re-parse logs with updated parser logic
399
+ - Parse logs for old jobs that weren't normalized
400
+ - Test parser changes
401
+
402
+ Args:
403
+ agent_type: Type of agent (claude, cursor, etc.) - determines parser
404
+ """
405
+ service = JobService(db)
406
+
407
+ # Get job
408
+ try:
409
+ job = await service.get_job_by_id(job_id)
410
+ except ResourceNotFoundError:
411
+ raise HTTPException(
412
+ status_code=status.HTTP_404_NOT_FOUND,
413
+ detail=f"Job {job_id} not found",
414
+ )
415
+
416
+ # Get raw logs (use async version to avoid blocking event loop)
417
+ raw_logs = await service.read_job_logs_async(job.log_path)
418
+ if not raw_logs:
419
+ raise HTTPException(
420
+ status_code=status.HTTP_400_BAD_REQUEST,
421
+ detail="No logs available to normalize",
422
+ )
423
+
424
+ # Delete existing normalized logs (if any)
425
+ from sqlalchemy import delete
426
+
427
+ from app.models.normalized_log import NormalizedLogEntry
428
+
429
+ await db.execute(
430
+ delete(NormalizedLogEntry).where(NormalizedLogEntry.job_id == job_id)
431
+ )
432
+ await db.commit()
433
+
434
+ # Normalize and store
435
+ normalizer = LogNormalizerService()
436
+ try:
437
+ entries = await normalizer.normalize_and_store(db, job_id, raw_logs, agent_type)
438
+ return {
439
+ "success": True,
440
+ "job_id": job_id,
441
+ "entries_created": len(entries),
442
+ "message": f"Successfully normalized {len(entries)} log entries",
443
+ }
444
+ except Exception as e:
445
+ raise HTTPException(
446
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
447
+ detail=f"Failed to normalize logs: {str(e)}",
448
+ )
@@ -0,0 +1,172 @@
1
+ """API router for maintenance operations."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from pydantic import BaseModel
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.database import get_db
8
+ from app.schemas.merge import CleanupRequest, CleanupResponse
9
+ from app.services.cleanup_service import CleanupService
10
+
11
+ router = APIRouter(prefix="/maintenance", tags=["maintenance"])
12
+
13
+
14
+ class WatchdogResponse(BaseModel):
15
+ """Response from watchdog run."""
16
+
17
+ stale_jobs_recovered: int
18
+ timed_out_jobs_recovered: int
19
+ stuck_queued_jobs_failed: int
20
+ lost_tasks_reenqueued: int = 0 # Jobs re-enqueued due to lost Celery tasks
21
+ tickets_blocked: int
22
+ details: list[str]
23
+
24
+
25
+ @router.post(
26
+ "/cleanup",
27
+ response_model=CleanupResponse,
28
+ summary="Run cleanup of stale worktrees and old evidence",
29
+ )
30
+ async def run_cleanup(
31
+ data: CleanupRequest,
32
+ db: AsyncSession = Depends(get_db),
33
+ ) -> CleanupResponse:
34
+ """
35
+ Run cleanup of stale worktrees and old evidence files.
36
+
37
+ By default runs in dry_run mode, which only reports what would be deleted.
38
+ Set dry_run=false to actually perform deletions.
39
+
40
+ Cleanup rules (from draft.yaml cleanup_config):
41
+ - worktree_ttl_days: Delete worktrees older than this
42
+ - evidence_ttl_days: Delete evidence files older than this
43
+ - max_worktrees: Maximum number of active worktrees (not enforced yet)
44
+
45
+ Safety:
46
+ - Only deletes files under .draft/
47
+ - Uses `git worktree remove` + `git worktree prune`
48
+ - Never deletes worktrees for tickets in executing/verifying/needs_human
49
+ - Creates audit events for deletions
50
+ - Orphaned directories (not in DB) are also cleaned up
51
+ """
52
+ service = CleanupService(db)
53
+
54
+ result = await service.run_full_cleanup(
55
+ dry_run=data.dry_run,
56
+ delete_worktrees=data.delete_worktrees,
57
+ delete_evidence=data.delete_evidence,
58
+ )
59
+
60
+ return CleanupResponse(
61
+ dry_run=data.dry_run,
62
+ worktrees_deleted=result.worktrees_deleted,
63
+ worktrees_failed=result.worktrees_failed,
64
+ worktrees_skipped=result.worktrees_skipped,
65
+ evidence_files_deleted=result.evidence_files_deleted,
66
+ evidence_files_failed=result.evidence_files_failed,
67
+ bytes_freed=result.bytes_freed,
68
+ details=result.details,
69
+ )
70
+
71
+
72
+ class ReenqueueResponse(BaseModel):
73
+ """Response from re-enqueue operation."""
74
+
75
+ jobs_reenqueued: int
76
+ details: list[str]
77
+
78
+
79
+ @router.post(
80
+ "/reenqueue-lost-jobs",
81
+ response_model=ReenqueueResponse,
82
+ summary="[DEV ONLY] Re-enqueue jobs that are queued in DB but missing from Celery",
83
+ )
84
+ async def reenqueue_lost_jobs(
85
+ db: AsyncSession = Depends(get_db),
86
+ ) -> ReenqueueResponse:
87
+ """
88
+ Re-enqueue jobs that are stuck in QUEUED status but missing from the Celery queue.
89
+
90
+ This can happen if:
91
+ - Redis was restarted/flushed
92
+ - The Celery worker was down when jobs were created
93
+ - Task messages were lost
94
+
95
+ For each QUEUED job, this re-sends the Celery task and updates the celery_task_id.
96
+ """
97
+ from sqlalchemy import select
98
+
99
+ from app.models.job import Job, JobKind, JobStatus
100
+ from app.services.task_dispatch import enqueue_task
101
+
102
+ result = await db.execute(select(Job).where(Job.status == JobStatus.QUEUED.value))
103
+ queued_jobs = result.scalars().all()
104
+
105
+ details = []
106
+ count = 0
107
+
108
+ task_names = {
109
+ JobKind.EXECUTE.value: "execute_ticket",
110
+ JobKind.VERIFY.value: "verify_ticket",
111
+ JobKind.RESUME.value: "resume_ticket",
112
+ }
113
+
114
+ for job in queued_jobs:
115
+ try:
116
+ # Re-enqueue based on job kind using send_task
117
+ task_name = task_names.get(job.kind)
118
+ if not task_name:
119
+ details.append(f"Job {job.id}: Unknown kind {job.kind}")
120
+ continue
121
+
122
+ task = enqueue_task(task_name, args=[job.id])
123
+
124
+ # Update task ID
125
+ job.celery_task_id = task.id
126
+ details.append(f"Job {job.id} ({job.kind}): Re-enqueued as {task.id}")
127
+ count += 1
128
+ except Exception as e:
129
+ details.append(f"Job {job.id}: Error re-enqueueing: {e}")
130
+
131
+ await db.commit()
132
+
133
+ return ReenqueueResponse(
134
+ jobs_reenqueued=count,
135
+ details=details,
136
+ )
137
+
138
+
139
+ @router.post(
140
+ "/watchdog/run",
141
+ response_model=WatchdogResponse,
142
+ summary="[DEV ONLY] Manually run job watchdog",
143
+ )
144
+ async def run_watchdog() -> WatchdogResponse:
145
+ """
146
+ Manually trigger the job watchdog task.
147
+
148
+ This is a DEV/DEBUG endpoint for testing watchdog behavior.
149
+ In production, the watchdog runs automatically via Celery beat every 15s.
150
+
151
+ The watchdog checks for:
152
+ 1. RUNNING jobs with stale heartbeat (no update in 2 minutes)
153
+ 2. RUNNING jobs that exceeded their timeout_seconds
154
+ 3. QUEUED jobs for 30+ seconds - re-enqueues lost Celery tasks
155
+ 4. QUEUED jobs stuck for 2+ minutes - fails them as worker may be down
156
+
157
+ For stuck jobs, it either:
158
+ - Re-enqueues the Celery task (if task was lost from Redis)
159
+ - Marks the job as FAILED and transitions ticket to BLOCKED
160
+ """
161
+ from app.services.job_watchdog_service import run_job_watchdog
162
+
163
+ result = run_job_watchdog()
164
+
165
+ return WatchdogResponse(
166
+ stale_jobs_recovered=result.stale_jobs_recovered,
167
+ timed_out_jobs_recovered=result.timed_out_jobs_recovered,
168
+ stuck_queued_jobs_failed=result.stuck_queued_jobs_failed,
169
+ lost_tasks_reenqueued=result.lost_tasks_reenqueued,
170
+ tickets_blocked=result.tickets_blocked,
171
+ details=result.details,
172
+ )