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,528 @@
1
+ """API router for Debug endpoints - live logs and system status."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncGenerator
6
+ from datetime import UTC, datetime
7
+
8
+ from fastapi import APIRouter, Depends, Query
9
+ from fastapi.responses import StreamingResponse
10
+ from pydantic import BaseModel
11
+ from sqlalchemy import desc, func, select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ from sqlalchemy.orm import selectinload
14
+
15
+ from app.database import get_db
16
+ from app.models.evidence import Evidence
17
+ from app.models.job import Job, JobStatus
18
+ from app.models.ticket import Ticket
19
+ from app.models.ticket_event import TicketEvent
20
+ from app.services.orchestrator_log import _orchestrator_logs
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter(prefix="/debug", tags=["debug"])
25
+
26
+
27
+ class OrchestratorLogEntry(BaseModel):
28
+ """A single orchestrator log entry."""
29
+
30
+ timestamp: str
31
+ level: str
32
+ message: str
33
+ data: dict
34
+
35
+
36
+ class OrchestratorLogsResponse(BaseModel):
37
+ """Response containing orchestrator logs."""
38
+
39
+ logs: list[OrchestratorLogEntry]
40
+ total: int
41
+
42
+
43
+ class AgentLogEntry(BaseModel):
44
+ """A single agent log entry with context."""
45
+
46
+ timestamp: str
47
+ job_id: str
48
+ ticket_id: str
49
+ ticket_title: str
50
+ kind: str
51
+ content: str
52
+
53
+
54
+ class AgentLogsResponse(BaseModel):
55
+ """Response containing agent logs."""
56
+
57
+ logs: list[AgentLogEntry]
58
+ job_id: str | None
59
+ ticket_title: str | None
60
+
61
+
62
+ class RunningJobInfo(BaseModel):
63
+ """Information about a running job."""
64
+
65
+ job_id: str
66
+ ticket_id: str
67
+ ticket_title: str
68
+ kind: str
69
+ started_at: str | None
70
+ log_preview: str | None
71
+
72
+
73
+ class SystemStatusResponse(BaseModel):
74
+ """Live system status for debug panel."""
75
+
76
+ timestamp: str
77
+ running_jobs: list[RunningJobInfo]
78
+ queued_count: int
79
+ tickets_by_state: dict[str, int]
80
+ recent_events_count: int
81
+
82
+
83
+ @router.get(
84
+ "/orchestrator/logs",
85
+ response_model=OrchestratorLogsResponse,
86
+ summary="Get orchestrator logs from in-memory buffer",
87
+ )
88
+ async def get_orchestrator_logs(
89
+ limit: int = Query(
90
+ default=100, le=500, description="Number of log entries to return"
91
+ ),
92
+ since: str | None = Query(
93
+ default=None, description="Only return logs after this ISO timestamp"
94
+ ),
95
+ ) -> OrchestratorLogsResponse:
96
+ """
97
+ Get recent orchestrator logs from the in-memory buffer.
98
+
99
+ These logs capture planner decisions, ticket state transitions,
100
+ and other orchestrator-level events.
101
+ """
102
+ logs = list(_orchestrator_logs)
103
+
104
+ # Filter by timestamp if provided
105
+ if since:
106
+ logs = [l for l in logs if l["timestamp"] > since]
107
+
108
+ # Return most recent entries (buffer stores oldest to newest)
109
+ logs = logs[-limit:]
110
+
111
+ return OrchestratorLogsResponse(
112
+ logs=[OrchestratorLogEntry(**l) for l in logs],
113
+ total=len(_orchestrator_logs),
114
+ )
115
+
116
+
117
+ @router.get(
118
+ "/orchestrator/stream",
119
+ summary="Stream orchestrator logs via Server-Sent Events",
120
+ )
121
+ async def stream_orchestrator_logs() -> StreamingResponse:
122
+ """
123
+ Stream orchestrator logs in real-time using Server-Sent Events (SSE).
124
+
125
+ Connect to this endpoint to receive live log updates as they happen.
126
+ Each event is a JSON object with timestamp, level, message, and data.
127
+ """
128
+
129
+ async def event_generator() -> AsyncGenerator[str, None]:
130
+ len(_orchestrator_logs)
131
+ last_timestamp = ""
132
+
133
+ # Send initial logs
134
+ for log in list(_orchestrator_logs)[-20:]: # Last 20 entries
135
+ import json
136
+
137
+ yield f"data: {json.dumps(log)}\n\n"
138
+ last_timestamp = log["timestamp"]
139
+
140
+ # Stream new logs
141
+ while True:
142
+ await asyncio.sleep(0.5) # Poll every 500ms
143
+
144
+ current_logs = list(_orchestrator_logs)
145
+ if not current_logs:
146
+ continue
147
+
148
+ # Find new logs since last check
149
+ new_logs = [l for l in current_logs if l["timestamp"] > last_timestamp]
150
+
151
+ for log in new_logs:
152
+ import json
153
+
154
+ yield f"data: {json.dumps(log)}\n\n"
155
+ last_timestamp = log["timestamp"]
156
+
157
+ return StreamingResponse(
158
+ event_generator(),
159
+ media_type="text/event-stream",
160
+ headers={
161
+ "Cache-Control": "no-cache",
162
+ "Connection": "keep-alive",
163
+ "X-Accel-Buffering": "no",
164
+ },
165
+ )
166
+
167
+
168
+ @router.get(
169
+ "/agent/logs/{job_id}",
170
+ response_model=AgentLogsResponse,
171
+ summary="Get agent output for a specific job",
172
+ )
173
+ async def get_agent_logs(
174
+ job_id: str,
175
+ db: AsyncSession = Depends(get_db),
176
+ ) -> AgentLogsResponse:
177
+ """
178
+ Get the agent's stdout/stderr for a specific job.
179
+
180
+ This returns the actual output from the executor (cursor-agent, etc.)
181
+ showing what the agent did and why.
182
+ """
183
+ # Get job with ticket
184
+ result = await db.execute(
185
+ select(Job).options(selectinload(Job.ticket)).where(Job.id == job_id)
186
+ )
187
+ job = result.scalar_one_or_none()
188
+
189
+ if not job:
190
+ return AgentLogsResponse(logs=[], job_id=job_id, ticket_title=None)
191
+
192
+ # Get evidence for this job (executor stdout)
193
+ evidence_result = await db.execute(
194
+ select(Evidence).where(Evidence.job_id == job_id).order_by(Evidence.created_at)
195
+ )
196
+ evidences = evidence_result.scalars().all()
197
+
198
+ logs = []
199
+ for ev in evidences:
200
+ # Try to get stdout content
201
+ content = ""
202
+ if ev.stdout_path:
203
+ try:
204
+ from pathlib import Path
205
+
206
+ stdout_path = Path(ev.stdout_path)
207
+ if stdout_path.exists():
208
+ content = stdout_path.read_text()[:10000] # Limit size
209
+ except Exception:
210
+ content = f"[Error reading stdout from {ev.stdout_path}]"
211
+
212
+ if content:
213
+ logs.append(
214
+ AgentLogEntry(
215
+ timestamp=ev.created_at.isoformat() if ev.created_at else "",
216
+ job_id=job_id,
217
+ ticket_id=job.ticket_id,
218
+ ticket_title=job.ticket.title if job.ticket else "Unknown",
219
+ kind=ev.kind,
220
+ content=content,
221
+ )
222
+ )
223
+
224
+ return AgentLogsResponse(
225
+ logs=logs,
226
+ job_id=job_id,
227
+ ticket_title=job.ticket.title if job.ticket else None,
228
+ )
229
+
230
+
231
+ @router.get(
232
+ "/agent/stream/{job_id}",
233
+ summary="Stream agent logs for a running job",
234
+ )
235
+ async def stream_agent_logs(
236
+ job_id: str,
237
+ db: AsyncSession = Depends(get_db),
238
+ ) -> StreamingResponse:
239
+ """
240
+ Stream agent logs in real-time for a running job.
241
+
242
+ Tails the job's log file and streams updates as they happen.
243
+ """
244
+ from pathlib import Path
245
+
246
+ # Get job to find log path
247
+ result = await db.execute(select(Job).where(Job.id == job_id))
248
+ job = result.scalar_one_or_none()
249
+
250
+ async def event_generator() -> AsyncGenerator[str, None]:
251
+ import json
252
+
253
+ if not job or not job.log_path:
254
+ yield f"data: {json.dumps({'error': 'No log file found'})}\n\n"
255
+ return
256
+
257
+ log_path = Path(job.log_path)
258
+ last_size = 0
259
+
260
+ while True:
261
+ try:
262
+ if log_path.exists():
263
+ current_size = log_path.stat().st_size
264
+
265
+ if current_size > last_size:
266
+ with open(log_path) as f:
267
+ f.seek(last_size)
268
+ new_content = f.read()
269
+ if new_content:
270
+ yield f"data: {json.dumps({'content': new_content})}\n\n"
271
+ last_size = current_size
272
+
273
+ # Check if job is still running
274
+ async with db.begin():
275
+ job_check = await db.execute(
276
+ select(Job.status).where(Job.id == job_id)
277
+ )
278
+ status = job_check.scalar_one_or_none()
279
+ if status and status not in [
280
+ JobStatus.QUEUED.value,
281
+ JobStatus.RUNNING.value,
282
+ ]:
283
+ yield f"data: {json.dumps({'status': 'completed', 'final_status': status})}\n\n"
284
+ break
285
+
286
+ except Exception as e:
287
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
288
+
289
+ await asyncio.sleep(0.5)
290
+
291
+ return StreamingResponse(
292
+ event_generator(),
293
+ media_type="text/event-stream",
294
+ headers={
295
+ "Cache-Control": "no-cache",
296
+ "Connection": "keep-alive",
297
+ "X-Accel-Buffering": "no",
298
+ },
299
+ )
300
+
301
+
302
+ @router.get(
303
+ "/status",
304
+ response_model=SystemStatusResponse,
305
+ summary="Get live system status for debug panel",
306
+ )
307
+ async def get_system_status(
308
+ db: AsyncSession = Depends(get_db),
309
+ ) -> SystemStatusResponse:
310
+ """
311
+ Get comprehensive system status for the debug panel.
312
+
313
+ Returns:
314
+ - Running jobs with log previews
315
+ - Queued job count
316
+ - Ticket counts by state
317
+ - Recent event count
318
+ """
319
+ # Get running jobs with ticket info
320
+ running_result = await db.execute(
321
+ select(Job)
322
+ .options(selectinload(Job.ticket))
323
+ .where(Job.status == JobStatus.RUNNING.value)
324
+ .order_by(Job.started_at)
325
+ )
326
+ running_jobs = running_result.scalars().all()
327
+
328
+ running_info = []
329
+ for job in running_jobs:
330
+ # Try to get last few lines of log
331
+ log_preview = None
332
+ if job.log_path:
333
+ try:
334
+ from pathlib import Path
335
+
336
+ log_path = Path(job.log_path)
337
+ if log_path.exists():
338
+ content = log_path.read_text()
339
+ lines = content.strip().split("\n")
340
+ log_preview = "\n".join(lines[-5:]) # Last 5 lines
341
+ except Exception:
342
+ pass
343
+
344
+ running_info.append(
345
+ RunningJobInfo(
346
+ job_id=job.id,
347
+ ticket_id=job.ticket_id,
348
+ ticket_title=job.ticket.title if job.ticket else "Unknown",
349
+ kind=job.kind,
350
+ started_at=job.started_at.isoformat() if job.started_at else None,
351
+ log_preview=log_preview,
352
+ )
353
+ )
354
+
355
+ # Count queued jobs
356
+ queued_result = await db.execute(
357
+ select(Job).where(Job.status == JobStatus.QUEUED.value)
358
+ )
359
+ queued_count = len(queued_result.scalars().all())
360
+
361
+ # Count tickets by state (single GROUP BY query instead of N+1)
362
+ tickets_by_state = {}
363
+ state_counts_result = await db.execute(
364
+ select(Ticket.state, func.count(Ticket.id)).group_by(Ticket.state)
365
+ )
366
+ for state_value, count in state_counts_result.all():
367
+ if count > 0:
368
+ tickets_by_state[state_value] = count
369
+
370
+ # Count recent events (last hour)
371
+ from datetime import timedelta
372
+
373
+ one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
374
+ events_result = await db.execute(
375
+ select(TicketEvent).where(TicketEvent.created_at >= one_hour_ago)
376
+ )
377
+ recent_events_count = len(events_result.scalars().all())
378
+
379
+ return SystemStatusResponse(
380
+ timestamp=datetime.now(UTC).isoformat(),
381
+ running_jobs=running_info,
382
+ queued_count=queued_count,
383
+ tickets_by_state=tickets_by_state,
384
+ recent_events_count=recent_events_count,
385
+ )
386
+
387
+
388
+ class ResetResponse(BaseModel):
389
+ """Response from reset operation."""
390
+
391
+ tickets_deleted: int
392
+ goals_deleted: int
393
+ jobs_deleted: int
394
+ events_deleted: int
395
+ message: str
396
+
397
+
398
+ @router.post(
399
+ "/reset",
400
+ response_model=ResetResponse,
401
+ summary="[DEV ONLY] Delete ALL data - nuclear reset",
402
+ )
403
+ async def reset_all_data(
404
+ confirm: str = Query(..., description="Must be 'yes-delete-everything' to confirm"),
405
+ db: AsyncSession = Depends(get_db),
406
+ ) -> ResetResponse:
407
+ """
408
+ **DANGER:** Delete ALL tickets, goals, jobs, events, evidence, etc.
409
+
410
+ This is a nuclear option for development/testing. Use with caution.
411
+
412
+ Requires `confirm=yes-delete-everything` query parameter.
413
+ """
414
+ if confirm != "yes-delete-everything":
415
+ from fastapi import HTTPException
416
+
417
+ raise HTTPException(
418
+ status_code=400,
419
+ detail="Must provide confirm=yes-delete-everything to proceed",
420
+ )
421
+
422
+ from app.models.analysis_cache import AnalysisCache
423
+ from app.models.goal import Goal
424
+ from app.models.planner_lock import PlannerLock
425
+ from app.models.review_comment import ReviewComment
426
+ from app.models.review_summary import ReviewSummary
427
+ from app.models.revision import Revision
428
+ from app.models.workspace import Workspace
429
+
430
+ # Count before deletion
431
+ tickets_count = len((await db.execute(select(Ticket))).scalars().all())
432
+ goals_count = len((await db.execute(select(Goal))).scalars().all())
433
+ jobs_count = len((await db.execute(select(Job))).scalars().all())
434
+ events_count = len((await db.execute(select(TicketEvent))).scalars().all())
435
+
436
+ # Delete in correct order (respecting foreign keys)
437
+ # 1. Review comments and summaries
438
+ await db.execute(select(ReviewComment).execution_options(synchronize_session=False))
439
+ for rc in (await db.execute(select(ReviewComment))).scalars().all():
440
+ await db.delete(rc)
441
+
442
+ for rs in (await db.execute(select(ReviewSummary))).scalars().all():
443
+ await db.delete(rs)
444
+
445
+ # 2. Revisions
446
+ for rev in (await db.execute(select(Revision))).scalars().all():
447
+ await db.delete(rev)
448
+
449
+ # 3. Evidence
450
+ for ev in (await db.execute(select(Evidence))).scalars().all():
451
+ await db.delete(ev)
452
+
453
+ # 4. Jobs
454
+ for job in (await db.execute(select(Job))).scalars().all():
455
+ await db.delete(job)
456
+
457
+ # 5. Ticket events
458
+ for event in (await db.execute(select(TicketEvent))).scalars().all():
459
+ await db.delete(event)
460
+
461
+ # 6. Tickets
462
+ for ticket in (await db.execute(select(Ticket))).scalars().all():
463
+ await db.delete(ticket)
464
+
465
+ # 7. Workspaces
466
+ for ws in (await db.execute(select(Workspace))).scalars().all():
467
+ await db.delete(ws)
468
+
469
+ # 8. Goals
470
+ for goal in (await db.execute(select(Goal))).scalars().all():
471
+ await db.delete(goal)
472
+
473
+ # 9. Planner locks
474
+ for lock in (await db.execute(select(PlannerLock))).scalars().all():
475
+ await db.delete(lock)
476
+
477
+ # 10. Analysis cache
478
+ for cache in (await db.execute(select(AnalysisCache))).scalars().all():
479
+ await db.delete(cache)
480
+
481
+ await db.commit()
482
+
483
+ # Clear in-memory logs too
484
+ _orchestrator_logs.clear()
485
+
486
+ return ResetResponse(
487
+ tickets_deleted=tickets_count,
488
+ goals_deleted=goals_count,
489
+ jobs_deleted=jobs_count,
490
+ events_deleted=events_count,
491
+ message="All data deleted successfully",
492
+ )
493
+
494
+
495
+ @router.get(
496
+ "/events/recent",
497
+ summary="Get recent ticket events for activity feed",
498
+ )
499
+ async def get_recent_events(
500
+ limit: int = Query(default=50, le=200),
501
+ db: AsyncSession = Depends(get_db),
502
+ ) -> list[dict]:
503
+ """
504
+ Get recent ticket events for the activity feed.
505
+
506
+ Returns events ordered by most recent first.
507
+ """
508
+ result = await db.execute(
509
+ select(TicketEvent)
510
+ .options(selectinload(TicketEvent.ticket))
511
+ .order_by(desc(TicketEvent.created_at))
512
+ .limit(limit)
513
+ )
514
+ events = result.scalars().all()
515
+
516
+ return [
517
+ {
518
+ "id": ev.id,
519
+ "ticket_id": ev.ticket_id,
520
+ "ticket_title": ev.ticket.title if ev.ticket else None,
521
+ "event_type": ev.event_type,
522
+ "actor_type": ev.actor_type,
523
+ "actor_id": ev.actor_id,
524
+ "payload": ev.get_payload(),
525
+ "created_at": ev.created_at.isoformat() if ev.created_at else None,
526
+ }
527
+ for ev in events
528
+ ]
@@ -0,0 +1,96 @@
1
+ """API router for Evidence endpoints."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from fastapi.responses import PlainTextResponse
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from app.database import get_db
11
+ from app.models.board import Board
12
+ from app.models.evidence import Evidence
13
+ from app.models.job import Job
14
+ from app.utils.artifact_reader import read_artifact
15
+
16
+ router = APIRouter(prefix="/evidence", tags=["evidence"])
17
+
18
+
19
+ async def get_evidence_by_id(evidence_id: str, db: AsyncSession) -> Evidence:
20
+ """Get evidence by ID or raise 404."""
21
+ result = await db.execute(select(Evidence).where(Evidence.id == evidence_id))
22
+ evidence = result.scalar_one_or_none()
23
+ if evidence is None:
24
+ raise HTTPException(
25
+ status_code=status.HTTP_404_NOT_FOUND,
26
+ detail=f"Evidence with id '{evidence_id}' not found",
27
+ )
28
+ return evidence
29
+
30
+
31
+ async def _get_repo_root_for_evidence(evidence: Evidence, db: AsyncSession) -> Path:
32
+ """Get repo_root from the board associated with this evidence's job.
33
+
34
+ Falls back to the global ConfigService repo_root if no board is found.
35
+ """
36
+ if evidence.job_id:
37
+ job_result = await db.execute(select(Job).where(Job.id == evidence.job_id))
38
+ job = job_result.scalar_one_or_none()
39
+ if job and job.board_id:
40
+ board_result = await db.execute(
41
+ select(Board).where(Board.id == job.board_id)
42
+ )
43
+ board = board_result.scalar_one_or_none()
44
+ if board and board.repo_root:
45
+ return Path(board.repo_root)
46
+
47
+ # Fallback to WorkspaceService
48
+ from app.services.workspace_service import WorkspaceService
49
+
50
+ return WorkspaceService.get_repo_path()
51
+
52
+
53
+ @router.get(
54
+ "/{evidence_id}/stdout",
55
+ response_class=PlainTextResponse,
56
+ summary="Get stdout content for an evidence record",
57
+ )
58
+ async def get_evidence_stdout(
59
+ evidence_id: str,
60
+ db: AsyncSession = Depends(get_db),
61
+ ) -> PlainTextResponse:
62
+ """Get the stdout content for a verification command.
63
+
64
+ Security: Only reads files under <repo_root>/.draft/
65
+ """
66
+ evidence = await get_evidence_by_id(evidence_id, db)
67
+ repo_root = await _get_repo_root_for_evidence(evidence, db)
68
+
69
+ content = read_artifact(repo_root, evidence.stdout_path)
70
+ if content is None:
71
+ return PlainTextResponse(content="", status_code=status.HTTP_200_OK)
72
+
73
+ return PlainTextResponse(content=content)
74
+
75
+
76
+ @router.get(
77
+ "/{evidence_id}/stderr",
78
+ response_class=PlainTextResponse,
79
+ summary="Get stderr content for an evidence record",
80
+ )
81
+ async def get_evidence_stderr(
82
+ evidence_id: str,
83
+ db: AsyncSession = Depends(get_db),
84
+ ) -> PlainTextResponse:
85
+ """Get the stderr content for a verification command.
86
+
87
+ Security: Only reads files under <repo_root>/.draft/
88
+ """
89
+ evidence = await get_evidence_by_id(evidence_id, db)
90
+ repo_root = await _get_repo_root_for_evidence(evidence, db)
91
+
92
+ content = read_artifact(repo_root, evidence.stderr_path)
93
+ if content is None:
94
+ return PlainTextResponse(content="", status_code=status.HTTP_200_OK)
95
+
96
+ return PlainTextResponse(content=content)