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