steroids-api 0.2.7

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 (328) hide show
  1. package/dist/API/src/index.d.ts +10 -0
  2. package/dist/API/src/index.d.ts.map +1 -0
  3. package/dist/API/src/index.js +130 -0
  4. package/dist/API/src/index.js.map +1 -0
  5. package/dist/API/src/routes/activity.d.ts +7 -0
  6. package/dist/API/src/routes/activity.d.ts.map +1 -0
  7. package/dist/API/src/routes/activity.js +252 -0
  8. package/dist/API/src/routes/activity.js.map +1 -0
  9. package/dist/API/src/routes/config.d.ts +7 -0
  10. package/dist/API/src/routes/config.d.ts.map +1 -0
  11. package/dist/API/src/routes/config.js +521 -0
  12. package/dist/API/src/routes/config.js.map +1 -0
  13. package/dist/API/src/routes/health.d.ts +7 -0
  14. package/dist/API/src/routes/health.d.ts.map +1 -0
  15. package/dist/API/src/routes/health.js +172 -0
  16. package/dist/API/src/routes/health.js.map +1 -0
  17. package/dist/API/src/routes/incidents.d.ts +7 -0
  18. package/dist/API/src/routes/incidents.d.ts.map +1 -0
  19. package/dist/API/src/routes/incidents.js +117 -0
  20. package/dist/API/src/routes/incidents.js.map +1 -0
  21. package/dist/API/src/routes/projects.d.ts +7 -0
  22. package/dist/API/src/routes/projects.d.ts.map +1 -0
  23. package/dist/API/src/routes/projects.js +398 -0
  24. package/dist/API/src/routes/projects.js.map +1 -0
  25. package/dist/API/src/routes/runners.d.ts +7 -0
  26. package/dist/API/src/routes/runners.d.ts.map +1 -0
  27. package/dist/API/src/routes/runners.js +242 -0
  28. package/dist/API/src/routes/runners.js.map +1 -0
  29. package/dist/API/src/routes/tasks.d.ts +7 -0
  30. package/dist/API/src/routes/tasks.d.ts.map +1 -0
  31. package/dist/API/src/routes/tasks.js +1007 -0
  32. package/dist/API/src/routes/tasks.js.map +1 -0
  33. package/dist/API/src/utils/validation.d.ts +22 -0
  34. package/dist/API/src/utils/validation.d.ts.map +1 -0
  35. package/dist/API/src/utils/validation.js +50 -0
  36. package/dist/API/src/utils/validation.js.map +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +184 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/routes/activity.d.ts +7 -0
  42. package/dist/routes/activity.d.ts.map +1 -0
  43. package/dist/routes/activity.js +252 -0
  44. package/dist/routes/activity.js.map +1 -0
  45. package/dist/routes/config.d.ts +7 -0
  46. package/dist/routes/config.d.ts.map +1 -0
  47. package/dist/routes/config.js +647 -0
  48. package/dist/routes/config.js.map +1 -0
  49. package/dist/routes/credit-alerts.d.ts +2 -0
  50. package/dist/routes/credit-alerts.d.ts.map +1 -0
  51. package/dist/routes/credit-alerts.js +97 -0
  52. package/dist/routes/credit-alerts.js.map +1 -0
  53. package/dist/routes/health.d.ts +7 -0
  54. package/dist/routes/health.d.ts.map +1 -0
  55. package/dist/routes/health.js +200 -0
  56. package/dist/routes/health.js.map +1 -0
  57. package/dist/routes/incidents.d.ts +7 -0
  58. package/dist/routes/incidents.d.ts.map +1 -0
  59. package/dist/routes/incidents.js +117 -0
  60. package/dist/routes/incidents.js.map +1 -0
  61. package/dist/routes/projects.d.ts +7 -0
  62. package/dist/routes/projects.d.ts.map +1 -0
  63. package/dist/routes/projects.js +643 -0
  64. package/dist/routes/projects.js.map +1 -0
  65. package/dist/routes/runners.d.ts +7 -0
  66. package/dist/routes/runners.d.ts.map +1 -0
  67. package/dist/routes/runners.js +299 -0
  68. package/dist/routes/runners.js.map +1 -0
  69. package/dist/routes/skills.d.ts +3 -0
  70. package/dist/routes/skills.d.ts.map +1 -0
  71. package/dist/routes/skills.js +109 -0
  72. package/dist/routes/skills.js.map +1 -0
  73. package/dist/routes/storage.d.ts +7 -0
  74. package/dist/routes/storage.d.ts.map +1 -0
  75. package/dist/routes/storage.js +93 -0
  76. package/dist/routes/storage.js.map +1 -0
  77. package/dist/routes/tasks.d.ts +7 -0
  78. package/dist/routes/tasks.d.ts.map +1 -0
  79. package/dist/routes/tasks.js +1145 -0
  80. package/dist/routes/tasks.js.map +1 -0
  81. package/dist/src/cleanup/invocation-logs.d.ts +30 -0
  82. package/dist/src/cleanup/invocation-logs.d.ts.map +1 -0
  83. package/dist/src/cleanup/invocation-logs.js +66 -0
  84. package/dist/src/cleanup/invocation-logs.js.map +1 -0
  85. package/dist/src/commands/loop-phases.d.ts +11 -0
  86. package/dist/src/commands/loop-phases.d.ts.map +1 -0
  87. package/dist/src/commands/loop-phases.js +304 -0
  88. package/dist/src/commands/loop-phases.js.map +1 -0
  89. package/dist/src/config/loader.d.ts +160 -0
  90. package/dist/src/config/loader.d.ts.map +1 -0
  91. package/dist/src/config/loader.js +276 -0
  92. package/dist/src/config/loader.js.map +1 -0
  93. package/dist/src/database/connection.d.ts +35 -0
  94. package/dist/src/database/connection.d.ts.map +1 -0
  95. package/dist/src/database/connection.js +197 -0
  96. package/dist/src/database/connection.js.map +1 -0
  97. package/dist/src/database/queries.d.ts +220 -0
  98. package/dist/src/database/queries.d.ts.map +1 -0
  99. package/dist/src/database/queries.js +589 -0
  100. package/dist/src/database/queries.js.map +1 -0
  101. package/dist/src/database/schema.d.ts +8 -0
  102. package/dist/src/database/schema.d.ts.map +1 -0
  103. package/dist/src/database/schema.js +184 -0
  104. package/dist/src/database/schema.js.map +1 -0
  105. package/dist/src/git/push.d.ts +26 -0
  106. package/dist/src/git/push.d.ts.map +1 -0
  107. package/dist/src/git/push.js +91 -0
  108. package/dist/src/git/push.js.map +1 -0
  109. package/dist/src/git/status.d.ts +83 -0
  110. package/dist/src/git/status.d.ts.map +1 -0
  111. package/dist/src/git/status.js +315 -0
  112. package/dist/src/git/status.js.map +1 -0
  113. package/dist/src/health/stuck-task-detector.d.ts +131 -0
  114. package/dist/src/health/stuck-task-detector.d.ts.map +1 -0
  115. package/dist/src/health/stuck-task-detector.js +233 -0
  116. package/dist/src/health/stuck-task-detector.js.map +1 -0
  117. package/dist/src/health/stuck-task-recovery.d.ts +45 -0
  118. package/dist/src/health/stuck-task-recovery.d.ts.map +1 -0
  119. package/dist/src/health/stuck-task-recovery.js +309 -0
  120. package/dist/src/health/stuck-task-recovery.js.map +1 -0
  121. package/dist/src/index.d.ts +10 -0
  122. package/dist/src/index.d.ts.map +1 -0
  123. package/dist/src/index.js +130 -0
  124. package/dist/src/index.js.map +1 -0
  125. package/dist/src/locking/queries.d.ts +116 -0
  126. package/dist/src/locking/queries.d.ts.map +1 -0
  127. package/dist/src/locking/queries.js +232 -0
  128. package/dist/src/locking/queries.js.map +1 -0
  129. package/dist/src/locking/section-lock.d.ts +74 -0
  130. package/dist/src/locking/section-lock.d.ts.map +1 -0
  131. package/dist/src/locking/section-lock.js +196 -0
  132. package/dist/src/locking/section-lock.js.map +1 -0
  133. package/dist/src/locking/task-lock.d.ts +92 -0
  134. package/dist/src/locking/task-lock.d.ts.map +1 -0
  135. package/dist/src/locking/task-lock.js +233 -0
  136. package/dist/src/locking/task-lock.js.map +1 -0
  137. package/dist/src/migrations/index.d.ts +7 -0
  138. package/dist/src/migrations/index.d.ts.map +1 -0
  139. package/dist/src/migrations/index.js +9 -0
  140. package/dist/src/migrations/index.js.map +1 -0
  141. package/dist/src/migrations/manifest.d.ts +92 -0
  142. package/dist/src/migrations/manifest.d.ts.map +1 -0
  143. package/dist/src/migrations/manifest.js +255 -0
  144. package/dist/src/migrations/manifest.js.map +1 -0
  145. package/dist/src/migrations/runner.d.ts +84 -0
  146. package/dist/src/migrations/runner.d.ts.map +1 -0
  147. package/dist/src/migrations/runner.js +338 -0
  148. package/dist/src/migrations/runner.js.map +1 -0
  149. package/dist/src/orchestrator/coder.d.ts +32 -0
  150. package/dist/src/orchestrator/coder.d.ts.map +1 -0
  151. package/dist/src/orchestrator/coder.js +170 -0
  152. package/dist/src/orchestrator/coder.js.map +1 -0
  153. package/dist/src/orchestrator/coordinator.d.ts +28 -0
  154. package/dist/src/orchestrator/coordinator.d.ts.map +1 -0
  155. package/dist/src/orchestrator/coordinator.js +252 -0
  156. package/dist/src/orchestrator/coordinator.js.map +1 -0
  157. package/dist/src/orchestrator/fallback-handler.d.ts +24 -0
  158. package/dist/src/orchestrator/fallback-handler.d.ts.map +1 -0
  159. package/dist/src/orchestrator/fallback-handler.js +280 -0
  160. package/dist/src/orchestrator/fallback-handler.js.map +1 -0
  161. package/dist/src/orchestrator/invoke.d.ts +14 -0
  162. package/dist/src/orchestrator/invoke.d.ts.map +1 -0
  163. package/dist/src/orchestrator/invoke.js +76 -0
  164. package/dist/src/orchestrator/invoke.js.map +1 -0
  165. package/dist/src/orchestrator/post-coder.d.ts +10 -0
  166. package/dist/src/orchestrator/post-coder.d.ts.map +1 -0
  167. package/dist/src/orchestrator/post-coder.js +198 -0
  168. package/dist/src/orchestrator/post-coder.js.map +1 -0
  169. package/dist/src/orchestrator/post-reviewer.d.ts +10 -0
  170. package/dist/src/orchestrator/post-reviewer.d.ts.map +1 -0
  171. package/dist/src/orchestrator/post-reviewer.js +199 -0
  172. package/dist/src/orchestrator/post-reviewer.js.map +1 -0
  173. package/dist/src/orchestrator/reviewer.d.ts +35 -0
  174. package/dist/src/orchestrator/reviewer.d.ts.map +1 -0
  175. package/dist/src/orchestrator/reviewer.js +237 -0
  176. package/dist/src/orchestrator/reviewer.js.map +1 -0
  177. package/dist/src/orchestrator/schemas.d.ts +10 -0
  178. package/dist/src/orchestrator/schemas.d.ts.map +1 -0
  179. package/dist/src/orchestrator/schemas.js +81 -0
  180. package/dist/src/orchestrator/schemas.js.map +1 -0
  181. package/dist/src/orchestrator/task-selector.d.ts +102 -0
  182. package/dist/src/orchestrator/task-selector.d.ts.map +1 -0
  183. package/dist/src/orchestrator/task-selector.js +326 -0
  184. package/dist/src/orchestrator/task-selector.js.map +1 -0
  185. package/dist/src/orchestrator/types.d.ts +74 -0
  186. package/dist/src/orchestrator/types.d.ts.map +1 -0
  187. package/dist/src/orchestrator/types.js +5 -0
  188. package/dist/src/orchestrator/types.js.map +1 -0
  189. package/dist/src/prompts/coder.d.ts +36 -0
  190. package/dist/src/prompts/coder.d.ts.map +1 -0
  191. package/dist/src/prompts/coder.js +303 -0
  192. package/dist/src/prompts/coder.js.map +1 -0
  193. package/dist/src/prompts/prompt-helpers.d.ts +51 -0
  194. package/dist/src/prompts/prompt-helpers.d.ts.map +1 -0
  195. package/dist/src/prompts/prompt-helpers.js +299 -0
  196. package/dist/src/prompts/prompt-helpers.js.map +1 -0
  197. package/dist/src/prompts/reviewer.d.ts +40 -0
  198. package/dist/src/prompts/reviewer.d.ts.map +1 -0
  199. package/dist/src/prompts/reviewer.js +416 -0
  200. package/dist/src/prompts/reviewer.js.map +1 -0
  201. package/dist/src/providers/claude.d.ts +53 -0
  202. package/dist/src/providers/claude.d.ts.map +1 -0
  203. package/dist/src/providers/claude.js +227 -0
  204. package/dist/src/providers/claude.js.map +1 -0
  205. package/dist/src/providers/codex.d.ts +53 -0
  206. package/dist/src/providers/codex.d.ts.map +1 -0
  207. package/dist/src/providers/codex.js +253 -0
  208. package/dist/src/providers/codex.js.map +1 -0
  209. package/dist/src/providers/gemini.d.ts +58 -0
  210. package/dist/src/providers/gemini.d.ts.map +1 -0
  211. package/dist/src/providers/gemini.js +240 -0
  212. package/dist/src/providers/gemini.js.map +1 -0
  213. package/dist/src/providers/interface.d.ts +185 -0
  214. package/dist/src/providers/interface.d.ts.map +1 -0
  215. package/dist/src/providers/interface.js +92 -0
  216. package/dist/src/providers/interface.js.map +1 -0
  217. package/dist/src/providers/invocation-logger.d.ts +97 -0
  218. package/dist/src/providers/invocation-logger.d.ts.map +1 -0
  219. package/dist/src/providers/invocation-logger.js +378 -0
  220. package/dist/src/providers/invocation-logger.js.map +1 -0
  221. package/dist/src/providers/openai.d.ts +53 -0
  222. package/dist/src/providers/openai.d.ts.map +1 -0
  223. package/dist/src/providers/openai.js +230 -0
  224. package/dist/src/providers/openai.js.map +1 -0
  225. package/dist/src/providers/registry.d.ts +100 -0
  226. package/dist/src/providers/registry.d.ts.map +1 -0
  227. package/dist/src/providers/registry.js +170 -0
  228. package/dist/src/providers/registry.js.map +1 -0
  229. package/dist/src/routes/activity.d.ts +7 -0
  230. package/dist/src/routes/activity.d.ts.map +1 -0
  231. package/dist/src/routes/activity.js +252 -0
  232. package/dist/src/routes/activity.js.map +1 -0
  233. package/dist/src/routes/config.d.ts +7 -0
  234. package/dist/src/routes/config.d.ts.map +1 -0
  235. package/dist/src/routes/config.js +521 -0
  236. package/dist/src/routes/config.js.map +1 -0
  237. package/dist/src/routes/health.d.ts +7 -0
  238. package/dist/src/routes/health.d.ts.map +1 -0
  239. package/dist/src/routes/health.js +172 -0
  240. package/dist/src/routes/health.js.map +1 -0
  241. package/dist/src/routes/incidents.d.ts +7 -0
  242. package/dist/src/routes/incidents.d.ts.map +1 -0
  243. package/dist/src/routes/incidents.js +117 -0
  244. package/dist/src/routes/incidents.js.map +1 -0
  245. package/dist/src/routes/projects.d.ts +7 -0
  246. package/dist/src/routes/projects.d.ts.map +1 -0
  247. package/dist/src/routes/projects.js +398 -0
  248. package/dist/src/routes/projects.js.map +1 -0
  249. package/dist/src/routes/runners.d.ts +7 -0
  250. package/dist/src/routes/runners.d.ts.map +1 -0
  251. package/dist/src/routes/runners.js +242 -0
  252. package/dist/src/routes/runners.js.map +1 -0
  253. package/dist/src/routes/tasks.d.ts +7 -0
  254. package/dist/src/routes/tasks.d.ts.map +1 -0
  255. package/dist/src/routes/tasks.js +1007 -0
  256. package/dist/src/routes/tasks.js.map +1 -0
  257. package/dist/src/runners/activity-log.d.ts +65 -0
  258. package/dist/src/runners/activity-log.d.ts.map +1 -0
  259. package/dist/src/runners/activity-log.js +140 -0
  260. package/dist/src/runners/activity-log.js.map +1 -0
  261. package/dist/src/runners/cron.d.ts +30 -0
  262. package/dist/src/runners/cron.d.ts.map +1 -0
  263. package/dist/src/runners/cron.js +333 -0
  264. package/dist/src/runners/cron.js.map +1 -0
  265. package/dist/src/runners/daemon.d.ts +71 -0
  266. package/dist/src/runners/daemon.d.ts.map +1 -0
  267. package/dist/src/runners/daemon.js +233 -0
  268. package/dist/src/runners/daemon.js.map +1 -0
  269. package/dist/src/runners/global-db.d.ts +31 -0
  270. package/dist/src/runners/global-db.d.ts.map +1 -0
  271. package/dist/src/runners/global-db.js +220 -0
  272. package/dist/src/runners/global-db.js.map +1 -0
  273. package/dist/src/runners/hang-detector.d.ts +38 -0
  274. package/dist/src/runners/hang-detector.d.ts.map +1 -0
  275. package/dist/src/runners/hang-detector.js +130 -0
  276. package/dist/src/runners/hang-detector.js.map +1 -0
  277. package/dist/src/runners/heartbeat.d.ts +39 -0
  278. package/dist/src/runners/heartbeat.d.ts.map +1 -0
  279. package/dist/src/runners/heartbeat.js +71 -0
  280. package/dist/src/runners/heartbeat.js.map +1 -0
  281. package/dist/src/runners/lock.d.ts +47 -0
  282. package/dist/src/runners/lock.d.ts.map +1 -0
  283. package/dist/src/runners/lock.js +140 -0
  284. package/dist/src/runners/lock.js.map +1 -0
  285. package/dist/src/runners/orchestrator-loop.d.ts +20 -0
  286. package/dist/src/runners/orchestrator-loop.d.ts.map +1 -0
  287. package/dist/src/runners/orchestrator-loop.js +208 -0
  288. package/dist/src/runners/orchestrator-loop.js.map +1 -0
  289. package/dist/src/runners/projects.d.ts +96 -0
  290. package/dist/src/runners/projects.d.ts.map +1 -0
  291. package/dist/src/runners/projects.js +243 -0
  292. package/dist/src/runners/projects.js.map +1 -0
  293. package/dist/src/runners/wakeup.d.ts +37 -0
  294. package/dist/src/runners/wakeup.d.ts.map +1 -0
  295. package/dist/src/runners/wakeup.js +355 -0
  296. package/dist/src/runners/wakeup.js.map +1 -0
  297. package/dist/src/utils/validation.d.ts +22 -0
  298. package/dist/src/utils/validation.d.ts.map +1 -0
  299. package/dist/src/utils/validation.js +50 -0
  300. package/dist/src/utils/validation.js.map +1 -0
  301. package/dist/utils/sqlite.d.ts +17 -0
  302. package/dist/utils/sqlite.d.ts.map +1 -0
  303. package/dist/utils/sqlite.js +27 -0
  304. package/dist/utils/sqlite.js.map +1 -0
  305. package/dist/utils/storage-cache.d.ts +33 -0
  306. package/dist/utils/storage-cache.d.ts.map +1 -0
  307. package/dist/utils/storage-cache.js +81 -0
  308. package/dist/utils/storage-cache.js.map +1 -0
  309. package/dist/utils/validation.d.ts +22 -0
  310. package/dist/utils/validation.d.ts.map +1 -0
  311. package/dist/utils/validation.js +51 -0
  312. package/dist/utils/validation.js.map +1 -0
  313. package/package.json +39 -0
  314. package/src/index.ts +199 -0
  315. package/src/routes/activity.ts +302 -0
  316. package/src/routes/config.ts +723 -0
  317. package/src/routes/credit-alerts.ts +73 -0
  318. package/src/routes/health.ts +219 -0
  319. package/src/routes/incidents.ts +131 -0
  320. package/src/routes/projects.ts +854 -0
  321. package/src/routes/runners.ts +357 -0
  322. package/src/routes/skills.ts +127 -0
  323. package/src/routes/storage.ts +108 -0
  324. package/src/routes/tasks.ts +1372 -0
  325. package/src/utils/sqlite.ts +36 -0
  326. package/src/utils/storage-cache.ts +107 -0
  327. package/src/utils/validation.ts +61 -0
  328. package/tsconfig.json +20 -0
