lastlight 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (478) hide show
  1. package/README.md +265 -118
  2. package/agent-context/rules.md +62 -9
  3. package/agent-context/security.md +69 -0
  4. package/config/default.yaml +89 -0
  5. package/deploy/.env.production.example +20 -2
  6. package/deploy/entrypoint.sh +16 -17
  7. package/deploy/native/README.md +160 -0
  8. package/deploy/native/install.sh +110 -0
  9. package/deploy/native/lastlight.env.example +95 -0
  10. package/deploy/native/lastlight.service +50 -0
  11. package/deploy/sandbox-entrypoint.sh +59 -42
  12. package/dist/admin/auth.d.ts +1 -1
  13. package/dist/admin/auth.js +3 -1
  14. package/dist/admin/auth.js.map +1 -1
  15. package/dist/admin/chat-session-reader.d.ts +11 -14
  16. package/dist/admin/chat-session-reader.js +27 -51
  17. package/dist/admin/chat-session-reader.js.map +1 -1
  18. package/dist/admin/docker.d.ts +37 -0
  19. package/dist/admin/docker.js +86 -1
  20. package/dist/admin/docker.js.map +1 -1
  21. package/dist/admin/index.js +38 -3
  22. package/dist/admin/index.js.map +1 -1
  23. package/dist/admin/log-search.test.d.ts +1 -0
  24. package/dist/admin/log-search.test.js +78 -0
  25. package/dist/admin/log-search.test.js.map +1 -0
  26. package/dist/admin/routes.d.ts +52 -3
  27. package/dist/admin/routes.js +780 -63
  28. package/dist/admin/routes.js.map +1 -1
  29. package/dist/admin/routes.test.js +680 -14
  30. package/dist/admin/routes.test.js.map +1 -1
  31. package/dist/admin/server-logs.test.d.ts +1 -0
  32. package/dist/admin/server-logs.test.js +58 -0
  33. package/dist/admin/server-logs.test.js.map +1 -0
  34. package/dist/admin/sessions.d.ts +26 -39
  35. package/dist/admin/sessions.js +86 -310
  36. package/dist/admin/sessions.js.map +1 -1
  37. package/dist/admin/sessions.test.d.ts +1 -0
  38. package/dist/admin/sessions.test.js +78 -0
  39. package/dist/admin/sessions.test.js.map +1 -0
  40. package/dist/admin/version.d.ts +15 -0
  41. package/dist/admin/version.js +73 -0
  42. package/dist/admin/version.js.map +1 -0
  43. package/dist/admin/version.test.d.ts +1 -0
  44. package/dist/admin/version.test.js +71 -0
  45. package/dist/admin/version.test.js.map +1 -0
  46. package/dist/cli-config.d.ts +55 -0
  47. package/dist/cli-config.js +119 -0
  48. package/dist/cli-config.js.map +1 -0
  49. package/dist/cli-config.test.d.ts +1 -0
  50. package/dist/cli-config.test.js +92 -0
  51. package/dist/cli-config.test.js.map +1 -0
  52. package/dist/cli-format.d.ts +19 -0
  53. package/dist/cli-format.js +107 -0
  54. package/dist/cli-format.js.map +1 -0
  55. package/dist/cli-server.d.ts +73 -0
  56. package/dist/cli-server.js +347 -0
  57. package/dist/cli-server.js.map +1 -0
  58. package/dist/cli-server.test.d.ts +1 -0
  59. package/dist/cli-server.test.js +38 -0
  60. package/dist/cli-server.test.js.map +1 -0
  61. package/dist/cli-timeline.d.ts +35 -0
  62. package/dist/cli-timeline.js +373 -0
  63. package/dist/cli-timeline.js.map +1 -0
  64. package/dist/cli-timeline.test.d.ts +1 -0
  65. package/dist/cli-timeline.test.js +104 -0
  66. package/dist/cli-timeline.test.js.map +1 -0
  67. package/dist/cli.d.ts +0 -19
  68. package/dist/cli.js +910 -194
  69. package/dist/cli.js.map +1 -1
  70. package/dist/config-overlay.test.d.ts +1 -0
  71. package/dist/config-overlay.test.js +112 -0
  72. package/dist/config-overlay.test.js.map +1 -0
  73. package/dist/config-resolve.d.ts +28 -0
  74. package/dist/config-resolve.js +47 -0
  75. package/dist/config-resolve.js.map +1 -0
  76. package/dist/config-resolve.test.d.ts +1 -0
  77. package/dist/config-resolve.test.js +68 -0
  78. package/dist/config-resolve.test.js.map +1 -0
  79. package/dist/config.d.ts +62 -49
  80. package/dist/config.js +452 -76
  81. package/dist/config.js.map +1 -1
  82. package/dist/config.test.js +201 -32
  83. package/dist/config.test.js.map +1 -1
  84. package/dist/connectors/github-webhook.js +83 -5
  85. package/dist/connectors/github-webhook.js.map +1 -1
  86. package/dist/connectors/github-webhook.test.d.ts +1 -0
  87. package/dist/connectors/github-webhook.test.js +156 -0
  88. package/dist/connectors/github-webhook.test.js.map +1 -0
  89. package/dist/connectors/messaging/session-manager.d.ts +18 -0
  90. package/dist/connectors/messaging/session-manager.js +83 -2
  91. package/dist/connectors/messaging/session-manager.js.map +1 -1
  92. package/dist/connectors/messaging/session-manager.test.d.ts +1 -0
  93. package/dist/connectors/messaging/session-manager.test.js +144 -0
  94. package/dist/connectors/messaging/session-manager.test.js.map +1 -0
  95. package/dist/connectors/slack/connector.d.ts +9 -0
  96. package/dist/connectors/slack/connector.js +15 -0
  97. package/dist/connectors/slack/connector.js.map +1 -1
  98. package/dist/connectors/slack/mrkdwn.js +81 -0
  99. package/dist/connectors/slack/mrkdwn.js.map +1 -1
  100. package/dist/connectors/slack/mrkdwn.test.js +49 -0
  101. package/dist/connectors/slack/mrkdwn.test.js.map +1 -1
  102. package/dist/connectors/types.d.ts +1 -1
  103. package/dist/cron/fanout.d.ts +33 -0
  104. package/dist/cron/fanout.js +55 -0
  105. package/dist/cron/fanout.js.map +1 -0
  106. package/dist/cron/fanout.test.d.ts +1 -0
  107. package/dist/cron/fanout.test.js +73 -0
  108. package/dist/cron/fanout.test.js.map +1 -0
  109. package/dist/cron/jobs.d.ts +5 -0
  110. package/dist/cron/jobs.js +10 -3
  111. package/dist/cron/jobs.js.map +1 -1
  112. package/dist/cron/scheduler.d.ts +12 -0
  113. package/dist/cron/scheduler.js +27 -1
  114. package/dist/cron/scheduler.js.map +1 -1
  115. package/dist/engine/agent-executor.d.ts +158 -0
  116. package/dist/engine/agent-executor.js +1140 -0
  117. package/dist/engine/agent-executor.js.map +1 -0
  118. package/dist/engine/agent-executor.test.d.ts +1 -0
  119. package/dist/engine/agent-executor.test.js +395 -0
  120. package/dist/engine/agent-executor.test.js.map +1 -0
  121. package/dist/engine/chat-runner.d.ts +123 -0
  122. package/dist/engine/chat-runner.js +379 -0
  123. package/dist/engine/chat-runner.js.map +1 -0
  124. package/dist/engine/chat-runner.test.d.ts +1 -0
  125. package/dist/engine/chat-runner.test.js +104 -0
  126. package/dist/engine/chat-runner.test.js.map +1 -0
  127. package/dist/engine/chat-skills.d.ts +45 -0
  128. package/dist/engine/chat-skills.js +184 -0
  129. package/dist/engine/chat-skills.js.map +1 -0
  130. package/dist/engine/chat-skills.test.d.ts +1 -0
  131. package/dist/engine/chat-skills.test.js +20 -0
  132. package/dist/engine/chat-skills.test.js.map +1 -0
  133. package/dist/engine/chat.d.ts +20 -23
  134. package/dist/engine/chat.js +251 -155
  135. package/dist/engine/chat.js.map +1 -1
  136. package/dist/engine/chat.test.d.ts +1 -0
  137. package/dist/engine/chat.test.js +42 -0
  138. package/dist/engine/chat.test.js.map +1 -0
  139. package/dist/engine/classifier.d.ts +35 -2
  140. package/dist/engine/classifier.js +170 -31
  141. package/dist/engine/classifier.js.map +1 -1
  142. package/dist/engine/classifier.test.d.ts +1 -0
  143. package/dist/engine/classifier.test.js +115 -0
  144. package/dist/engine/classifier.test.js.map +1 -0
  145. package/dist/engine/dispatcher.d.ts +60 -0
  146. package/dist/engine/dispatcher.js +601 -0
  147. package/dist/engine/dispatcher.js.map +1 -0
  148. package/dist/engine/dispatcher.test.d.ts +1 -0
  149. package/dist/engine/dispatcher.test.js +511 -0
  150. package/dist/engine/dispatcher.test.js.map +1 -0
  151. package/dist/engine/event-shim.d.ts +117 -0
  152. package/dist/engine/event-shim.js +435 -0
  153. package/dist/engine/event-shim.js.map +1 -0
  154. package/dist/engine/event-shim.test.d.ts +1 -0
  155. package/dist/engine/event-shim.test.js +194 -0
  156. package/dist/engine/event-shim.test.js.map +1 -0
  157. package/dist/engine/git-auth.d.ts +13 -17
  158. package/dist/engine/git-auth.js +99 -42
  159. package/dist/engine/git-auth.js.map +1 -1
  160. package/dist/engine/git-auth.test.d.ts +1 -0
  161. package/dist/engine/git-auth.test.js +117 -0
  162. package/dist/engine/git-auth.test.js.map +1 -0
  163. package/dist/engine/github-app-client.d.ts +7 -0
  164. package/dist/engine/github-app-client.js +16 -0
  165. package/dist/engine/github-app-client.js.map +1 -0
  166. package/dist/engine/github-app-client.test.d.ts +1 -0
  167. package/dist/engine/github-app-client.test.js +44 -0
  168. package/dist/engine/github-app-client.test.js.map +1 -0
  169. package/dist/engine/github-tools.d.ts +12 -0
  170. package/dist/engine/github-tools.js +261 -0
  171. package/dist/engine/github-tools.js.map +1 -0
  172. package/dist/engine/github.d.ts +1027 -43
  173. package/dist/engine/github.js +153 -14
  174. package/dist/engine/github.js.map +1 -1
  175. package/dist/engine/llm.d.ts +47 -0
  176. package/dist/engine/llm.js +221 -0
  177. package/dist/engine/llm.js.map +1 -0
  178. package/dist/engine/llm.test.d.ts +1 -0
  179. package/dist/engine/llm.test.js +189 -0
  180. package/dist/engine/llm.test.js.map +1 -0
  181. package/dist/engine/profiles.d.ts +249 -0
  182. package/dist/engine/profiles.js +42 -0
  183. package/dist/engine/profiles.js.map +1 -0
  184. package/dist/engine/router.d.ts +12 -6
  185. package/dist/engine/router.js +339 -81
  186. package/dist/engine/router.js.map +1 -1
  187. package/dist/engine/router.test.js +428 -78
  188. package/dist/engine/router.test.js.map +1 -1
  189. package/dist/engine/screen.d.ts +41 -0
  190. package/dist/engine/screen.js +93 -0
  191. package/dist/engine/screen.js.map +1 -0
  192. package/dist/engine/screen.test.d.ts +1 -0
  193. package/dist/engine/screen.test.js +82 -0
  194. package/dist/engine/screen.test.js.map +1 -0
  195. package/dist/index.js +423 -437
  196. package/dist/index.js.map +1 -1
  197. package/dist/managed-repos.d.ts +7 -9
  198. package/dist/managed-repos.js +12 -15
  199. package/dist/managed-repos.js.map +1 -1
  200. package/dist/managed-repos.test.js +24 -19
  201. package/dist/managed-repos.test.js.map +1 -1
  202. package/dist/notify/index.d.ts +15 -0
  203. package/dist/notify/index.js +6 -0
  204. package/dist/notify/index.js.map +1 -0
  205. package/dist/notify/model.d.ts +57 -0
  206. package/dist/notify/model.js +89 -0
  207. package/dist/notify/model.js.map +1 -0
  208. package/dist/notify/model.test.d.ts +1 -0
  209. package/dist/notify/model.test.js +109 -0
  210. package/dist/notify/model.test.js.map +1 -0
  211. package/dist/notify/notifier.d.ts +17 -0
  212. package/dist/notify/notifier.js +87 -0
  213. package/dist/notify/notifier.js.map +1 -0
  214. package/dist/notify/notifier.test.d.ts +1 -0
  215. package/dist/notify/notifier.test.js +100 -0
  216. package/dist/notify/notifier.test.js.map +1 -0
  217. package/dist/notify/render.d.ts +21 -0
  218. package/dist/notify/render.js +59 -0
  219. package/dist/notify/render.js.map +1 -0
  220. package/dist/notify/render.test.d.ts +1 -0
  221. package/dist/notify/render.test.js +65 -0
  222. package/dist/notify/render.test.js.map +1 -0
  223. package/dist/notify/transports/github.d.ts +26 -0
  224. package/dist/notify/transports/github.js +26 -0
  225. package/dist/notify/transports/github.js.map +1 -0
  226. package/dist/notify/transports/slack.d.ts +26 -0
  227. package/dist/notify/transports/slack.js +28 -0
  228. package/dist/notify/transports/slack.js.map +1 -0
  229. package/dist/notify/transports.test.d.ts +1 -0
  230. package/dist/notify/transports.test.js +71 -0
  231. package/dist/notify/transports.test.js.map +1 -0
  232. package/dist/notify/types.d.ts +95 -0
  233. package/dist/notify/types.js +16 -0
  234. package/dist/notify/types.js.map +1 -0
  235. package/dist/sandbox/docker-compose.test.d.ts +1 -0
  236. package/dist/sandbox/docker-compose.test.js +128 -0
  237. package/dist/sandbox/docker-compose.test.js.map +1 -0
  238. package/dist/sandbox/docker.d.ts +97 -9
  239. package/dist/sandbox/docker.js +192 -41
  240. package/dist/sandbox/docker.js.map +1 -1
  241. package/dist/sandbox/docker.test.d.ts +1 -0
  242. package/dist/sandbox/docker.test.js +137 -0
  243. package/dist/sandbox/docker.test.js.map +1 -0
  244. package/dist/sandbox/egress-allowlist.d.ts +69 -0
  245. package/dist/sandbox/egress-allowlist.js +164 -0
  246. package/dist/sandbox/egress-allowlist.js.map +1 -0
  247. package/dist/sandbox/egress-allowlist.test.d.ts +1 -0
  248. package/dist/sandbox/egress-allowlist.test.js +65 -0
  249. package/dist/sandbox/egress-allowlist.test.js.map +1 -0
  250. package/dist/sandbox/egress-firewall-config.d.ts +127 -0
  251. package/dist/sandbox/egress-firewall-config.js +434 -0
  252. package/dist/sandbox/egress-firewall-config.js.map +1 -0
  253. package/dist/sandbox/egress-firewall-config.test.d.ts +1 -0
  254. package/dist/sandbox/egress-firewall-config.test.js +244 -0
  255. package/dist/sandbox/egress-firewall-config.test.js.map +1 -0
  256. package/dist/sandbox/images.d.ts +23 -0
  257. package/dist/sandbox/images.js +55 -0
  258. package/dist/sandbox/images.js.map +1 -0
  259. package/dist/sandbox/index.d.ts +82 -3
  260. package/dist/sandbox/index.js +223 -93
  261. package/dist/sandbox/index.js.map +1 -1
  262. package/dist/sandbox/index.test.d.ts +1 -0
  263. package/dist/sandbox/index.test.js +157 -0
  264. package/dist/sandbox/index.test.js.map +1 -0
  265. package/dist/session-log.d.ts +63 -0
  266. package/dist/session-log.js +290 -0
  267. package/dist/session-log.js.map +1 -0
  268. package/dist/session-log.test.d.ts +1 -0
  269. package/dist/session-log.test.js +85 -0
  270. package/dist/session-log.test.js.map +1 -0
  271. package/dist/setup.d.ts +31 -12
  272. package/dist/setup.js +516 -174
  273. package/dist/setup.js.map +1 -1
  274. package/dist/setup.test.js +138 -6
  275. package/dist/setup.test.js.map +1 -1
  276. package/dist/state/approval-store.d.ts +86 -0
  277. package/dist/state/approval-store.js +140 -0
  278. package/dist/state/approval-store.js.map +1 -0
  279. package/dist/state/build-assets.d.ts +50 -0
  280. package/dist/state/build-assets.js +154 -0
  281. package/dist/state/build-assets.js.map +1 -0
  282. package/dist/state/build-assets.test.d.ts +1 -0
  283. package/dist/state/build-assets.test.js +106 -0
  284. package/dist/state/build-assets.test.js.map +1 -0
  285. package/dist/state/db.d.ts +65 -333
  286. package/dist/state/db.js +112 -835
  287. package/dist/state/db.js.map +1 -1
  288. package/dist/state/db.test.js +127 -91
  289. package/dist/state/db.test.js.map +1 -1
  290. package/dist/state/execution-store.d.ts +257 -0
  291. package/dist/state/execution-store.js +527 -0
  292. package/dist/state/execution-store.js.map +1 -0
  293. package/dist/state/migrate.d.ts +15 -0
  294. package/dist/state/migrate.js +175 -0
  295. package/dist/state/migrate.js.map +1 -0
  296. package/dist/state/workflow-run-store.d.ts +195 -0
  297. package/dist/state/workflow-run-store.js +363 -0
  298. package/dist/state/workflow-run-store.js.map +1 -0
  299. package/dist/state/workflow-run-store.test.d.ts +1 -0
  300. package/dist/state/workflow-run-store.test.js +264 -0
  301. package/dist/state/workflow-run-store.test.js.map +1 -0
  302. package/dist/telemetry/index.d.ts +38 -0
  303. package/dist/telemetry/index.js +253 -0
  304. package/dist/telemetry/index.js.map +1 -0
  305. package/dist/telemetry/index.test.d.ts +1 -0
  306. package/dist/telemetry/index.test.js +73 -0
  307. package/dist/telemetry/index.test.js.map +1 -0
  308. package/dist/telemetry/pi-events.d.ts +12 -0
  309. package/dist/telemetry/pi-events.js +134 -0
  310. package/dist/telemetry/pi-events.js.map +1 -0
  311. package/dist/telemetry/pi-events.test.d.ts +1 -0
  312. package/dist/telemetry/pi-events.test.js +69 -0
  313. package/dist/telemetry/pi-events.test.js.map +1 -0
  314. package/dist/workflows/dag.d.ts +13 -1
  315. package/dist/workflows/dag.js +7 -3
  316. package/dist/workflows/dag.js.map +1 -1
  317. package/dist/workflows/dag.test.js +22 -0
  318. package/dist/workflows/dag.test.js.map +1 -1
  319. package/dist/workflows/golden-build.test.d.ts +1 -0
  320. package/dist/workflows/golden-build.test.js +45 -0
  321. package/dist/workflows/golden-build.test.js.map +1 -0
  322. package/dist/workflows/loader-overlay.test.d.ts +1 -0
  323. package/dist/workflows/loader-overlay.test.js +101 -0
  324. package/dist/workflows/loader-overlay.test.js.map +1 -0
  325. package/dist/workflows/loader.d.ts +36 -11
  326. package/dist/workflows/loader.js +311 -87
  327. package/dist/workflows/loader.js.map +1 -1
  328. package/dist/workflows/loader.test.js +114 -10
  329. package/dist/workflows/loader.test.js.map +1 -1
  330. package/dist/workflows/loop-eval.js +2 -0
  331. package/dist/workflows/loop-eval.js.map +1 -1
  332. package/dist/workflows/loop-eval.test.js +14 -0
  333. package/dist/workflows/loop-eval.test.js.map +1 -1
  334. package/dist/workflows/phase-executor.d.ts +145 -0
  335. package/dist/workflows/phase-executor.js +691 -0
  336. package/dist/workflows/phase-executor.js.map +1 -0
  337. package/dist/workflows/phase-executor.test.d.ts +1 -0
  338. package/dist/workflows/phase-executor.test.js +434 -0
  339. package/dist/workflows/phase-executor.test.js.map +1 -0
  340. package/dist/workflows/phase-ref.d.ts +44 -0
  341. package/dist/workflows/phase-ref.js +78 -0
  342. package/dist/workflows/phase-ref.js.map +1 -0
  343. package/dist/workflows/phase-ref.test.d.ts +1 -0
  344. package/dist/workflows/phase-ref.test.js +24 -0
  345. package/dist/workflows/phase-ref.test.js.map +1 -0
  346. package/dist/workflows/resume.d.ts +5 -11
  347. package/dist/workflows/resume.js +88 -7
  348. package/dist/workflows/resume.js.map +1 -1
  349. package/dist/workflows/runner.d.ts +34 -27
  350. package/dist/workflows/runner.js +252 -930
  351. package/dist/workflows/runner.js.map +1 -1
  352. package/dist/workflows/runner.test.js +346 -35
  353. package/dist/workflows/runner.test.js.map +1 -1
  354. package/dist/workflows/schema.d.ts +52 -10
  355. package/dist/workflows/schema.js +147 -7
  356. package/dist/workflows/schema.js.map +1 -1
  357. package/dist/workflows/simple.d.ts +67 -3
  358. package/dist/workflows/simple.js +248 -87
  359. package/dist/workflows/simple.js.map +1 -1
  360. package/dist/workflows/simple.test.d.ts +1 -0
  361. package/dist/workflows/simple.test.js +107 -0
  362. package/dist/workflows/simple.test.js.map +1 -0
  363. package/dist/workflows/templates.d.ts +29 -0
  364. package/dist/workflows/templates.js +42 -4
  365. package/dist/workflows/templates.js.map +1 -1
  366. package/dist/workflows/templates.test.js +103 -0
  367. package/dist/workflows/templates.test.js.map +1 -1
  368. package/dist/workflows/triggers.d.ts +21 -0
  369. package/dist/workflows/triggers.js +44 -0
  370. package/dist/workflows/triggers.js.map +1 -0
  371. package/dist/workflows/verdict.d.ts +19 -0
  372. package/dist/workflows/verdict.js +30 -0
  373. package/dist/workflows/verdict.js.map +1 -0
  374. package/dist/workflows/verdict.test.d.ts +1 -0
  375. package/dist/workflows/verdict.test.js +44 -0
  376. package/dist/workflows/verdict.test.js.map +1 -0
  377. package/dist/worktree/manager.d.ts +1 -1
  378. package/dist/worktree/manager.js +14 -14
  379. package/dist/worktree/manager.js.map +1 -1
  380. package/dist/worktree/manager.test.d.ts +1 -0
  381. package/dist/worktree/manager.test.js +87 -0
  382. package/dist/worktree/manager.test.js.map +1 -0
  383. package/docker-compose.yml +206 -5
  384. package/package.json +19 -7
  385. package/sandbox.Dockerfile +97 -18
  386. package/skills/README.md +40 -0
  387. package/skills/browser-qa/SKILL.md +193 -0
  388. package/skills/browser-qa/scripts/agent-browser.mjs +380 -0
  389. package/skills/building/SKILL.md +95 -0
  390. package/skills/chat/SKILL.md +30 -11
  391. package/skills/code-review/SKILL.md +59 -0
  392. package/skills/debug-production/SKILL.md +108 -0
  393. package/skills/demo/SKILL.md +194 -0
  394. package/skills/demo/scripts/compose-demo.sh +205 -0
  395. package/skills/issue-answer/SKILL.md +90 -0
  396. package/skills/issue-comment/SKILL.md +38 -24
  397. package/skills/issue-triage/SKILL.md +103 -37
  398. package/skills/issue-triage/references/AGENT-BRIEF.md +61 -0
  399. package/skills/pr-comment/SKILL.md +64 -0
  400. package/skills/pr-review/SKILL.md +65 -49
  401. package/skills/qa-test/SKILL.md +97 -0
  402. package/skills/repo-health/SKILL.md +48 -45
  403. package/skills/security-feedback/SKILL.md +173 -0
  404. package/skills/security-feedback/references/templates.md +77 -0
  405. package/skills/security-review/SKILL.md +213 -0
  406. package/skills/security-review/references/issue-format.md +302 -0
  407. package/skills/verify/SKILL.md +118 -0
  408. package/workflows/answer.yaml +37 -0
  409. package/workflows/build.yaml +32 -22
  410. package/workflows/cron-security.yaml +6 -0
  411. package/workflows/demo.yaml +55 -0
  412. package/workflows/explore.yaml +9 -0
  413. package/workflows/pr-comment.yaml +16 -0
  414. package/workflows/pr-fix.yaml +3 -0
  415. package/workflows/pr-review.yaml +4 -1
  416. package/workflows/prompts/answer.md +67 -0
  417. package/workflows/prompts/architect.md +24 -7
  418. package/workflows/prompts/demo.md +100 -0
  419. package/workflows/prompts/executor.md +29 -20
  420. package/workflows/prompts/explore-ask.md +51 -22
  421. package/workflows/prompts/explore-publish.md +2 -2
  422. package/workflows/prompts/explore-read.md +24 -10
  423. package/workflows/prompts/explore-synthesize.md +15 -8
  424. package/workflows/prompts/fix.md +9 -10
  425. package/workflows/prompts/guardrails.md +26 -8
  426. package/workflows/prompts/pr-fix.md +6 -7
  427. package/workflows/prompts/pr.md +11 -10
  428. package/workflows/prompts/qa-browser.md +95 -0
  429. package/workflows/prompts/qa-synth.md +44 -0
  430. package/workflows/prompts/qa-test.md +60 -0
  431. package/workflows/prompts/re-reviewer.md +8 -9
  432. package/workflows/prompts/reviewer.md +10 -9
  433. package/workflows/prompts/verify-browser.md +93 -0
  434. package/workflows/prompts/verify-synth.md +44 -0
  435. package/workflows/prompts/verify.md +63 -0
  436. package/workflows/qa-test.yaml +87 -0
  437. package/workflows/security-feedback.yaml +25 -0
  438. package/workflows/security-review.yaml +29 -0
  439. package/workflows/verify.yaml +85 -0
  440. package/deploy/mcp-config.tmpl.json +0 -14
  441. package/dist/cron/rate-limits.d.ts +0 -26
  442. package/dist/cron/rate-limits.js +0 -193
  443. package/dist/cron/rate-limits.js.map +0 -1
  444. package/dist/engine/executor.d.ts +0 -75
  445. package/dist/engine/executor.js +0 -292
  446. package/dist/engine/executor.js.map +0 -1
  447. package/mcp-github-app/package.json +0 -18
  448. package/mcp-github-app/src/auth.js +0 -66
  449. package/mcp-github-app/src/github.js +0 -371
  450. package/mcp-github-app/src/index.js +0 -534
  451. package/skills/devops/webhook-subscriptions/SKILL.md +0 -180
  452. package/skills/github/DESCRIPTION.md +0 -3
  453. package/skills/github/codebase-inspection/SKILL.md +0 -115
  454. package/skills/github/github-auth/SKILL.md +0 -54
  455. package/skills/github/github-auth/scripts/gh-env.sh +0 -66
  456. package/skills/github/github-code-review/SKILL.md +0 -480
  457. package/skills/github/github-code-review/references/review-output-template.md +0 -74
  458. package/skills/github/github-issues/SKILL.md +0 -369
  459. package/skills/github/github-issues/templates/bug-report.md +0 -35
  460. package/skills/github/github-issues/templates/feature-request.md +0 -31
  461. package/skills/github/github-pr-workflow/SKILL.md +0 -395
  462. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +0 -183
  463. package/skills/github/github-pr-workflow/references/conventional-commits.md +0 -71
  464. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +0 -35
  465. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +0 -33
  466. package/skills/github/github-repo-management/SKILL.md +0 -515
  467. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +0 -161
  468. package/skills/github-orchestrator/SKILL.md +0 -103
  469. package/skills/mcp/DESCRIPTION.md +0 -3
  470. package/skills/mcp/mcporter/SKILL.md +0 -122
  471. package/skills/mcp/native-mcp/SKILL.md +0 -356
  472. package/skills/software-development/architect/SKILL.md +0 -154
  473. package/skills/software-development/assure-guardrails/SKILL.md +0 -239
  474. package/skills/software-development/plan/SKILL.md +0 -64
  475. package/skills/software-development/requesting-code-review/SKILL.md +0 -291
  476. package/skills/software-development/subagent-driven-development/SKILL.md +0 -433
  477. package/skills/software-development/systematic-debugging/SKILL.md +0 -366
  478. package/skills/software-development/test-driven-development/SKILL.md +0 -342
