lastlight 0.15.0 → 0.16.1

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