@@ -0,0 +1,1372 @@
1
+ /**
2
+ * Tasks API routes
3
+ * Exposes task details and logs for individual tasks
4
+ */
5
+
6
+ import { Router, Request, Response } from 'express';
7
+ import Database from 'better-sqlite3';
8
+ import { createReadStream, existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { execSync } from 'node:child_process';
11
+ import { Tail } from 'tail';
12
+ import { openSqliteForRead } from '../utils/sqlite.js';
13
+
14
+ const router = Router();
15
+
16
+ const MAX_SSE_CONNECTIONS = Math.max(1, parseInt(process.env.MAX_SSE_CONNECTIONS || '100', 10) || 100);
17
+ let activeSseConnections = 0;
18
+
19
+ interface TaskDetails {
20
+ id: string;
21
+ title: string;
22
+ status: string;
23
+ section_id: string | null;
24
+ section_name: string | null;
25
+ source_file: string | null;
26
+ rejection_count: number;
27
+ failure_count: number;
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+
32
+ interface AuditEntry {
33
+ id: number;
34
+ task_id: string;
35
+ from_status: string | null;
36
+ to_status: string;
37
+ actor: string;
38
+ actor_type: string | null;
39
+ model: string | null;
40
+ notes: string | null;
41
+ error_code: string | null;
42
+ commit_sha: string | null;
43
+ created_at: string;
44
+ duration_seconds?: number;
45
+ }
46
+
47
+ interface InvocationEntry {
48
+ id: number;
49
+ task_id: string;
50
+ role: string;
51
+ provider: string;
52
+ model: string;
53
+ exit_code: number;
54
+ duration_ms: number;
55
+ success: number;
56
+ timed_out: number;
57
+ rejection_number: number | null;
58
+ created_at: string;
59
+ }
60
+
61
+ interface InvocationDetails extends InvocationEntry {
62
+ prompt: string;
63
+ response: string | null;
64
+ error: string | null;
65
+ }
66
+
67
+ interface DisputeEntry {
68
+ id: string;
69
+ task_id: string;
70
+ type: string;
71
+ status: string;
72
+ reason: string;
73
+ coder_position: string | null;
74
+ reviewer_position: string | null;
75
+ resolution: string | null;
76
+ resolution_notes: string | null;
77
+ created_by: string;
78
+ resolved_by: string | null;
79
+ created_at: string;
80
+ resolved_at: string | null;
81
+ }
82
+
83
+ interface TaskResponse extends TaskDetails {
84
+ duration: {
85
+ total_seconds: number;
86
+ in_progress_seconds: number;
87
+ review_seconds: number;
88
+ };
89
+ audit_trail: AuditEntry[];
90
+ invocations: InvocationEntry[];
91
+ disputes: DisputeEntry[];
92
+ github_url: string | null;
93
+ }
94
+
95
+ /**
96
+ * Get GitHub URL from git remote
97
+ * @param projectPath - Path to project root
98
+ * @returns GitHub base URL (e.g., https://github.com/owner/repo) or null
99
+ */
100
+ function getGitHubUrl(projectPath: string): string | null {
101
+ try {
102
+ const remoteUrl = execSync('git remote get-url origin', {
103
+ cwd: projectPath,
104
+ encoding: 'utf-8',
105
+ }).trim();
106
+
107
+ // Convert SSH or HTTPS URL to web URL
108
+ // SSH: git@github.com:owner/repo.git
109
+ // HTTPS: https://github.com/owner/repo.git
110
+ let webUrl: string | null = null;
111
+
112
+ if (remoteUrl.startsWith('git@github.com:')) {
113
+ // SSH format
114
+ const path = remoteUrl.replace('git@github.com:', '').replace(/\.git$/, '');
115
+ webUrl = `https://github.com/${path}`;
116
+ } else if (remoteUrl.includes('github.com')) {
117
+ // HTTPS format
118
+ webUrl = remoteUrl.replace(/\.git$/, '');
119
+ }
120
+
121
+ return webUrl;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Open project database
129
+ * @param projectPath - Path to project root
130
+ * @returns Database connection or null if not found
131
+ */
132
+ function openProjectDatabase(projectPath: string): Database.Database | null {
133
+ const dbPath = join(projectPath, '.steroids', 'steroids.db');
134
+ if (!existsSync(dbPath)) {
135
+ return null;
136
+ }
137
+ try {
138
+ return openSqliteForRead(dbPath);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Calculate duration for each status from audit trail
146
+ * @param auditTrail - Array of audit entries sorted by created_at
147
+ * @returns Audit entries with duration_seconds added
148
+ */
149
+ function calculateDurations(auditTrail: AuditEntry[]): AuditEntry[] {
150
+ // Sort by created_at ascending for duration calculation
151
+ const sorted = [...auditTrail].sort(
152
+ (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
153
+ );
154
+
155
+ return sorted.map((entry, index) => {
156
+ // Duration is time until next status change
157
+ if (index < sorted.length - 1) {
158
+ const startTime = new Date(entry.created_at).getTime();
159
+ const endTime = new Date(sorted[index + 1].created_at).getTime();
160
+ const durationSeconds = Math.round((endTime - startTime) / 1000);
161
+ return { ...entry, duration_seconds: durationSeconds };
162
+ }
163
+ // Current/last status - duration from entry until now
164
+ const startTime = new Date(entry.created_at).getTime();
165
+ const now = Date.now();
166
+ const durationSeconds = Math.round((now - startTime) / 1000);
167
+ return { ...entry, duration_seconds: durationSeconds };
168
+ });
169
+ }
170
+
171
+ function sleep(ms: number): Promise<void> {
172
+ return new Promise((resolve) => setTimeout(resolve, ms));
173
+ }
174
+
175
+ async function writeSSE(res: Response, payload: unknown): Promise<void> {
176
+ if (res.writableEnded) return;
177
+ const chunk = `data: ${JSON.stringify(payload)}\n\n`;
178
+ const ok = res.write(chunk);
179
+ if (ok) return;
180
+ await new Promise<void>((resolve, reject) => {
181
+ const onDrain = (): void => {
182
+ cleanup();
183
+ resolve();
184
+ };
185
+ const onError = (err: unknown): void => {
186
+ cleanup();
187
+ reject(err);
188
+ };
189
+ const cleanup = (): void => {
190
+ res.off('drain', onDrain);
191
+ res.off('error', onError);
192
+ };
193
+ res.on('drain', onDrain);
194
+ res.on('error', onError);
195
+ });
196
+ }
197
+
198
+ async function writeSSEComment(res: Response, comment: string): Promise<void> {
199
+ if (res.writableEnded) return;
200
+ const chunk = `: ${comment}\n\n`;
201
+ const ok = res.write(chunk);
202
+ if (ok) return;
203
+ await new Promise<void>((resolve, reject) => {
204
+ const onDrain = (): void => {
205
+ cleanup();
206
+ resolve();
207
+ };
208
+ const onError = (err: unknown): void => {
209
+ cleanup();
210
+ reject(err);
211
+ };
212
+ const cleanup = (): void => {
213
+ res.off('drain', onDrain);
214
+ res.off('error', onError);
215
+ };
216
+ res.on('drain', onDrain);
217
+ res.on('error', onError);
218
+ });
219
+ }
220
+
221
+ async function waitForFile(filePath: string, opts: { timeoutMs: number; pollMs: number; isAborted: () => boolean }): Promise<boolean> {
222
+ const deadline = Date.now() + opts.timeoutMs;
223
+ while (Date.now() < deadline) {
224
+ if (opts.isAborted()) return false;
225
+ if (existsSync(filePath)) return true;
226
+ await sleep(opts.pollMs);
227
+ }
228
+ return existsSync(filePath);
229
+ }
230
+
231
+ async function streamJsonlFileToSSE(
232
+ res: Response,
233
+ filePath: string,
234
+ opts: { isAborted: () => boolean }
235
+ ): Promise<void> {
236
+ const rs = createReadStream(filePath, { encoding: 'utf8' });
237
+ let buffer = '';
238
+
239
+ for await (const chunk of rs) {
240
+ if (opts.isAborted()) return;
241
+ buffer += chunk;
242
+ let nl = buffer.indexOf('\n');
243
+ while (nl >= 0) {
244
+ const line = buffer.slice(0, nl);
245
+ buffer = buffer.slice(nl + 1);
246
+ nl = buffer.indexOf('\n');
247
+ const trimmed = line.trim();
248
+ if (!trimmed) continue;
249
+ try {
250
+ const entry = JSON.parse(trimmed);
251
+ await writeSSE(res, entry);
252
+ } catch {
253
+ // ignore malformed JSONL lines
254
+ }
255
+ }
256
+ }
257
+
258
+ const tailLine = buffer.trim();
259
+ if (tailLine && !opts.isAborted()) {
260
+ try {
261
+ const entry = JSON.parse(tailLine);
262
+ await writeSSE(res, entry);
263
+ } catch {
264
+ // ignore
265
+ }
266
+ }
267
+ }
268
+
269
+ async function readSampledJsonlEntries(
270
+ filePath: string,
271
+ opts: { keepEveryN: number; shouldKeep?: (entry: any, index: number) => boolean }
272
+ ): Promise<any[]> {
273
+ const rs = createReadStream(filePath, { encoding: 'utf8' });
274
+ let buffer = '';
275
+ let index = 0;
276
+ const out: any[] = [];
277
+
278
+ const keep = (entry: any, i: number): boolean => {
279
+ if (opts.shouldKeep) return opts.shouldKeep(entry, i);
280
+ // Keep all tools, and sample the rest.
281
+ return entry?.type === 'tool' || i % opts.keepEveryN === 0;
282
+ };
283
+
284
+ for await (const chunk of rs) {
285
+ buffer += chunk;
286
+ let nl = buffer.indexOf('\n');
287
+ while (nl >= 0) {
288
+ const line = buffer.slice(0, nl);
289
+ buffer = buffer.slice(nl + 1);
290
+ nl = buffer.indexOf('\n');
291
+
292
+ const trimmed = line.trim();
293
+ if (!trimmed) continue;
294
+ try {
295
+ const entry = JSON.parse(trimmed);
296
+ if (keep(entry, index)) out.push(entry);
297
+ } catch {
298
+ // ignore malformed JSONL lines
299
+ } finally {
300
+ index++;
301
+ }
302
+ }
303
+ }
304
+
305
+ const tailLine = buffer.trim();
306
+ if (tailLine) {
307
+ try {
308
+ const entry = JSON.parse(tailLine);
309
+ if (keep(entry, index)) out.push(entry);
310
+ } catch {
311
+ // ignore
312
+ }
313
+ }
314
+
315
+ return out;
316
+ }
317
+
318
+ /**
319
+ * GET /api/tasks/:taskId
320
+ * Get detailed information about a task including audit history
321
+ * Query params:
322
+ * - project: string (required) - project path
323
+ */
324
+ router.get('/tasks/:taskId', (req: Request, res: Response) => {
325
+ try {
326
+ const { taskId } = req.params;
327
+ const projectPath = req.query.project as string;
328
+
329
+ if (!projectPath) {
330
+ res.status(400).json({
331
+ success: false,
332
+ error: 'Missing required query parameter: project',
333
+ });
334
+ return;
335
+ }
336
+
337
+ const db = openProjectDatabase(projectPath);
338
+ if (!db) {
339
+ res.status(404).json({
340
+ success: false,
341
+ error: 'Project database not found',
342
+ project: projectPath,
343
+ });
344
+ return;
345
+ }
346
+
347
+ try {
348
+ // Get task details with section name
349
+ const task = db
350
+ .prepare(
351
+ `SELECT
352
+ t.id, t.title, t.status, t.section_id,
353
+ s.name as section_name,
354
+ t.source_file, t.rejection_count,
355
+ t.created_at, t.updated_at
356
+ FROM tasks t
357
+ LEFT JOIN sections s ON t.section_id = s.id
358
+ WHERE t.id = ?`
359
+ )
360
+ .get(taskId) as TaskDetails | undefined;
361
+
362
+ if (!task) {
363
+ res.status(404).json({
364
+ success: false,
365
+ error: 'Task not found',
366
+ task_id: taskId,
367
+ });
368
+ return;
369
+ }
370
+
371
+ // Get audit trail
372
+ const auditTrail = db
373
+ .prepare(
374
+ `SELECT id, task_id, from_status, to_status, actor, actor_type, model, notes, error_code, commit_sha, created_at
375
+ FROM audit
376
+ WHERE task_id = ?
377
+ ORDER BY created_at ASC`
378
+ )
379
+ .all(taskId) as AuditEntry[];
380
+
381
+ // Get disputes for task
382
+ const disputes = db
383
+ .prepare(
384
+ `SELECT * FROM disputes
385
+ WHERE task_id = ?
386
+ ORDER BY created_at DESC`
387
+ )
388
+ .all(taskId) as DisputeEntry[];
389
+
390
+ // Get LLM invocations (exclude prompt/response to keep payload light)
391
+ const invocations = db
392
+ .prepare(
393
+ `SELECT id, task_id, role, provider, model, exit_code, duration_ms, success, timed_out, rejection_number, created_at
394
+ FROM task_invocations
395
+ WHERE task_id = ?
396
+ ORDER BY created_at ASC`
397
+ )
398
+ .all(taskId) as InvocationEntry[];
399
+
400
+ // Calculate durations for each status
401
+ const auditWithDurations = calculateDurations(auditTrail);
402
+
403
+ // Calculate total time in each status
404
+ let inProgressSeconds = 0;
405
+ let reviewSeconds = 0;
406
+
407
+ for (const entry of auditWithDurations) {
408
+ if (entry.to_status === 'in_progress' && entry.duration_seconds) {
409
+ inProgressSeconds += entry.duration_seconds;
410
+ } else if (entry.to_status === 'review' && entry.duration_seconds) {
411
+ reviewSeconds += entry.duration_seconds;
412
+ }
413
+ }
414
+
415
+ // Total time is just the sum of active work time (coding + review)
416
+ const totalSeconds = inProgressSeconds + reviewSeconds;
417
+
418
+ // Get GitHub URL for commit links
419
+ const githubUrl = getGitHubUrl(projectPath);
420
+
421
+ const response: TaskResponse = {
422
+ ...task,
423
+ duration: {
424
+ total_seconds: totalSeconds,
425
+ in_progress_seconds: inProgressSeconds,
426
+ review_seconds: reviewSeconds,
427
+ },
428
+ audit_trail: auditWithDurations.reverse(), // Most recent first for display
429
+ invocations, // Oldest first (chronological)
430
+ disputes,
431
+ github_url: githubUrl,
432
+ };
433
+
434
+ res.json({
435
+ success: true,
436
+ task: response,
437
+ });
438
+ } finally {
439
+ db.close();
440
+ }
441
+ } catch (error) {
442
+ console.error('Error getting task details:', error);
443
+ res.status(500).json({
444
+ success: false,
445
+ error: 'Failed to get task details',
446
+ message: error instanceof Error ? error.message : 'Unknown error',
447
+ });
448
+ }
449
+ });
450
+
451
+ /**
452
+ * GET /api/tasks/:taskId/stream
453
+ * Stream invocation activity (JSONL) for the currently-running invocation using SSE.
454
+ * Query params:
455
+ * - project: string (required) - project path
456
+ */
457
+ router.get('/tasks/:taskId/stream', async (req: Request, res: Response) => {
458
+ const { taskId } = req.params;
459
+ const projectPath = req.query.project as string | undefined;
460
+
461
+ if (!projectPath) {
462
+ res.status(400).json({
463
+ success: false,
464
+ error: 'Missing required query parameter: project',
465
+ });
466
+ return;
467
+ }
468
+
469
+ if (activeSseConnections >= MAX_SSE_CONNECTIONS) {
470
+ res.status(429).json({
471
+ success: false,
472
+ error: 'Too many active streams',
473
+ max: MAX_SSE_CONNECTIONS,
474
+ });
475
+ return;
476
+ }
477
+
478
+ activeSseConnections++;
479
+
480
+ let closed = false;
481
+ const close = (): void => {
482
+ if (closed) return;
483
+ closed = true;
484
+ activeSseConnections = Math.max(0, activeSseConnections - 1);
485
+ try {
486
+ res.end();
487
+ } catch {
488
+ // ignore
489
+ }
490
+ };
491
+
492
+ req.on('close', close);
493
+
494
+ // SSE headers
495
+ res.setHeader('Content-Type', 'text/event-stream');
496
+ res.setHeader('Cache-Control', 'no-cache');
497
+ res.setHeader('Connection', 'keep-alive');
498
+ res.setHeader('X-Accel-Buffering', 'no');
499
+ res.flushHeaders();
500
+
501
+ const db = openProjectDatabase(projectPath);
502
+ if (!db) {
503
+ await writeSSE(res, { type: 'error', error: 'Project database not found', project: projectPath });
504
+ close();
505
+ return;
506
+ }
507
+
508
+ let invocation: { id: number; status: string } | undefined;
509
+ try {
510
+ invocation = db
511
+ .prepare(
512
+ `SELECT id, status
513
+ FROM task_invocations
514
+ WHERE task_id = ? AND status = 'running'
515
+ ORDER BY started_at_ms DESC
516
+ LIMIT 1`
517
+ )
518
+ .get(taskId) as { id: number; status: string } | undefined;
519
+ } catch (error) {
520
+ await writeSSE(res, {
521
+ type: 'error',
522
+ error: 'Failed to query running invocation (is the database migrated?)',
523
+ message: error instanceof Error ? error.message : String(error),
524
+ });
525
+ close();
526
+ return;
527
+ } finally {
528
+ try {
529
+ db.close();
530
+ } catch {
531
+ // ignore
532
+ }
533
+ }
534
+
535
+ if (!invocation) {
536
+ await writeSSE(res, { type: 'no_active_invocation', taskId });
537
+ close();
538
+ return;
539
+ }
540
+
541
+ const logFile = join(projectPath, '.steroids', 'invocations', `${invocation.id}.log`);
542
+ const isAborted = (): boolean => closed || res.writableEnded;
543
+
544
+ // If the invocation just started, the log file may not exist yet.
545
+ if (!existsSync(logFile)) {
546
+ await writeSSE(res, { type: 'waiting_for_log', taskId, invocationId: invocation.id });
547
+ const ok = await waitForFile(logFile, { timeoutMs: 5000, pollMs: 100, isAborted });
548
+ if (!ok) {
549
+ await writeSSE(res, { type: 'log_not_found', taskId, invocationId: invocation.id });
550
+ close();
551
+ return;
552
+ }
553
+ }
554
+
555
+ try {
556
+ // 1) Send existing log entries first
557
+ await streamJsonlFileToSSE(res, logFile, { isAborted });
558
+
559
+ if (isAborted()) return;
560
+
561
+ // 2) Tail for new entries
562
+ const tail = new Tail(logFile, { follow: true, useWatchFile: true });
563
+ let writeChain: Promise<void> = Promise.resolve();
564
+
565
+ const heartbeat = setInterval(() => {
566
+ // Keep proxies from timing out the connection.
567
+ writeChain = writeChain.then(() => writeSSEComment(res, 'heartbeat')).catch(() => {});
568
+ }, 30000);
569
+
570
+ const cleanup = (): void => {
571
+ clearInterval(heartbeat);
572
+ try {
573
+ tail.unwatch();
574
+ } catch {
575
+ // ignore
576
+ }
577
+ close();
578
+ };
579
+
580
+ req.on('close', () => {
581
+ clearInterval(heartbeat);
582
+ try {
583
+ tail.unwatch();
584
+ } catch {}
585
+ });
586
+
587
+ tail.on('line', (line: string) => {
588
+ if (isAborted()) return;
589
+ const trimmed = line.trim();
590
+ if (!trimmed) return;
591
+
592
+ writeChain = writeChain
593
+ .then(async () => {
594
+ try {
595
+ const entry = JSON.parse(trimmed) as any;
596
+ await writeSSE(res, entry);
597
+ if (entry?.type === 'complete' || entry?.type === 'error') {
598
+ cleanup();
599
+ }
600
+ } catch {
601
+ // ignore malformed JSONL lines
602
+ }
603
+ })
604
+ .catch(() => {});
605
+ });
606
+
607
+ tail.on('error', (err: unknown) => {
608
+ void writeChain
609
+ .then(() =>
610
+ writeSSE(res, {
611
+ type: 'error',
612
+ error: 'Tail error',
613
+ message: err instanceof Error ? err.message : String(err),
614
+ })
615
+ )
616
+ .finally(cleanup);
617
+ });
618
+ } catch (error) {
619
+ await writeSSE(res, {
620
+ type: 'error',
621
+ error: 'Failed to stream invocation log',
622
+ message: error instanceof Error ? error.message : String(error),
623
+ });
624
+ close();
625
+ }
626
+ });
627
+
628
+ /**
629
+ * GET /api/tasks/:taskId/timeline
630
+ * Parse invocation JSONL activity logs on demand and return a sampled timeline.
631
+ * Query params:
632
+ * - project: string (required) - project path
633
+ */
634
+ router.get('/tasks/:taskId/timeline', async (req: Request, res: Response) => {
635
+ const { taskId } = req.params;
636
+ const projectPath = req.query.project as string | undefined;
637
+
638
+ if (!projectPath) {
639
+ res.status(400).json({
640
+ success: false,
641
+ error: 'Missing required query parameter: project',
642
+ });
643
+ return;
644
+ }
645
+
646
+ const db = openProjectDatabase(projectPath);
647
+ if (!db) {
648
+ res.status(404).json({
649
+ success: false,
650
+ error: 'Project database not found',
651
+ project: projectPath,
652
+ });
653
+ return;
654
+ }
655
+
656
+ type InvocationTimelineRow = {
657
+ id: number;
658
+ role: string;
659
+ provider: string;
660
+ model: string;
661
+ started_at_ms: number;
662
+ completed_at_ms: number | null;
663
+ status: string;
664
+ };
665
+
666
+ let invocations: InvocationTimelineRow[] = [];
667
+ try {
668
+ invocations = db
669
+ .prepare(
670
+ `SELECT id, role, provider, model, started_at_ms, completed_at_ms, status
671
+ FROM task_invocations
672
+ WHERE task_id = ?
673
+ ORDER BY started_at_ms ASC`
674
+ )
675
+ .all(taskId) as InvocationTimelineRow[];
676
+ } catch (error) {
677
+ res.status(500).json({
678
+ success: false,
679
+ error: 'Failed to query invocations (is the database migrated?)',
680
+ message: error instanceof Error ? error.message : String(error),
681
+ });
682
+ return;
683
+ } finally {
684
+ try {
685
+ db.close();
686
+ } catch {}
687
+ }
688
+
689
+ const timeline: any[] = [];
690
+
691
+ for (const inv of invocations) {
692
+ // Invocation start event (from DB lifecycle timestamps).
693
+ timeline.push({
694
+ ts: inv.started_at_ms,
695
+ type: 'invocation.started',
696
+ invocationId: inv.id,
697
+ role: inv.role,
698
+ provider: inv.provider,
699
+ model: inv.model,
700
+ });
701
+
702
+ const logFile = join(projectPath, '.steroids', 'invocations', `${inv.id}.log`);
703
+ if (existsSync(logFile)) {
704
+ try {
705
+ const sampled = await readSampledJsonlEntries(logFile, { keepEveryN: 10 });
706
+ // Exclude output events from timeline - they're only shown in the live stream
707
+ for (const e of sampled) {
708
+ if (e.type !== 'output') timeline.push({ ...e, invocationId: inv.id });
709
+ }
710
+ } catch {
711
+ // ignore per spec: timeline is best-effort
712
+ }
713
+ }
714
+
715
+ // Invocation completion event, when available.
716
+ if (inv.completed_at_ms) {
717
+ timeline.push({
718
+ ts: inv.completed_at_ms,
719
+ type: 'invocation.completed',
720
+ invocationId: inv.id,
721
+ success: inv.status === 'completed',
722
+ duration: inv.completed_at_ms - inv.started_at_ms,
723
+ });
724
+ }
725
+ }
726
+
727
+ res.json({ success: true, timeline });
728
+ });
729
+
730
+ /**
731
+ * GET /api/tasks/:taskId/logs
732
+ * Get execution logs/audit trail for a task
733
+ * Query params:
734
+ * - project: string (required) - project path
735
+ * - limit: number (optional) - max entries to return (default: 50)
736
+ * - offset: number (optional) - offset for pagination (default: 0)
737
+ */
738
+ router.get('/tasks/:taskId/logs', (req: Request, res: Response) => {
739
+ try {
740
+ const { taskId } = req.params;
741
+ const projectPath = req.query.project as string;
742
+ const limitParam = req.query.limit;
743
+ const offsetParam = req.query.offset;
744
+
745
+ if (!projectPath) {
746
+ res.status(400).json({
747
+ success: false,
748
+ error: 'Missing required query parameter: project',
749
+ });
750
+ return;
751
+ }
752
+
753
+ // Parse limit and offset
754
+ let limit = 50;
755
+ let offset = 0;
756
+
757
+ if (limitParam !== undefined) {
758
+ const parsed = parseInt(limitParam as string, 10);
759
+ if (!isNaN(parsed) && parsed > 0) {
760
+ limit = Math.min(parsed, 500); // Cap at 500
761
+ }
762
+ }
763
+
764
+ if (offsetParam !== undefined) {
765
+ const parsed = parseInt(offsetParam as string, 10);
766
+ if (!isNaN(parsed) && parsed >= 0) {
767
+ offset = parsed;
768
+ }
769
+ }
770
+
771
+ const db = openProjectDatabase(projectPath);
772
+ if (!db) {
773
+ res.status(404).json({
774
+ success: false,
775
+ error: 'Project database not found',
776
+ project: projectPath,
777
+ });
778
+ return;
779
+ }
780
+
781
+ try {
782
+ // Check task exists
783
+ const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(taskId) as
784
+ | { id: string; title: string; status: string }
785
+ | undefined;
786
+
787
+ if (!task) {
788
+ res.status(404).json({
789
+ success: false,
790
+ error: 'Task not found',
791
+ task_id: taskId,
792
+ });
793
+ return;
794
+ }
795
+
796
+ // Get total count
797
+ const countResult = db
798
+ .prepare('SELECT COUNT(*) as count FROM audit WHERE task_id = ?')
799
+ .get(taskId) as { count: number };
800
+
801
+ // Get audit entries with pagination
802
+ const logs = db
803
+ .prepare(
804
+ `SELECT id, task_id, from_status, to_status, actor, actor_type, model, notes, commit_sha, created_at
805
+ FROM audit
806
+ WHERE task_id = ?
807
+ ORDER BY created_at DESC
808
+ LIMIT ? OFFSET ?`
809
+ )
810
+ .all(taskId, limit, offset) as AuditEntry[];
811
+
812
+ res.json({
813
+ success: true,
814
+ task_id: taskId,
815
+ task_title: task.title,
816
+ task_status: task.status,
817
+ logs,
818
+ pagination: {
819
+ total: countResult.count,
820
+ limit,
821
+ offset,
822
+ has_more: offset + logs.length < countResult.count,
823
+ },
824
+ });
825
+ } finally {
826
+ db.close();
827
+ }
828
+ } catch (error) {
829
+ console.error('Error getting task logs:', error);
830
+ res.status(500).json({
831
+ success: false,
832
+ error: 'Failed to get task logs',
833
+ message: error instanceof Error ? error.message : 'Unknown error',
834
+ });
835
+ }
836
+ });
837
+
838
+ /**
839
+ * GET /api/projects/:projectPath/sections
840
+ * List all sections for a project with task counts by status
841
+ */
842
+ router.get('/projects/:projectPath(*)/sections', (req: Request, res: Response) => {
843
+ try {
844
+ const projectPath = decodeURIComponent(req.params.projectPath);
845
+
846
+ const db = openProjectDatabase(projectPath);
847
+ if (!db) {
848
+ res.status(404).json({
849
+ success: false,
850
+ error: 'Project database not found',
851
+ project: projectPath,
852
+ });
853
+ return;
854
+ }
855
+
856
+ try {
857
+ // Check which columns exist (handles older DBs missing priority/branch/pr fields)
858
+ const sectionCols = (() => {
859
+ try {
860
+ const cols = db.prepare("PRAGMA table_info(sections)").all() as Array<{ name: string }>;
861
+ return new Set(cols.map(c => c.name));
862
+ } catch {
863
+ return new Set<string>();
864
+ }
865
+ })();
866
+
867
+ const hasPriority = sectionCols.has('priority');
868
+ const hasBranch = sectionCols.has('branch');
869
+ const hasPrFields = sectionCols.has('pr_number') && sectionCols.has('auto_pr');
870
+
871
+ const prioritySelect = hasPriority ? 's.priority,' : '50 as priority,';
872
+ const branchSelect = hasBranch ? 's.branch,' : 'NULL as branch,';
873
+ const prSelect = hasPrFields ? 's.auto_pr, s.pr_number,' : '0 as auto_pr, NULL as pr_number,';
874
+ const orderBy = hasPriority ? 'ORDER BY s.priority DESC, s.name ASC' : 'ORDER BY s.name ASC';
875
+
876
+ // Get sections with task counts by status
877
+ const sections = db
878
+ .prepare(
879
+ `SELECT
880
+ s.id,
881
+ s.name,
882
+ ${prioritySelect}
883
+ ${branchSelect}
884
+ ${prSelect}
885
+ s.created_at,
886
+ COUNT(t.id) as total_tasks,
887
+ SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
888
+ SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
889
+ SUM(CASE WHEN t.status = 'review' THEN 1 ELSE 0 END) as review,
890
+ SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
891
+ SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed,
892
+ SUM(CASE WHEN t.status = 'skipped' THEN 1 ELSE 0 END) as skipped
893
+ FROM sections s
894
+ LEFT JOIN tasks t ON t.section_id = s.id
895
+ GROUP BY s.id
896
+ ${orderBy}`
897
+ )
898
+ .all() as Array<{
899
+ id: string;
900
+ name: string;
901
+ priority: number;
902
+ branch: string | null;
903
+ auto_pr: number;
904
+ pr_number: number | null;
905
+ created_at: string;
906
+ total_tasks: number;
907
+ pending: number;
908
+ in_progress: number;
909
+ review: number;
910
+ completed: number;
911
+ failed: number;
912
+ skipped: number;
913
+ }>;
914
+
915
+ // Derive PR URL from git remote + pr_number (GitHub-only)
916
+ const githubBaseUrl = getGitHubUrl(projectPath);
917
+
918
+ // Fetch all section dependencies in one query, keyed by section_id
919
+ // Wrapped in try/catch for older DBs that pre-date migration 004
920
+ const dependsBySection = new Map<string, string[]>();
921
+ try {
922
+ const deps = db.prepare(`
923
+ SELECT sd.section_id, s.name as depends_on_name
924
+ FROM section_dependencies sd
925
+ JOIN sections s ON sd.depends_on_section_id = s.id
926
+ ORDER BY s.name ASC
927
+ `).all() as Array<{ section_id: string; depends_on_name: string }>;
928
+ for (const row of deps) {
929
+ const existing = dependsBySection.get(row.section_id);
930
+ if (existing) {
931
+ existing.push(row.depends_on_name);
932
+ } else {
933
+ dependsBySection.set(row.section_id, [row.depends_on_name]);
934
+ }
935
+ }
936
+ } catch {
937
+ // section_dependencies table absent — leave map empty
938
+ }
939
+
940
+ // Also get tasks without a section (null section_id)
941
+ const unassigned = db
942
+ .prepare(
943
+ `SELECT
944
+ COUNT(*) as total_tasks,
945
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
946
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
947
+ SUM(CASE WHEN status = 'review' THEN 1 ELSE 0 END) as review,
948
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
949
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
950
+ SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) as skipped
951
+ FROM tasks
952
+ WHERE section_id IS NULL`
953
+ )
954
+ .get() as {
955
+ total_tasks: number;
956
+ pending: number;
957
+ in_progress: number;
958
+ review: number;
959
+ completed: number;
960
+ failed: number;
961
+ skipped: number;
962
+ };
963
+
964
+ res.json({
965
+ success: true,
966
+ project: projectPath,
967
+ sections: sections.map(s => ({
968
+ ...s,
969
+ auto_pr: s.auto_pr !== 0,
970
+ pr_url: s.pr_number && githubBaseUrl ? `${githubBaseUrl}/pull/${s.pr_number}` : null,
971
+ depends_on: dependsBySection.get(s.id) ?? [],
972
+ })),
973
+ unassigned: unassigned.total_tasks > 0 ? unassigned : null,
974
+ });
975
+ } finally {
976
+ db.close();
977
+ }
978
+ } catch (error) {
979
+ console.error('Error listing project sections:', error);
980
+ res.status(500).json({
981
+ success: false,
982
+ error: 'Failed to list project sections',
983
+ message: error instanceof Error ? error.message : 'Unknown error',
984
+ });
985
+ }
986
+ });
987
+
988
+ /**
989
+ * GET /api/projects/:projectPath/tasks
990
+ * List all tasks for a project
991
+ * Query params:
992
+ * - status: string (optional) - filter by status
993
+ * - section: string (optional) - filter by section id
994
+ * - issue: string (optional) - filter by issue bucket (failed_retries, stale)
995
+ * - hours: number (optional) - filter to tasks created within last N hours
996
+ * - limit: number (optional) - max entries (default: 100)
997
+ */
998
+ router.get('/projects/:projectPath(*)/tasks', (req: Request, res: Response) => {
999
+ try {
1000
+ // projectPath comes URL-encoded, decode it
1001
+ const projectPath = decodeURIComponent(req.params.projectPath);
1002
+ const statusFilter = req.query.status as string | undefined;
1003
+ const sectionFilter = req.query.section as string | undefined;
1004
+ const issueFilter = req.query.issue as string | undefined;
1005
+ const hoursParam = req.query.hours;
1006
+ const limitParam = req.query.limit;
1007
+
1008
+ const normalizedIssueFilter =
1009
+ issueFilter === 'failed_retries' || issueFilter === 'stale'
1010
+ ? issueFilter
1011
+ : undefined;
1012
+
1013
+ if (issueFilter && !normalizedIssueFilter) {
1014
+ res.status(400).json({
1015
+ success: false,
1016
+ error: 'Invalid issue parameter - supported values: failed_retries, stale',
1017
+ });
1018
+ return;
1019
+ }
1020
+
1021
+ let limit = 100;
1022
+ if (limitParam !== undefined) {
1023
+ const parsed = parseInt(limitParam as string, 10);
1024
+ if (!isNaN(parsed) && parsed > 0) {
1025
+ limit = Math.min(parsed, 500);
1026
+ }
1027
+ }
1028
+
1029
+ let hoursFilter: number | undefined;
1030
+ if (hoursParam !== undefined) {
1031
+ const parsed = parseInt(hoursParam as string, 10);
1032
+ if (isNaN(parsed) || parsed <= 0) {
1033
+ res.status(400).json({
1034
+ success: false,
1035
+ error: 'Invalid hours parameter - must be a positive integer',
1036
+ });
1037
+ return;
1038
+ }
1039
+ hoursFilter = parsed;
1040
+ }
1041
+
1042
+ const db = openProjectDatabase(projectPath);
1043
+ if (!db) {
1044
+ res.status(404).json({
1045
+ success: false,
1046
+ error: 'Project database not found',
1047
+ project: projectPath,
1048
+ });
1049
+ return;
1050
+ }
1051
+
1052
+ try {
1053
+ let staleIssueSupported = false;
1054
+ if (normalizedIssueFilter === 'stale') {
1055
+ try {
1056
+ const row = db
1057
+ .prepare(`SELECT 1 as exists_flag FROM sqlite_master WHERE type = 'table' AND name = 'incidents' LIMIT 1`)
1058
+ .get() as { exists_flag: number } | undefined;
1059
+ staleIssueSupported = Boolean(row?.exists_flag);
1060
+ } catch {
1061
+ staleIssueSupported = false;
1062
+ }
1063
+ }
1064
+
1065
+ let query = `
1066
+ SELECT
1067
+ t.id, t.title, t.status, t.section_id,
1068
+ s.name as section_name,
1069
+ t.source_file, t.rejection_count, t.failure_count,
1070
+ t.created_at, t.updated_at
1071
+ FROM tasks t
1072
+ LEFT JOIN sections s ON t.section_id = s.id
1073
+ WHERE 1=1
1074
+ `;
1075
+ const params: (string | number)[] = [];
1076
+
1077
+ if (statusFilter) {
1078
+ query += ' AND t.status = ?';
1079
+ params.push(statusFilter);
1080
+ }
1081
+
1082
+ if (sectionFilter) {
1083
+ query += ' AND t.section_id = ?';
1084
+ params.push(sectionFilter);
1085
+ }
1086
+
1087
+ if (hoursFilter) {
1088
+ query += ` AND julianday(t.created_at) >= julianday('now', ? || ' hours')`;
1089
+ params.push(`-${hoursFilter}`);
1090
+ }
1091
+
1092
+ if (normalizedIssueFilter === 'failed_retries') {
1093
+ query += ` AND COALESCE(t.failure_count, 0) > 0`;
1094
+ }
1095
+
1096
+ if (normalizedIssueFilter === 'stale') {
1097
+ if (!staleIssueSupported) {
1098
+ query += ' AND 1=0';
1099
+ } else {
1100
+ query += `
1101
+ AND EXISTS (
1102
+ SELECT 1
1103
+ FROM incidents i
1104
+ WHERE i.task_id = t.id
1105
+ AND i.resolved_at IS NULL
1106
+ AND i.failure_mode IN ('orphaned_task', 'hanging_invocation')
1107
+ )
1108
+ `;
1109
+ }
1110
+ }
1111
+
1112
+ query += ' ORDER BY t.created_at DESC LIMIT ?';
1113
+ params.push(limit);
1114
+
1115
+ const tasks = db.prepare(query).all(...params) as TaskDetails[];
1116
+
1117
+ // Get task counts by status
1118
+ let countsQuery = `
1119
+ SELECT status, COUNT(*) as count
1120
+ FROM tasks
1121
+ WHERE 1=1
1122
+ `;
1123
+ const countParams: (string | number)[] = [];
1124
+ if (hoursFilter) {
1125
+ countsQuery += ` AND julianday(created_at) >= julianday('now', ? || ' hours')`;
1126
+ countParams.push(`-${hoursFilter}`);
1127
+ }
1128
+
1129
+ if (normalizedIssueFilter === 'failed_retries') {
1130
+ countsQuery += ` AND COALESCE(failure_count, 0) > 0`;
1131
+ }
1132
+
1133
+ if (normalizedIssueFilter === 'stale') {
1134
+ if (!staleIssueSupported) {
1135
+ countsQuery += ' AND 1=0';
1136
+ } else {
1137
+ countsQuery += `
1138
+ AND EXISTS (
1139
+ SELECT 1
1140
+ FROM incidents i
1141
+ WHERE i.task_id = tasks.id
1142
+ AND i.resolved_at IS NULL
1143
+ AND i.failure_mode IN ('orphaned_task', 'hanging_invocation')
1144
+ )
1145
+ `;
1146
+ }
1147
+ }
1148
+ countsQuery += ' GROUP BY status';
1149
+
1150
+ const statusCounts = db
1151
+ .prepare(countsQuery)
1152
+ .all(...countParams) as { status: string; count: number }[];
1153
+
1154
+ const counts = statusCounts.reduce(
1155
+ (acc, { status, count }) => {
1156
+ acc[status] = count;
1157
+ return acc;
1158
+ },
1159
+ {} as Record<string, number>
1160
+ );
1161
+
1162
+ // Pending tasks are always shown regardless of time range — a task created
1163
+ // two weeks ago that is still pending is just as relevant as one from today.
1164
+ if (hoursFilter) {
1165
+ const pendingRow = db
1166
+ .prepare(`SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'`)
1167
+ .get() as { count: number };
1168
+ counts['pending'] = pendingRow.count;
1169
+ }
1170
+
1171
+ res.json({
1172
+ success: true,
1173
+ project: projectPath,
1174
+ tasks,
1175
+ count: tasks.length,
1176
+ status_counts: counts,
1177
+ });
1178
+ } finally {
1179
+ db.close();
1180
+ }
1181
+ } catch (error) {
1182
+ console.error('Error listing project tasks:', error);
1183
+ res.status(500).json({
1184
+ success: false,
1185
+ error: 'Failed to list project tasks',
1186
+ message: error instanceof Error ? error.message : 'Unknown error',
1187
+ });
1188
+ }
1189
+ });
1190
+
1191
+ /**
1192
+ * GET /api/tasks/:taskId/invocations/:invocationId
1193
+ * Get full details for a specific invocation including prompt and response
1194
+ * Query params:
1195
+ * - project: string (required) - project path
1196
+ */
1197
+ router.get('/tasks/:taskId/invocations/:invocationId', (req: Request, res: Response) => {
1198
+ try {
1199
+ const { taskId, invocationId } = req.params;
1200
+ const projectPath = req.query.project as string;
1201
+
1202
+ if (!projectPath) {
1203
+ res.status(400).json({
1204
+ success: false,
1205
+ error: 'Missing required query parameter: project',
1206
+ });
1207
+ return;
1208
+ }
1209
+
1210
+ const db = openProjectDatabase(projectPath);
1211
+ if (!db) {
1212
+ res.status(404).json({
1213
+ success: false,
1214
+ error: 'Project database not found',
1215
+ project: projectPath,
1216
+ });
1217
+ return;
1218
+ }
1219
+
1220
+ try {
1221
+ // Get full invocation details including prompt and response
1222
+ const invocation = db
1223
+ .prepare(
1224
+ `SELECT id, task_id, role, provider, model, prompt, response, error, exit_code, duration_ms, success, timed_out, rejection_number, created_at
1225
+ FROM task_invocations
1226
+ WHERE id = ? AND task_id = ?`
1227
+ )
1228
+ .get(invocationId, taskId) as InvocationDetails | undefined;
1229
+
1230
+ if (!invocation) {
1231
+ res.status(404).json({
1232
+ success: false,
1233
+ error: 'Invocation not found',
1234
+ invocation_id: invocationId,
1235
+ task_id: taskId,
1236
+ });
1237
+ return;
1238
+ }
1239
+
1240
+ res.json({
1241
+ success: true,
1242
+ invocation,
1243
+ });
1244
+ } finally {
1245
+ db.close();
1246
+ }
1247
+ } catch (error) {
1248
+ console.error('Error getting invocation details:', error);
1249
+ res.status(500).json({
1250
+ success: false,
1251
+ error: 'Failed to get invocation details',
1252
+ message: error instanceof Error ? error.message : 'Unknown error',
1253
+ });
1254
+ }
1255
+ });
1256
+
1257
+ /**
1258
+ * POST /api/tasks/:taskId/restart
1259
+ * Restart a failed/disputed task by resetting rejection count and setting status to pending
1260
+ * Body: { project: string, notes?: string }
1261
+ * Notes are stored in the audit entry as human guidance for the coder
1262
+ */
1263
+ router.post('/tasks/:taskId/restart', (req: Request, res: Response) => {
1264
+ try {
1265
+ const { taskId } = req.params;
1266
+ const projectPath = req.body.project as string;
1267
+ const notes = req.body.notes as string | undefined;
1268
+
1269
+ if (!projectPath) {
1270
+ res.status(400).json({
1271
+ success: false,
1272
+ error: 'Missing required body parameter: project',
1273
+ });
1274
+ return;
1275
+ }
1276
+
1277
+ const dbPath = join(projectPath, '.steroids', 'steroids.db');
1278
+ if (!existsSync(dbPath)) {
1279
+ res.status(404).json({
1280
+ success: false,
1281
+ error: 'Project database not found',
1282
+ project: projectPath,
1283
+ });
1284
+ return;
1285
+ }
1286
+
1287
+ let db: Database.Database;
1288
+ try {
1289
+ db = new Database(dbPath); // Writable mode
1290
+ } catch {
1291
+ res.status(500).json({
1292
+ success: false,
1293
+ error: 'Failed to open project database',
1294
+ });
1295
+ return;
1296
+ }
1297
+
1298
+ try {
1299
+ // Check task exists
1300
+ const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(taskId) as
1301
+ | { id: string; title: string; status: string }
1302
+ | undefined;
1303
+
1304
+ if (!task) {
1305
+ res.status(404).json({
1306
+ success: false,
1307
+ error: 'Task not found',
1308
+ task_id: taskId,
1309
+ });
1310
+ return;
1311
+ }
1312
+
1313
+ // Block restart for tasks already in progress
1314
+ if (task.status === 'in_progress' || task.status === 'review') {
1315
+ res.status(400).json({
1316
+ success: false,
1317
+ error: `Cannot restart task in ${task.status} status. Task is currently being worked on.`,
1318
+ });
1319
+ return;
1320
+ }
1321
+
1322
+ // Resolve any open disputes for this task
1323
+ const openDisputes = db
1324
+ .prepare(`SELECT id FROM disputes WHERE task_id = ? AND status = 'open'`)
1325
+ .all(taskId) as { id: string }[];
1326
+
1327
+ for (const dispute of openDisputes) {
1328
+ db.prepare(
1329
+ `UPDATE disputes
1330
+ SET status = 'resolved', resolution = 'custom', resolution_notes = ?,
1331
+ resolved_by = 'human:webui', resolved_at = datetime('now')
1332
+ WHERE id = ?`
1333
+ ).run(notes || 'Resolved via WebUI restart', dispute.id);
1334
+ }
1335
+
1336
+ // Reset task: set status to pending and rejection_count to 0
1337
+ db.prepare(
1338
+ `UPDATE tasks
1339
+ SET status = 'pending', rejection_count = 0, updated_at = datetime('now')
1340
+ WHERE id = ?`
1341
+ ).run(taskId);
1342
+
1343
+ // Add audit entry with human guidance notes
1344
+ const auditNotes = notes
1345
+ ? `Task restarted via WebUI. Human guidance: ${notes}`
1346
+ : 'Task restarted via WebUI';
1347
+
1348
+ db.prepare(
1349
+ `INSERT INTO audit (task_id, from_status, to_status, actor, actor_type, notes, created_at)
1350
+ VALUES (?, ?, 'pending', 'human:webui', 'human', ?, datetime('now'))`
1351
+ ).run(taskId, task.status, auditNotes);
1352
+
1353
+ res.json({
1354
+ success: true,
1355
+ message: 'Task restarted successfully',
1356
+ task_id: taskId,
1357
+ disputes_resolved: openDisputes.length,
1358
+ });
1359
+ } finally {
1360
+ db.close();
1361
+ }
1362
+ } catch (error) {
1363
+ console.error('Error restarting task:', error);
1364
+ res.status(500).json({
1365
+ success: false,
1366
+ error: 'Failed to restart task',
1367
+ message: error instanceof Error ? error.message : 'Unknown error',
1368
+ });
1369
+ }
1370
+ });
1371
+
1372
+ export default router;