@@ -2,24 +2,137 @@ import { Hono } from "hono";
2
2
  import { timingSafeEqual, randomBytes } from "node:crypto";
3
3
  import { streamSSE } from "hono/streaming";
4
4
  import { getCookie, setCookie, deleteCookie } from "hono/cookie";
5
- import { Slack } from "arctic";
6
- import { unwrapLine } from "./sessions.js";
5
+ import { Slack, GitHub } from "arctic";
7
6
  import { tailJsonl } from "./tail.js";
8
- import { listRunningContainers, killContainer, getContainerStats } from "./docker.js";
7
+ import { listRunningContainers, killContainer, getContainerStats, listServerContainers, resolveServerContainer, getContainerLogs, streamContainerLogs, } from "./docker.js";
9
8
  import { authMiddleware, createToken } from "./auth.js";
9
+ import { Cron } from "croner";
10
+ import { getCronWorkflows, getWorkflow, listAgentWorkflows, loadWorkflowYamlRaw, loadPromptTemplate, loadSkillRaw, } from "../workflows/loader.js";
11
+ import { getWorkflowTriggers, getWorkflowTriggerKinds, } from "../workflows/triggers.js";
12
+ import { getManagedRepos } from "../managed-repos.js";
13
+ import { getServerVersion } from "./version.js";
14
+ import { BuildAssetStore, buildAssetIssueKey } from "../state/build-assets.js";
15
+ /**
16
+ * Map a build-asset filename extension to a binary MIME type, or null when the
17
+ * file should be served as text/plain (markdown handoff docs). Binary artifacts
18
+ * — PNG screenshot evidence from browser QA, and `/demo`'s mp4/webm video — must
19
+ * be served as raw bytes, not utf-8 text, so the dashboard can render them in an
20
+ * <img>/<video> and GitHub can embed them.
21
+ */
22
+ export function binaryMimeForArtifact(name) {
23
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
24
+ switch (ext) {
25
+ case ".png":
26
+ return "image/png";
27
+ case ".jpg":
28
+ case ".jpeg":
29
+ return "image/jpeg";
30
+ case ".gif":
31
+ return "image/gif";
32
+ case ".webp":
33
+ return "image/webp";
34
+ case ".svg":
35
+ return "image/svg+xml";
36
+ case ".mp4":
37
+ return "video/mp4";
38
+ case ".webm":
39
+ return "video/webm";
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Build an HTTP response for a binary artifact buffer, honoring a `Range`
46
+ * request so <video> elements can seek/stream (GitHub's inline player and
47
+ * browsers send `Range: bytes=…`; without 206 support, seeking — and some
48
+ * players — break). Artifacts are small (≤ a few MB), so we slice the in-memory
49
+ * buffer rather than streaming from disk. Returns 206 for a satisfiable range,
50
+ * 416 for an unsatisfiable one, else 200 with the full body. `Accept-Ranges:
51
+ * bytes` is always advertised.
52
+ */
53
+ export function rangeResponse(rangeHeader, buf, mime, cacheControl) {
54
+ const total = buf.length;
55
+ const baseHeaders = {
56
+ "Content-Type": mime,
57
+ "Accept-Ranges": "bytes",
58
+ "Cache-Control": cacheControl,
59
+ };
60
+ const m = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) : null;
61
+ if (m) {
62
+ let start = m[1] ? parseInt(m[1], 10) : 0;
63
+ let end = m[2] ? parseInt(m[2], 10) : total - 1;
64
+ if (Number.isNaN(start))
65
+ start = 0;
66
+ if (Number.isNaN(end) || end >= total)
67
+ end = total - 1;
68
+ if (start > end || start >= total) {
69
+ return new Response(null, {
70
+ status: 416,
71
+ headers: { ...baseHeaders, "Content-Range": `bytes */${total}` },
72
+ });
73
+ }
74
+ const chunk = buf.subarray(start, end + 1);
75
+ return new Response(new Uint8Array(chunk), {
76
+ status: 206,
77
+ headers: {
78
+ ...baseHeaders,
79
+ "Content-Range": `bytes ${start}-${end}/${total}`,
80
+ "Content-Length": String(chunk.length),
81
+ },
82
+ });
83
+ }
84
+ return new Response(new Uint8Array(buf), {
85
+ status: 200,
86
+ headers: { ...baseHeaders, "Content-Length": String(total) },
87
+ });
88
+ }
89
+ /**
90
+ * Parse a JSON status column (`extension_status` / `skills_status`) into the
91
+ * object the dashboard renders. Tolerates null / malformed JSON (returns
92
+ * undefined) so a bad row never breaks the executions endpoint.
93
+ */
94
+ function parseJsonColumn(raw) {
95
+ if (!raw)
96
+ return undefined;
97
+ try {
98
+ return JSON.parse(raw);
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
10
104
  /**
11
105
  * Check if a session is live by matching against running container taskIds.
12
106
  * Sessions are live if they were recently active (within 5 min) and a container
13
107
  * with a matching pattern is running.
14
108
  */
109
+ /**
110
+ * A session is worth listing only if it actually produced something. A
111
+ * zero-message session is a run that died before writing any conversation
112
+ * (e.g. an aborted/duplicate task that left an empty jsonl behind) — it has
113
+ * nothing to render and previously surfaced as a phantom "live" row at the
114
+ * top of the list. Error-only runs still write an assistant error line, so
115
+ * they keep a non-zero count and remain visible.
116
+ */
117
+ function hasContent(meta) {
118
+ return meta.message_count > 0;
119
+ }
15
120
  function isSessionLive(meta, liveTaskIds) {
16
- // A session is considered live if it has recent activity and no end marker
121
+ // Stale sessions are never live, regardless of containers.
17
122
  const lastActivity = meta.last_message_at ?? meta.started_at;
18
123
  const fiveMinAgo = Date.now() / 1000 - 300;
19
124
  if (lastActivity < fiveMinAgo)
20
125
  return false;
21
- // Check if any running container's taskId appears related to this session
22
- // Sessions don't directly map to taskIds, but recent + active = likely live
126
+ // Fallback-named sessions encode their taskId as `exec-<taskId>`, and that
127
+ // taskId is exactly what listRunningContainers parses from the sandbox
128
+ // container name — so we can match them precisely instead of guessing.
129
+ if (meta.id.startsWith("exec-")) {
130
+ return liveTaskIds.has(meta.id.slice("exec-".length));
131
+ }
132
+ // UUID-named sessions don't carry their taskId in the meta, so we can't map
133
+ // them to a specific container. Recent activity + at least one sandbox
134
+ // running (the caller gates on liveTaskIds being non-empty) is the best
135
+ // signal available — keeps live agent logs flowing for in-flight phases.
23
136
  return true;
24
137
  }
25
138
  /**
@@ -40,6 +153,7 @@ function mountSessionRoutes(app, sessions, prefix) {
40
153
  const liveTaskIds = new Set(containers.map((c) => c.taskId).filter(Boolean));
41
154
  const valid = metas
42
155
  .filter((m) => m !== null)
156
+ .filter(hasContent)
43
157
  .sort((a, b) => b.started_at - a.started_at)
44
158
  .slice(0, limit)
45
159
  .map((m) => ({ ...m, live: liveTaskIds.size > 0 && isSessionLive(m, liveTaskIds) }));
@@ -61,6 +175,7 @@ function mountSessionRoutes(app, sessions, prefix) {
61
175
  const metas = await Promise.all(allIds.slice(0, limit * 2).map((id) => sessions.getSessionMeta(id)));
62
176
  const valid = metas
63
177
  .filter((m) => m !== null)
178
+ .filter(hasContent)
64
179
  .sort((a, b) => b.started_at - a.started_at)
65
180
  .slice(0, limit)
66
181
  .map((m) => ({ ...m, live: liveTaskIds.size > 0 && isSessionLive(m, liveTaskIds) }));
@@ -125,7 +240,7 @@ function mountSessionRoutes(app, sessions, prefix) {
125
240
  let msgIndex = sinceIndex;
126
241
  const tailer = await tailJsonl(filePath, (lines) => {
127
242
  for (const { msg } of lines) {
128
- const unwrapped = unwrapLine(msg);
243
+ const unwrapped = sessions.normalizeRawLine(msg);
129
244
  for (const m of unwrapped) {
130
245
  msgIndex++;
131
246
  stream.writeSSE({ event: "message", data: JSON.stringify({ id: msgIndex, ...m }) });
@@ -152,11 +267,20 @@ function mountSessionRoutes(app, sessions, prefix) {
152
267
  export function createAdminRoutes(db, sessions, chatSessions, config) {
153
268
  const app = new Hono();
154
269
  const slackOAuthEnabled = Boolean(config.slackOAuthClientId && config.slackOAuthClientSecret);
270
+ const githubCredsSet = Boolean(config.githubOAuthClientId && config.githubOAuthClientSecret);
271
+ const githubOAuthEnabled = githubCredsSet && Boolean(config.githubAllowedOrg);
272
+ if (githubCredsSet && !config.githubAllowedOrg) {
273
+ console.error("[oauth] GitHub OAuth client id/secret are set but GITHUB_ALLOWED_ORG is empty. " +
274
+ "Set it to a GitHub org slug to restrict login to that org, or to \"*\" to " +
275
+ "explicitly allow any GitHub user. GitHub OAuth is disabled until this is set.");
276
+ }
277
+ const githubAllowAnyUser = config.githubAllowedOrg === "*";
155
278
  // Auth middleware
156
279
  app.use("/*", authMiddleware(config.adminPassword, config.adminSecret));
280
+ app.get("/config", (c) => c.json(config.publicConfig || { default: {}, overlay: null, merged: {}, sources: {} }));
157
281
  // Auth endpoints
158
282
  app.get("/auth-required", (c) => {
159
- return c.json({ required: Boolean(config.adminPassword), slackOAuth: slackOAuthEnabled });
283
+ return c.json({ required: Boolean(config.adminPassword), slackOAuth: slackOAuthEnabled, githubOAuth: githubOAuthEnabled });
160
284
  });
161
285
  app.post("/login", async (c) => {
162
286
  if (!config.adminPassword) {
@@ -205,16 +329,16 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
205
329
  }
206
330
  try {
207
331
  const slack = new Slack(config.slackOAuthClientId, config.slackOAuthClientSecret, config.slackOAuthRedirectUri ?? "");
208
- const tokens = await slack.validateAuthorizationCode(code);
209
- const accessToken = tokens.accessToken();
210
332
  // "Sign in with Slack" issues OIDC-scoped tokens (openid + profile),
211
333
  // which Slack's classic auth.test endpoint rejects with invalid_auth.
212
334
  // Use the OIDC userInfo endpoint instead — it returns a JWT-style
213
335
  // payload with claims under namespaced URLs.
214
- const userInfoRes = await fetch("https://slack.com/api/openid.connect.userInfo", {
336
+ const tokens = await slack.validateAuthorizationCode(code);
337
+ const accessToken = tokens.accessToken();
338
+ const res = await fetch("https://slack.com/api/openid.connect.userInfo", {
215
339
  headers: { Authorization: `Bearer ${accessToken}` },
216
340
  });
217
- const userInfo = (await userInfoRes.json());
341
+ const userInfo = (await res.json());
218
342
  if (userInfo.ok === false) {
219
343
  console.error("Slack openid.connect.userInfo failed:", userInfo.error);
220
344
  return c.json({ error: "Slack userInfo failed" }, 502);
@@ -242,6 +366,87 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
242
366
  return c.json({ error: "OAuth exchange failed" }, 502);
243
367
  }
244
368
  });
369
+ // GitHub OAuth routes (only active when GitHub OAuth env vars are configured)
370
+ app.get("/oauth/github/authorize", (c) => {
371
+ if (!githubOAuthEnabled) {
372
+ return c.json({ error: "GitHub OAuth not configured" }, 404);
373
+ }
374
+ const github = new GitHub(config.githubOAuthClientId, config.githubOAuthClientSecret, config.githubOAuthRedirectUri ?? "");
375
+ const state = randomBytes(16).toString("hex");
376
+ setCookie(c, "github_oauth_state", state, {
377
+ httpOnly: true,
378
+ sameSite: "Lax",
379
+ path: "/",
380
+ maxAge: 600, // 10 minutes
381
+ });
382
+ // `login` on GET /user needs no scope; read:org is only needed for the
383
+ // org-membership check, so skip it when the allowlist is "*".
384
+ const scopes = githubAllowAnyUser ? [] : ["read:org"];
385
+ const url = github.createAuthorizationURL(state, scopes);
386
+ return c.redirect(url.toString());
387
+ });
388
+ app.get("/oauth/github/callback", async (c) => {
389
+ if (!githubOAuthEnabled) {
390
+ return c.json({ error: "GitHub OAuth not configured" }, 404);
391
+ }
392
+ // Redirect the user back to the dashboard login screen with a short,
393
+ // URL-safe error code. The SPA maps this code to a human-readable
394
+ // message so the user sees the login card with an inline error instead
395
+ // of a raw JSON body.
396
+ const fail = (code) => c.redirect(`/admin/?error=${encodeURIComponent(code)}`);
397
+ const storedState = getCookie(c, "github_oauth_state");
398
+ deleteCookie(c, "github_oauth_state", { path: "/" });
399
+ const { code, state } = c.req.query();
400
+ if (!storedState || !state || storedState !== state) {
401
+ return fail("oauth_state");
402
+ }
403
+ if (!code) {
404
+ return fail("oauth_code");
405
+ }
406
+ try {
407
+ const github = new GitHub(config.githubOAuthClientId, config.githubOAuthClientSecret, config.githubOAuthRedirectUri ?? "");
408
+ const tokens = await github.validateAuthorizationCode(code);
409
+ const accessToken = tokens.accessToken();
410
+ const res = await fetch("https://api.github.com/user", {
411
+ headers: {
412
+ Authorization: `Bearer ${accessToken}`,
413
+ "User-Agent": "lastlight-admin",
414
+ Accept: "application/vnd.github+json",
415
+ },
416
+ });
417
+ const userInfo = (await res.json());
418
+ let memberStatus;
419
+ if (userInfo.login && !githubAllowAnyUser) {
420
+ const org = config.githubAllowedOrg;
421
+ const memberRes = await fetch(`https://api.github.com/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(userInfo.login)}`, {
422
+ headers: {
423
+ Authorization: `Bearer ${accessToken}`,
424
+ "User-Agent": "lastlight-admin",
425
+ Accept: "application/vnd.github+json",
426
+ },
427
+ redirect: "manual",
428
+ });
429
+ memberStatus = memberRes.status;
430
+ }
431
+ if (!userInfo.login) {
432
+ console.error("GitHub /user failed: missing login field");
433
+ return fail("github_userinfo");
434
+ }
435
+ const login = userInfo.login;
436
+ // Only 204 No Content means confirmed member. 302 means caller lacks
437
+ // read:org visibility; 404 means not a member. Both cases are rejected.
438
+ if (!githubAllowAnyUser && memberStatus !== 204) {
439
+ console.warn(`[oauth] GitHub login rejected: ${login} not a confirmed member of ${config.githubAllowedOrg} (status ${memberStatus})`);
440
+ return fail("github_org");
441
+ }
442
+ const token = createToken(config.adminSecret, "github");
443
+ return c.redirect(`/admin/?token=${encodeURIComponent(token)}`);
444
+ }
445
+ catch (err) {
446
+ console.error("GitHub OAuth exchange failed:", err);
447
+ return fail("oauth_exchange");
448
+ }
449
+ });
245
450
  // Health
246
451
  app.get("/health", (c) => {
247
452
  return c.json({ status: "ok", stateDir: config.stateDir });
@@ -254,7 +459,7 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
254
459
  // Stats — running count uses live Docker containers, not stale DB records
255
460
  app.get("/stats", async (c) => {
256
461
  const [stats, containers] = await Promise.all([
257
- Promise.resolve(db.executionStats()),
462
+ Promise.resolve(db.executions.executionStats()),
258
463
  listRunningContainers(),
259
464
  ]);
260
465
  stats.running = containers.length;
@@ -264,36 +469,13 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
264
469
  app.get("/stats/daily", (c) => {
265
470
  const daysParam = c.req.query("days");
266
471
  const days = Math.min(Math.max(1, parseInt(daysParam ?? "30", 10) || 30), 90);
267
- return c.json({ daily: db.dailyStats(days) });
472
+ return c.json({ daily: db.executions.dailyStats(days) });
268
473
  });
269
474
  // Hourly aggregated stats (rolling last N hours, default 24)
270
475
  app.get("/stats/hourly", (c) => {
271
476
  const hoursParam = c.req.query("hours");
272
477
  const hours = Math.min(Math.max(1, parseInt(hoursParam ?? "24", 10) || 24), 168);
273
- return c.json({ hourly: db.hourlyStats(hours) });
274
- });
275
- // API rate limits
276
- app.get("/rate-limits", (c) => {
277
- return c.json({ limits: db.getRateLimits() });
278
- });
279
- // Component health states (e.g. host-claude-auth degraded after auth failure)
280
- app.get("/system-status", (c) => {
281
- return c.json({ statuses: db.listSystemStatus() });
282
- });
283
- // Manually trigger a recheck of the host claude CLI auth.
284
- // Force-runs the check even if the state is currently degraded; the
285
- // notifier only fires on actual state transitions, so calling this
286
- // endpoint repeatedly while degraded does NOT spam the admin.
287
- app.post("/system-status/host-claude-auth/recheck", async (c) => {
288
- try {
289
- const { checkApiUsage } = await import("../cron/rate-limits.js");
290
- await checkApiUsage(db, config.adminNotifier, { force: true });
291
- const status = db.getSystemStatus("host-claude-auth");
292
- return c.json({ status });
293
- }
294
- catch (err) {
295
- return c.json({ error: err.message }, 500);
296
- }
478
+ return c.json({ hourly: db.executions.hourlyStats(hours) });
297
479
  });
298
480
  // Running Docker containers
299
481
  app.get("/containers", async (c) => {
@@ -305,6 +487,61 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
305
487
  const stats = await getContainerStats();
306
488
  return c.json({ stats });
307
489
  });
490
+ // ── Server logs ───────────────────────────────────────────────────────────
491
+ // Raw `docker logs` for the lastlight-* containers (the agent harness + the
492
+ // egress sidecars + otel-collector). Lets an operator read the actual
493
+ // server/process logs over the admin API instead of SSHing to the host. The
494
+ // requested container is resolved against the live container list, so an
495
+ // arbitrary name can never reach `docker logs`.
496
+ app.get("/server/containers", async (c) => {
497
+ return c.json({ containers: await listServerContainers() });
498
+ });
499
+ // Version + drift (core/overlay) for the dashboard "update available" banner.
500
+ // Best-effort: an unreachable remote yields latest=null (behind=false), never
501
+ // a false positive. The authoritative view is `lastlight server status`.
502
+ app.get("/server/info", async (c) => {
503
+ try {
504
+ return c.json(await getServerVersion());
505
+ }
506
+ catch (err) {
507
+ return c.json({ error: `version lookup failed: ${err.message}` }, 500);
508
+ }
509
+ });
510
+ app.get("/server/logs", async (c) => {
511
+ const name = await resolveServerContainer(c.req.query("container"));
512
+ if (!name)
513
+ return c.json({ error: "no matching lastlight container" }, 404);
514
+ const tail = Math.min(Math.max(parseInt(c.req.query("tail") ?? "200", 10) || 200, 1), 5000);
515
+ const since = c.req.query("since") || undefined;
516
+ try {
517
+ const lines = await getContainerLogs(name, { tail, since });
518
+ return c.json({ container: name, lines });
519
+ }
520
+ catch (err) {
521
+ return c.json({ error: `docker logs failed: ${err.message}` }, 500);
522
+ }
523
+ });
524
+ app.get("/server/logs/stream", (c) => {
525
+ return streamSSE(c, async (stream) => {
526
+ const name = await resolveServerContainer(c.req.query("container"));
527
+ if (!name) {
528
+ await stream.writeSSE({ data: JSON.stringify({ error: "no matching lastlight container" }) });
529
+ return;
530
+ }
531
+ const tail = Math.min(Math.max(parseInt(c.req.query("tail") ?? "100", 10) || 100, 1), 5000);
532
+ let stopped = false;
533
+ const stop = streamContainerLogs(name, { tail }, (line) => {
534
+ if (!stopped)
535
+ void stream.writeSSE({ data: line });
536
+ });
537
+ stream.onAbort(() => { stopped = true; stop(); });
538
+ // Hold the SSE open until the client disconnects.
539
+ while (!stopped) {
540
+ await stream.sleep(15000);
541
+ }
542
+ stop();
543
+ });
544
+ });
308
545
  // Kill a sandbox container and mark related DB executions as failed
309
546
  app.delete("/containers/:name", async (c) => {
310
547
  const name = c.req.param("name");
@@ -319,11 +556,11 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
319
556
  const taskId = match[1];
320
557
  // Mark any running executions with matching skill as failed. Phase
321
558
  // skill keys are `<workflowName>:<phaseName>` — match on the colon.
322
- const skills = db.runningExecutions()
559
+ const skills = db.executions.runningExecutions()
323
560
  .filter((e) => e.skill.includes(":") || e.skill === "pr-fix")
324
561
  .filter((e) => taskId.includes(e.triggerId?.replace(/[^a-z0-9]/gi, "") || "---"));
325
562
  for (const e of skills) {
326
- db.recordFinish(e.id, { success: false, error: "terminated via admin dashboard" });
563
+ db.executions.recordFinish(e.id, { success: false, error: "terminated via admin dashboard" });
327
564
  }
328
565
  }
329
566
  return c.json({ killed: name });
@@ -336,9 +573,71 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
336
573
  app.get("/executions", (c) => {
337
574
  const limit = Number(c.req.query("limit") ?? 100);
338
575
  const offset = Number(c.req.query("offset") ?? 0);
339
- const executions = db.allExecutions(limit, offset);
576
+ const executions = db.executions.allExecutions(limit, offset);
340
577
  return c.json({ executions });
341
578
  });
579
+ // Free-text log search — the backbone of remote debugging via the CLI
580
+ // (`lastlight logs search`). Two scopes:
581
+ // - errors (default): substring match over the executions ledger
582
+ // (error / skill / repo) — fast, indexed-ish.
583
+ // - messages: grep the most-recent session transcripts for matching
584
+ // conversation content, returning a snippet. Bounded by
585
+ // `maxSessions` so a deep history can't make this unbounded.
586
+ // - all: both, errors first.
587
+ app.get("/log-search", async (c) => {
588
+ const q = (c.req.query("q") ?? "").trim();
589
+ if (!q)
590
+ return c.json({ error: "missing 'q' query parameter" }, 400);
591
+ const scope = (c.req.query("scope") ?? "errors");
592
+ const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 200);
593
+ const results = [];
594
+ if (scope === "errors" || scope === "all") {
595
+ for (const r of db.executions.searchErrors(q, limit)) {
596
+ results.push({
597
+ source: "error",
598
+ executionId: r.id,
599
+ sessionId: r.sessionId,
600
+ workflowRunId: r.workflowRunId,
601
+ skill: r.skill,
602
+ repo: r.repo,
603
+ startedAt: r.startedAt,
604
+ success: r.success,
605
+ snippet: r.error ?? r.skill,
606
+ });
607
+ }
608
+ }
609
+ if (scope === "messages" || scope === "all") {
610
+ const needle = q.toLowerCase();
611
+ const maxSessions = 200; // newest-first cap on transcripts scanned
612
+ const ids = sessions.listSessionIds().slice(0, maxSessions);
613
+ outer: for (const id of ids) {
614
+ let msgs;
615
+ try {
616
+ msgs = (await sessions.read(id));
617
+ }
618
+ catch {
619
+ continue;
620
+ }
621
+ for (const { index, msg } of msgs) {
622
+ const text = JSON.stringify(msg.content ?? "");
623
+ const at = text.toLowerCase().indexOf(needle);
624
+ if (at === -1)
625
+ continue;
626
+ const start = Math.max(0, at - 60);
627
+ results.push({
628
+ source: "message",
629
+ sessionId: id,
630
+ messageIndex: index,
631
+ role: msg.role,
632
+ snippet: text.slice(start, at + needle.length + 120),
633
+ });
634
+ if (results.length >= limit)
635
+ break outer;
636
+ }
637
+ }
638
+ }
639
+ return c.json({ results: results.slice(0, limit) });
640
+ });
342
641
  // Workflow runs — paginated, optional filters by date, workflow name, and
343
642
  // status. Returns `total` so the dashboard can drive a "load more" pager.
344
643
  // `status=active` is shorthand for ('running','paused') — used by the
@@ -358,7 +657,7 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
358
657
  else if (statusParam) {
359
658
  statuses = statusParam.split(",").filter(Boolean);
360
659
  }
361
- const { runs, total } = db.listWorkflowRuns({
660
+ const { runs, total } = db.runs.list({
362
661
  limit,
363
662
  offset,
364
663
  sinceIso: since,
@@ -369,11 +668,11 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
369
668
  });
370
669
  // Distinct workflow names — used to populate the dashboard's filter row.
371
670
  app.get("/workflow-names", (c) => {
372
- return c.json({ names: db.distinctWorkflowNames() });
671
+ return c.json({ names: db.runs.distinctNames() });
373
672
  });
374
673
  app.get("/workflow-runs/:id", (c) => {
375
674
  const id = c.req.param("id");
376
- const run = db.getWorkflowRun(id);
675
+ const run = db.runs.getRun(id);
377
676
  if (!run)
378
677
  return c.json({ error: "workflow run not found" }, 404);
379
678
  return c.json({ workflowRun: run });
@@ -383,10 +682,10 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
383
682
  // (and usage metrics) for any phase the user clicks.
384
683
  app.get("/workflow-runs/:id/executions", (c) => {
385
684
  const id = c.req.param("id");
386
- const run = db.getWorkflowRun(id);
685
+ const run = db.runs.getRun(id);
387
686
  if (!run)
388
687
  return c.json({ error: "workflow run not found" }, 404);
389
- const rows = db.getExecutionsForWorkflowRun(run.id, run.triggerId, run.workflowName);
688
+ const rows = db.executions.getExecutionsForWorkflowRun(run.id, run.triggerId, run.workflowName);
390
689
  const prefix = `${run.workflowName}:`;
391
690
  const executions = rows.map((r) => ({
392
691
  id: r.id,
@@ -407,32 +706,99 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
407
706
  outputTokens: r.outputTokens,
408
707
  apiDurationMs: r.apiDurationMs,
409
708
  stopReason: r.stopReason,
709
+ extensions: parseJsonColumn(r.extensionStatus),
710
+ skills: parseJsonColumn(r.skillsStatus),
410
711
  }));
411
712
  return c.json({ executions });
412
713
  });
413
- app.post("/workflow-runs/:id/cancel", (c) => {
714
+ // All approvals (pending + resolved) for a workflow run, oldest first. Powers
715
+ // the pipeline's approval-gate nodes + the detail panel's read-only approval
716
+ // history (status, who responded, when, and any comment). The global
717
+ // /approvals endpoint only lists pending ones, so it can't show history.
718
+ app.get("/workflow-runs/:id/approvals", (c) => {
719
+ const id = c.req.param("id");
720
+ const run = db.runs.getRun(id);
721
+ if (!run)
722
+ return c.json({ error: "workflow run not found" }, 404);
723
+ const approvals = db.approvals.listForWorkflow(run.id);
724
+ return c.json({ approvals });
725
+ });
726
+ app.post("/workflow-runs/:id/cancel", async (c) => {
414
727
  const id = c.req.param("id");
415
- const run = db.getWorkflowRun(id);
728
+ const run = db.runs.getRun(id);
416
729
  if (!run)
417
730
  return c.json({ error: "workflow run not found" }, 404);
418
731
  if (run.status !== "running" && run.status !== "paused") {
419
732
  return c.json({ error: `cannot cancel a run with status '${run.status}'` }, 400);
420
733
  }
421
- db.cancelWorkflowRun(id);
422
- return c.json({ cancelled: id });
734
+ db.runs.cancelRun(id);
735
+ // Flipping the DB row alone only stops the runner before the NEXT phase.
736
+ // Kill any sandbox container currently executing a phase of this run so
737
+ // the in-flight phase stops too. Container names are
738
+ // lastlight-sandbox-<taskId>-<uuid>
739
+ // where taskId is the linear run's taskId or the DAG's phase-scoped
740
+ // `<taskId>-<phaseName>`, both of which start with the stored taskId.
741
+ const storedTaskId = run.context?.taskId;
742
+ let killed = [];
743
+ if (typeof storedTaskId === "string" && storedTaskId) {
744
+ try {
745
+ const containers = await listRunningContainers();
746
+ const matches = containers.filter((ctr) => ctr.taskId && ctr.taskId.startsWith(storedTaskId));
747
+ await Promise.all(matches.map(async (ctr) => {
748
+ try {
749
+ await killContainer(ctr.name);
750
+ killed.push(ctr.name);
751
+ }
752
+ catch (err) {
753
+ console.warn(`[cancel] failed to kill ${ctr.name}:`, err);
754
+ }
755
+ }));
756
+ // Mark execution rows belonging to THIS cancelled run as failed.
757
+ // Matching by workflowRunId (the run's id) instead of triggerId
758
+ // avoids clobbering a sibling run that happens to share the same
759
+ // trigger — e.g. two webhook deliveries for the same PR that
760
+ // raced before dedup closed.
761
+ for (const e of db.executions.runningExecutions()) {
762
+ if (e.workflowRunId === id) {
763
+ db.executions.recordFinish(e.id, { success: false, error: "cancelled via admin dashboard" });
764
+ }
765
+ }
766
+ }
767
+ catch (err) {
768
+ console.warn(`[cancel] container enumeration failed:`, err);
769
+ }
770
+ }
771
+ return c.json({ cancelled: id, killedContainers: killed });
423
772
  });
424
773
  // ── Workflow definitions ─────────────────────────────────────────
425
774
  //
426
775
  // The dashboard's pipeline visualisation fetches definitions from here so
427
776
  // it can render exactly the phases the YAML file declares — including
428
777
  // user-defined custom workflows. No hardcoded phase list, no fallback.
429
- app.get("/workflows/:name", async (c) => {
778
+ // List all agent workflows for the dashboard's Workflows browser.
779
+ app.get("/workflows", (c) => {
780
+ const defs = listAgentWorkflows();
781
+ const overrides = db.getAllWorkflowOverrides();
782
+ const workflows = defs.map((def) => ({
783
+ name: def.name,
784
+ kind: def.kind,
785
+ description: def.description,
786
+ trigger: def.trigger,
787
+ phaseCount: def.phases.length,
788
+ hasDag: def.phases.some((p) => Array.isArray(p.depends_on) && p.depends_on.length > 0),
789
+ triggerKinds: getWorkflowTriggerKinds(def.name),
790
+ enabled: overrides.get(def.name)?.enabled ?? true,
791
+ }));
792
+ workflows.sort((a, b) => a.name.localeCompare(b.name));
793
+ return c.json({ workflows });
794
+ });
795
+ app.get("/workflows/:name", (c) => {
430
796
  const name = c.req.param("name");
431
797
  try {
432
- const { getWorkflow } = await import("../workflows/loader.js");
433
798
  const def = getWorkflow(name);
434
799
  // Return only the dashboard-relevant subset (no prompt template paths,
435
- // no model overrides) — keeps the surface small and stable.
800
+ // no model overrides) — keeps the surface small and stable for the
801
+ // run-detail pipeline. Use /workflows/:name/full for the editor.
436
802
  return c.json({
437
803
  workflow: {
438
804
  name: def.name,
@@ -453,32 +819,383 @@ export function createAdminRoutes(db, sessions, chatSessions, config) {
453
819
  return c.json({ error: `workflow definition not found: ${name}`, detail: msg }, 404);
454
820
  }
455
821
  });
822
+ // Full structured definition: every phase field, used by the definition
823
+ // browser to render phase details and the diagram.
824
+ app.get("/workflows/:name/full", (c) => {
825
+ const name = c.req.param("name");
826
+ try {
827
+ const def = getWorkflow(name);
828
+ return c.json({
829
+ workflow: def,
830
+ triggers: getWorkflowTriggers(name),
831
+ enabled: db.isWorkflowEnabled(name),
832
+ });
833
+ }
834
+ catch (err) {
835
+ const msg = err instanceof Error ? err.message : String(err);
836
+ return c.json({ error: `workflow definition not found: ${name}`, detail: msg }, 404);
837
+ }
838
+ });
839
+ // Toggle the kill switch. Mirrors POST /crons/:name/toggle. Persisted to
840
+ // `workflow_overrides`; reads happen on every dispatch in
841
+ // `runSimpleWorkflow`, so the change applies to in-flight cron ticks and
842
+ // webhook dispatches without needing a restart.
843
+ app.post("/workflows/:name/toggle", async (c) => {
844
+ const name = c.req.param("name");
845
+ try {
846
+ // Validate the name actually exists before persisting an override.
847
+ getWorkflow(name);
848
+ }
849
+ catch {
850
+ return c.json({ error: `unknown workflow: ${name}` }, 404);
851
+ }
852
+ const current = db.isWorkflowEnabled(name);
853
+ const next = !current;
854
+ db.setWorkflowEnabled(name, next, "admin");
855
+ return c.json({ name, enabled: next });
856
+ });
857
+ // Raw YAML file content — preserves comments and formatting for the
858
+ // dashboard's syntax-highlighted YAML view.
859
+ app.get("/workflows/:name/yaml", (c) => {
860
+ const name = c.req.param("name");
861
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
862
+ return c.json({ error: "invalid workflow name" }, 400);
863
+ }
864
+ try {
865
+ const yaml = loadWorkflowYamlRaw(name);
866
+ return c.text(yaml, 200, { "Content-Type": "text/plain; charset=utf-8" });
867
+ }
868
+ catch (err) {
869
+ const msg = err instanceof Error ? err.message : String(err);
870
+ return c.json({ error: `workflow yaml not found: ${name}`, detail: msg }, 404);
871
+ }
872
+ });
873
+ // Read a prompt template referenced by a phase (e.g. ?path=prompts/architect.md).
874
+ // Path is validated by `loadPromptTemplate` to live within workflowDir.
875
+ app.get("/workflows/:name/prompt", (c) => {
876
+ const name = c.req.param("name");
877
+ const promptPath = c.req.query("path");
878
+ if (!promptPath)
879
+ return c.json({ error: "missing ?path query" }, 400);
880
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
881
+ return c.json({ error: "invalid workflow name" }, 400);
882
+ }
883
+ // Restrict to the prompts/ subdirectory — workflows reference templates
884
+ // via `prompts/foo.md` (loader.ts:resolvePromptPath also catches escapes).
885
+ if (!promptPath.startsWith("prompts/") || promptPath.includes("..")) {
886
+ return c.json({ error: "prompt path must be under prompts/" }, 400);
887
+ }
888
+ try {
889
+ const text = loadPromptTemplate(promptPath);
890
+ return c.text(text, 200, { "Content-Type": "text/plain; charset=utf-8" });
891
+ }
892
+ catch (err) {
893
+ const msg = err instanceof Error ? err.message : String(err);
894
+ return c.json({ error: `prompt not found`, detail: msg }, 404);
895
+ }
896
+ });
897
+ // Read a skill's SKILL.md file. Used by the phase detail drawer when a
898
+ // phase declares `skill: <name>`.
899
+ app.get("/skills/:name", (c) => {
900
+ const name = c.req.param("name");
901
+ try {
902
+ const text = loadSkillRaw(name);
903
+ return c.text(text, 200, { "Content-Type": "text/plain; charset=utf-8" });
904
+ }
905
+ catch (err) {
906
+ const msg = err instanceof Error ? err.message : String(err);
907
+ return c.json({ error: `skill not found: ${name}`, detail: msg }, 404);
908
+ }
909
+ });
910
+ // ── Build assets (server mode) ─────────────────────────────────
911
+ // Read-only views of the externalized handoff docs (architect-plan.md, …)
912
+ // that live in the server store when `buildAssets.location: server`. The
913
+ // store itself rejects path traversal on every segment. With no store dir
914
+ // configured (repo mode) the list endpoints report empty rather than 404 so
915
+ // the dashboard tab degrades gracefully.
916
+ const buildAssetStore = config.buildAssetsDir ? new BuildAssetStore(config.buildAssetsDir) : null;
917
+ // List the run keys (issue-N / <workflow>-<id>) stored for ?repo=owner/repo.
918
+ app.get("/artifacts", (c) => {
919
+ const repoParam = c.req.query("repo");
920
+ if (!repoParam || !repoParam.includes("/")) {
921
+ return c.json({ error: "missing or invalid ?repo=owner/repo" }, 400);
922
+ }
923
+ if (!buildAssetStore)
924
+ return c.json({ keys: [] });
925
+ const [owner, repo] = repoParam.split("/", 2);
926
+ try {
927
+ return c.json({ keys: buildAssetStore.listKeys(owner, repo) });
928
+ }
929
+ catch (err) {
930
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
931
+ }
932
+ });
933
+ // List the doc filenames stored for one run.
934
+ app.get("/artifacts/:owner/:repo/:key", (c) => {
935
+ if (!buildAssetStore)
936
+ return c.json({ files: [] });
937
+ const { owner, repo, key } = c.req.param();
938
+ try {
939
+ return c.json({ files: buildAssetStore.listFiles({ owner, repo, issueKey: key }) });
940
+ }
941
+ catch (err) {
942
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
943
+ }
944
+ });
945
+ // Read one doc. Binary artifacts (PNG screenshot evidence, /demo mp4/webm) are
946
+ // served as raw bytes with the right Content-Type and Range support (so the
947
+ // dashboard <video> can seek); everything else is text/plain (the dashboard
948
+ // renders it with marked/DOMPurify).
949
+ app.get("/artifacts/:owner/:repo/:key/:doc", (c) => {
950
+ if (!buildAssetStore)
951
+ return c.json({ error: "build-assets store not configured" }, 404);
952
+ const { owner, repo, key, doc } = c.req.param();
953
+ try {
954
+ const binMime = binaryMimeForArtifact(doc);
955
+ if (binMime) {
956
+ const buf = buildAssetStore.readBuffer({ owner, repo, issueKey: key }, doc);
957
+ if (buf === undefined)
958
+ return c.json({ error: `doc not found: ${doc}` }, 404);
959
+ return rangeResponse(c.req.header("range"), buf, binMime, "no-store");
960
+ }
961
+ const content = buildAssetStore.read({ owner, repo, issueKey: key }, doc);
962
+ if (content === undefined)
963
+ return c.json({ error: `doc not found: ${doc}` }, 404);
964
+ return c.text(content, 200, { "Content-Type": "text/plain; charset=utf-8" });
965
+ }
966
+ catch (err) {
967
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
968
+ }
969
+ });
970
+ // Overwrite one doc with the raw markdown request body. The store creates
971
+ // the run dir on demand and rejects path traversal on every segment.
972
+ app.put("/artifacts/:owner/:repo/:key/:doc", async (c) => {
973
+ if (!buildAssetStore)
974
+ return c.json({ error: "build-assets store not configured" }, 404);
975
+ const { owner, repo, key, doc } = c.req.param();
976
+ try {
977
+ const body = await c.req.text();
978
+ buildAssetStore.write({ owner, repo, issueKey: key }, doc, body);
979
+ return c.json({ ok: true });
980
+ }
981
+ catch (err) {
982
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
983
+ }
984
+ });
456
985
  // ── Approval Gates ─────────────────────────────────────────────
457
986
  app.get("/approvals", (c) => {
458
- const approvals = db.listPendingApprovals();
987
+ const approvals = db.approvals.listPending();
459
988
  return c.json({ approvals });
460
989
  });
990
+ // Single approval, enriched with an `artifactRef` the focused approval view
991
+ // uses to open the right doc. `artifactRef` is null when the gate carries no
992
+ // artifact. In server mode the ref points at the editable store doc; in repo
993
+ // mode it carries a GitHub blob URL (the doc is committed on the branch).
994
+ app.get("/approvals/:id", (c) => {
995
+ const approval = db.approvals.getById(c.req.param("id"));
996
+ if (!approval)
997
+ return c.json({ error: "approval not found" }, 404);
998
+ const run = db.runs.getRun(approval.workflowRunId);
999
+ let artifactRef = null;
1000
+ if (approval.artifact && run && run.repo) {
1001
+ // `workflow_runs.repo` is the BARE repo name and `owner` lives in
1002
+ // run.context (set by simple.ts) — except in tests / legacy rows that
1003
+ // may store "owner/repo". Handle both: prefer context.owner, else split.
1004
+ const ctx = run.context ?? {};
1005
+ const ctxOwner = typeof ctx.owner === "string" ? ctx.owner : undefined;
1006
+ const repo = run.repo.includes("/") ? run.repo.split("/")[1] : run.repo;
1007
+ const owner = ctxOwner ?? (run.repo.includes("/") ? run.repo.split("/")[0] : "");
1008
+ if (owner && repo) {
1009
+ const mode = config.buildAssets ?? "repo";
1010
+ const issueKey = buildAssetIssueKey(run.workflowName, run.issueNumber, run.id);
1011
+ const issueDir = typeof ctx.issueDir === "string" ? ctx.issueDir : `.lastlight/${issueKey}`;
1012
+ const branch = typeof ctx.branch === "string" ? ctx.branch : undefined;
1013
+ const githubUrl = mode === "repo" && branch
1014
+ ? `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(branch)}/${issueDir}/${approval.artifact}`
1015
+ : undefined;
1016
+ artifactRef = { mode, owner, repo, issueKey, doc: approval.artifact, githubUrl };
1017
+ }
1018
+ }
1019
+ return c.json({ approval, artifactRef, run: run ?? null });
1020
+ });
461
1021
  app.post("/approvals/:id/respond", async (c) => {
462
1022
  const id = c.req.param("id");
463
1023
  const body = await c.req.json();
464
- const approval = db.getApproval(id);
1024
+ const approval = db.approvals.getById(id);
465
1025
  if (!approval)
466
1026
  return c.json({ error: "approval not found" }, 404);
467
1027
  if (approval.status !== "pending")
468
1028
  return c.json({ error: `already ${approval.status}` }, 400);
469
- db.respondToApproval(id, body.decision, "admin", body.reason);
470
- const workflowRun = db.getWorkflowRun(approval.workflowRunId);
471
1029
  if (body.decision === "rejected") {
472
- if (workflowRun) {
473
- db.finishWorkflowRun(approval.workflowRunId, "failed", `Rejected via dashboard: ${body.reason || "no reason"}`);
1030
+ // One transaction: respond 'rejected' + fail the run.
1031
+ db.runs.resolveGateAndFail(id, "admin", body.reason);
1032
+ }
1033
+ else {
1034
+ // Record the approval, then let resumeWorkflow flip the run back to
1035
+ // `running` — but only as part of an actual dispatch. resumeWorkflow
1036
+ // validates the target (GitHub App present, triggerId is an owner/repo#N
1037
+ // issue) and calls setRunning right before dispatching, so a
1038
+ // non-resumable approval (no resumeWorkflow wired, App down, or a
1039
+ // non-issue trigger) leaves the run paused rather than flipping it to
1040
+ // `running` with no worker. We deliberately do NOT use the atomic
1041
+ // resolveGateAndResume here: unlike the GitHub/Slack path, the dashboard
1042
+ // can't prove a dispatch will follow before responding.
1043
+ //
1044
+ // respond() is a compare-and-set on the still-pending row, so a racing
1045
+ // responder (the status check above is a TOCTOU read) changes 0 rows.
1046
+ // Only the winner resumes — the loser must not dispatch a second time.
1047
+ const changed = db.approvals.respond(id, "approved", "admin", body.reason);
1048
+ if (changed !== 1) {
1049
+ return c.json({ error: "already resolved" }, 409);
1050
+ }
1051
+ const workflowRun = db.runs.getRun(approval.workflowRunId);
1052
+ if (workflowRun && config.resumeWorkflow) {
1053
+ config.resumeWorkflow(workflowRun, "admin").catch((err) => {
1054
+ console.error(`[admin] Failed to resume workflow ${workflowRun.id}:`, err);
1055
+ });
474
1056
  }
475
1057
  }
476
- else if (body.decision === "approved" && workflowRun && config.resumeWorkflow) {
477
- config.resumeWorkflow(workflowRun, "admin").catch((err) => {
478
- console.error(`[admin] Failed to resume workflow ${workflowRun.id}:`, err);
1058
+ return c.json({ status: body.decision });
1059
+ });
1060
+ // ── Crons ──────────────────────────────────────────────────────
1061
+ // List every cron defined in workflows/cron-*.yaml, merged with the
1062
+ // override row (if any) and the live scheduler state.
1063
+ app.get("/crons", (c) => {
1064
+ const overrides = db.getAllCronOverrides();
1065
+ const liveByName = new Map((config.cronScheduler?.list() ?? []).map((j) => [j.name, j]));
1066
+ const defs = getCronWorkflows();
1067
+ const crons = defs.map((def) => {
1068
+ const override = overrides.get(def.name) ?? null;
1069
+ const enabled = override ? override.enabled : true;
1070
+ const live = liveByName.get(def.name) ?? null;
1071
+ const recentFailures = db.executions.consecutiveFailures(def.workflow);
1072
+ // Find the most recent workflow_run for this cron's workflow
1073
+ const recent = db.runs.listRecent(50).find((r) => r.workflowName === def.workflow);
1074
+ return {
1075
+ name: def.name,
1076
+ workflow: def.workflow,
1077
+ schedule: override?.schedule ?? def.schedule,
1078
+ originalSchedule: def.schedule,
1079
+ enabled,
1080
+ registered: !!live,
1081
+ nextRun: live?.nextRun?.toISOString() ?? null,
1082
+ lastRun: recent?.startedAt ?? null,
1083
+ lastStatus: recent?.status ?? null,
1084
+ recentFailures,
1085
+ context: { repos: getManagedRepos(), ...def.context },
1086
+ override: override
1087
+ ? {
1088
+ updatedAt: override.updatedAt,
1089
+ updatedBy: override.updatedBy,
1090
+ hasScheduleOverride: override.schedule != null,
1091
+ }
1092
+ : null,
1093
+ };
1094
+ });
1095
+ return c.json({ crons });
1096
+ });
1097
+ // Toggle the enabled bit for a cron. Updates the scheduler in lockstep so
1098
+ // the change takes effect immediately (no restart).
1099
+ app.post("/crons/:name/toggle", async (c) => {
1100
+ if (!config.cronScheduler) {
1101
+ return c.json({ error: "cron scheduler not configured" }, 503);
1102
+ }
1103
+ const name = c.req.param("name");
1104
+ const def = getCronWorkflows().find((d) => d.name === name);
1105
+ if (!def)
1106
+ return c.json({ error: `cron not found: ${name}` }, 404);
1107
+ const override = db.getCronOverride(name);
1108
+ const currentlyEnabled = override ? override.enabled : true;
1109
+ const nextEnabled = !currentlyEnabled;
1110
+ db.setCronOverride(name, { enabled: nextEnabled, updatedBy: "admin" });
1111
+ if (nextEnabled) {
1112
+ // Re-register with the (possibly overridden) schedule
1113
+ const schedule = override?.schedule || def.schedule;
1114
+ if (config.cronScheduler.has(name)) {
1115
+ config.cronScheduler.update({
1116
+ name,
1117
+ schedule,
1118
+ workflow: def.workflow,
1119
+ context: { repos: getManagedRepos(), ...def.context },
1120
+ });
1121
+ }
1122
+ else {
1123
+ config.cronScheduler.register({
1124
+ name,
1125
+ schedule,
1126
+ workflow: def.workflow,
1127
+ context: { repos: getManagedRepos(), ...def.context },
1128
+ });
1129
+ }
1130
+ }
1131
+ else {
1132
+ config.cronScheduler.unregister(name);
1133
+ }
1134
+ return c.json({ name, enabled: nextEnabled });
1135
+ });
1136
+ // Persist a schedule override and apply it to the scheduler. Validates the
1137
+ // expression with croner before saving so a bad expression returns 400 and
1138
+ // the live cron isn't disturbed.
1139
+ app.post("/crons/:name/schedule", async (c) => {
1140
+ if (!config.cronScheduler) {
1141
+ return c.json({ error: "cron scheduler not configured" }, 503);
1142
+ }
1143
+ const name = c.req.param("name");
1144
+ const def = getCronWorkflows().find((d) => d.name === name);
1145
+ if (!def)
1146
+ return c.json({ error: `cron not found: ${name}` }, 404);
1147
+ const body = await c.req.json();
1148
+ const schedule = (body.schedule ?? "").trim();
1149
+ if (!schedule)
1150
+ return c.json({ error: "schedule is required" }, 400);
1151
+ try {
1152
+ // Construct a paused Cron purely to validate the pattern, then dispose.
1153
+ const probe = new Cron(schedule, { paused: true }, () => { });
1154
+ probe.stop();
1155
+ }
1156
+ catch (err) {
1157
+ const msg = err instanceof Error ? err.message : String(err);
1158
+ return c.json({ error: `invalid schedule: ${msg}` }, 400);
1159
+ }
1160
+ db.setCronOverride(name, { schedule, updatedBy: "admin" });
1161
+ const override = db.getCronOverride(name);
1162
+ if (override?.enabled !== false) {
1163
+ config.cronScheduler.update({
1164
+ name,
1165
+ schedule,
1166
+ workflow: def.workflow,
1167
+ context: { repos: getManagedRepos(), ...def.context },
479
1168
  });
480
1169
  }
481
- return c.json({ status: body.decision });
1170
+ return c.json({ name, schedule });
1171
+ });
1172
+ // Drop the override row and re-register the cron at its YAML default.
1173
+ app.delete("/crons/:name/override", (c) => {
1174
+ if (!config.cronScheduler) {
1175
+ return c.json({ error: "cron scheduler not configured" }, 503);
1176
+ }
1177
+ const name = c.req.param("name");
1178
+ const def = getCronWorkflows().find((d) => d.name === name);
1179
+ if (!def)
1180
+ return c.json({ error: `cron not found: ${name}` }, 404);
1181
+ db.clearCronOverride(name);
1182
+ if (config.cronScheduler.has(name)) {
1183
+ config.cronScheduler.update({
1184
+ name,
1185
+ schedule: def.schedule,
1186
+ workflow: def.workflow,
1187
+ context: { repos: getManagedRepos(), ...def.context },
1188
+ });
1189
+ }
1190
+ else {
1191
+ config.cronScheduler.register({
1192
+ name,
1193
+ schedule: def.schedule,
1194
+ workflow: def.workflow,
1195
+ context: { repos: getManagedRepos(), ...def.context },
1196
+ });
1197
+ }
1198
+ return c.json({ name, schedule: def.schedule, enabled: true });
482
1199
  });
483
1200
  return app;
484
1201
  }