lastlight 0.6.1 → 0.7.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 (326) hide show
  1. package/dist/admin/routes.d.ts +1 -1
  2. package/dist/admin/routes.js +1 -1
  3. package/dist/admin/routes.js.map +1 -1
  4. package/dist/cli/cli-config.js.map +1 -0
  5. package/dist/cli/cli-format.js.map +1 -0
  6. package/dist/{cli-server.d.ts → cli/cli-server.d.ts} +1 -1
  7. package/dist/{cli-server.js → cli/cli-server.js} +2 -2
  8. package/dist/cli/cli-server.js.map +1 -0
  9. package/dist/cli/cli-timeline.js.map +1 -0
  10. package/dist/{cli.js → cli/cli.js} +2 -0
  11. package/dist/cli/cli.js.map +1 -0
  12. package/dist/{fork-cli.d.ts → cli/fork-cli.d.ts} +14 -5
  13. package/dist/{fork-cli.js → cli/fork-cli.js} +86 -16
  14. package/dist/cli/fork-cli.js.map +1 -0
  15. package/dist/{setup.js → cli/setup.js} +1 -1
  16. package/dist/cli/setup.js.map +1 -0
  17. package/dist/{skills-install.js → cli/skills-install.js} +1 -1
  18. package/dist/cli/skills-install.js.map +1 -0
  19. package/dist/config/config-resolve.js.map +1 -0
  20. package/dist/{config.js → config/config.js} +2 -2
  21. package/dist/config/config.js.map +1 -0
  22. package/dist/config/overlay-assets.js.map +1 -0
  23. package/dist/config/overlay-bootstrap.js.map +1 -0
  24. package/dist/engine/agent-executor.d.ts +1 -1
  25. package/dist/engine/agent-executor.js +2 -2
  26. package/dist/engine/agent-executor.js.map +1 -1
  27. package/dist/engine/{chat-runner.d.ts → chat/chat-runner.d.ts} +2 -2
  28. package/dist/engine/{chat-runner.js → chat/chat-runner.js} +1 -1
  29. package/dist/engine/chat/chat-runner.js.map +1 -0
  30. package/dist/engine/{chat-skills.js → chat/chat-skills.js} +1 -1
  31. package/dist/engine/chat/chat-skills.js.map +1 -0
  32. package/dist/engine/{chat.d.ts → chat/chat.d.ts} +2 -2
  33. package/dist/engine/{chat.js → chat/chat.js} +7 -7
  34. package/dist/engine/chat/chat.js.map +1 -0
  35. package/dist/engine/{message-batcher.d.ts → chat/message-batcher.d.ts} +1 -1
  36. package/dist/engine/chat/message-batcher.js.map +1 -0
  37. package/dist/engine/dispatcher.d.ts +2 -2
  38. package/dist/engine/executors/backends.d.ts +2 -2
  39. package/dist/engine/executors/backends.js +1 -1
  40. package/dist/engine/executors/backends.js.map +1 -1
  41. package/dist/engine/executors/shared.d.ts +1 -1
  42. package/dist/engine/github/git-auth.js.map +1 -0
  43. package/dist/engine/github/github-app-client.js.map +1 -0
  44. package/dist/engine/github/github-tools.js.map +1 -0
  45. package/dist/engine/github/github.js.map +1 -0
  46. package/dist/engine/{profiles.d.ts → github/profiles.d.ts} +1 -1
  47. package/dist/engine/{profiles.js → github/profiles.js} +1 -1
  48. package/dist/engine/github/profiles.js.map +1 -0
  49. package/dist/engine/router.js +3 -3
  50. package/dist/engine/router.js.map +1 -1
  51. package/dist/engine/{classifier.d.ts → screen/classifier.d.ts} +1 -1
  52. package/dist/engine/{classifier.js → screen/classifier.js} +1 -1
  53. package/dist/engine/screen/classifier.js.map +1 -0
  54. package/dist/engine/{screen.d.ts → screen/screen.d.ts} +1 -1
  55. package/dist/engine/{screen.js → screen/screen.js} +1 -1
  56. package/dist/engine/screen/screen.js.map +1 -0
  57. package/dist/evals-api.d.ts +3 -3
  58. package/dist/evals-api.js +1 -1
  59. package/dist/evals-api.js.map +1 -1
  60. package/dist/index.js +8 -8
  61. package/dist/index.js.map +1 -1
  62. package/dist/managed-repos.js +1 -1
  63. package/dist/managed-repos.js.map +1 -1
  64. package/dist/notify/transports/github.d.ts +1 -1
  65. package/dist/telemetry/index.d.ts +1 -1
  66. package/dist/workflows/loader.d.ts +1 -1
  67. package/dist/workflows/phase-executor.d.ts +1 -1
  68. package/dist/workflows/resume.d.ts +3 -3
  69. package/dist/workflows/runner.d.ts +2 -2
  70. package/dist/workflows/runner.js +1 -1
  71. package/dist/workflows/runner.js.map +1 -1
  72. package/dist/workflows/schema.d.ts +2 -2
  73. package/dist/workflows/simple.d.ts +2 -2
  74. package/dist/workflows/simple.js +1 -1
  75. package/dist/workflows/simple.js.map +1 -1
  76. package/dist/workflows/triggers.js +1 -1
  77. package/dist/workflows/triggers.js.map +1 -1
  78. package/package.json +4 -3
  79. package/plugins/lastlight/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lastlight/skills/lastlight-evals/SKILL.md +52 -19
  81. package/plugins/lastlight/skills/lastlight-overlay/SKILL.md +7 -1
  82. package/plugins/lastlight/skills/lastlight-overlay/references/forking.md +23 -4
  83. package/dist/admin/auth.test.d.ts +0 -1
  84. package/dist/admin/auth.test.js +0 -60
  85. package/dist/admin/auth.test.js.map +0 -1
  86. package/dist/admin/log-search.test.d.ts +0 -1
  87. package/dist/admin/log-search.test.js +0 -78
  88. package/dist/admin/log-search.test.js.map +0 -1
  89. package/dist/admin/routes.test.d.ts +0 -1
  90. package/dist/admin/routes.test.js +0 -877
  91. package/dist/admin/routes.test.js.map +0 -1
  92. package/dist/admin/server-logs.test.d.ts +0 -1
  93. package/dist/admin/server-logs.test.js +0 -58
  94. package/dist/admin/server-logs.test.js.map +0 -1
  95. package/dist/admin/sessions.test.d.ts +0 -1
  96. package/dist/admin/sessions.test.js +0 -78
  97. package/dist/admin/sessions.test.js.map +0 -1
  98. package/dist/admin/version.test.d.ts +0 -1
  99. package/dist/admin/version.test.js +0 -71
  100. package/dist/admin/version.test.js.map +0 -1
  101. package/dist/cli-config.js.map +0 -1
  102. package/dist/cli-config.test.d.ts +0 -1
  103. package/dist/cli-config.test.js +0 -92
  104. package/dist/cli-config.test.js.map +0 -1
  105. package/dist/cli-format.js.map +0 -1
  106. package/dist/cli-server.js.map +0 -1
  107. package/dist/cli-server.test.d.ts +0 -1
  108. package/dist/cli-server.test.js +0 -41
  109. package/dist/cli-server.test.js.map +0 -1
  110. package/dist/cli-timeline.js.map +0 -1
  111. package/dist/cli-timeline.test.d.ts +0 -1
  112. package/dist/cli-timeline.test.js +0 -104
  113. package/dist/cli-timeline.test.js.map +0 -1
  114. package/dist/cli.js.map +0 -1
  115. package/dist/config-overlay.test.d.ts +0 -1
  116. package/dist/config-overlay.test.js +0 -112
  117. package/dist/config-overlay.test.js.map +0 -1
  118. package/dist/config-resolve.js.map +0 -1
  119. package/dist/config-resolve.test.d.ts +0 -1
  120. package/dist/config-resolve.test.js +0 -68
  121. package/dist/config-resolve.test.js.map +0 -1
  122. package/dist/config.js.map +0 -1
  123. package/dist/config.test.d.ts +0 -1
  124. package/dist/config.test.js +0 -288
  125. package/dist/config.test.js.map +0 -1
  126. package/dist/connectors/github-webhook.test.d.ts +0 -1
  127. package/dist/connectors/github-webhook.test.js +0 -156
  128. package/dist/connectors/github-webhook.test.js.map +0 -1
  129. package/dist/connectors/messaging/session-manager.test.d.ts +0 -1
  130. package/dist/connectors/messaging/session-manager.test.js +0 -144
  131. package/dist/connectors/messaging/session-manager.test.js.map +0 -1
  132. package/dist/connectors/slack/connector.test.d.ts +0 -1
  133. package/dist/connectors/slack/connector.test.js +0 -135
  134. package/dist/connectors/slack/connector.test.js.map +0 -1
  135. package/dist/connectors/slack/mrkdwn.test.d.ts +0 -1
  136. package/dist/connectors/slack/mrkdwn.test.js +0 -114
  137. package/dist/connectors/slack/mrkdwn.test.js.map +0 -1
  138. package/dist/cron/fanout.test.d.ts +0 -1
  139. package/dist/cron/fanout.test.js +0 -73
  140. package/dist/cron/fanout.test.js.map +0 -1
  141. package/dist/engine/agent-executor.seam.test.d.ts +0 -1
  142. package/dist/engine/agent-executor.seam.test.js +0 -57
  143. package/dist/engine/agent-executor.seam.test.js.map +0 -1
  144. package/dist/engine/agent-executor.test.d.ts +0 -1
  145. package/dist/engine/agent-executor.test.js +0 -395
  146. package/dist/engine/agent-executor.test.js.map +0 -1
  147. package/dist/engine/chat-runner.js.map +0 -1
  148. package/dist/engine/chat-runner.test.d.ts +0 -1
  149. package/dist/engine/chat-runner.test.js +0 -104
  150. package/dist/engine/chat-runner.test.js.map +0 -1
  151. package/dist/engine/chat-skills.js.map +0 -1
  152. package/dist/engine/chat-skills.test.d.ts +0 -1
  153. package/dist/engine/chat-skills.test.js +0 -20
  154. package/dist/engine/chat-skills.test.js.map +0 -1
  155. package/dist/engine/chat.js.map +0 -1
  156. package/dist/engine/chat.test.d.ts +0 -1
  157. package/dist/engine/chat.test.js +0 -42
  158. package/dist/engine/chat.test.js.map +0 -1
  159. package/dist/engine/classifier.js.map +0 -1
  160. package/dist/engine/classifier.test.d.ts +0 -1
  161. package/dist/engine/classifier.test.js +0 -115
  162. package/dist/engine/classifier.test.js.map +0 -1
  163. package/dist/engine/dispatcher.test.d.ts +0 -1
  164. package/dist/engine/dispatcher.test.js +0 -511
  165. package/dist/engine/dispatcher.test.js.map +0 -1
  166. package/dist/engine/event-shim.test.d.ts +0 -1
  167. package/dist/engine/event-shim.test.js +0 -194
  168. package/dist/engine/event-shim.test.js.map +0 -1
  169. package/dist/engine/git-auth.js.map +0 -1
  170. package/dist/engine/git-auth.test.d.ts +0 -1
  171. package/dist/engine/git-auth.test.js +0 -117
  172. package/dist/engine/git-auth.test.js.map +0 -1
  173. package/dist/engine/github-app-client.js.map +0 -1
  174. package/dist/engine/github-app-client.test.d.ts +0 -1
  175. package/dist/engine/github-app-client.test.js +0 -44
  176. package/dist/engine/github-app-client.test.js.map +0 -1
  177. package/dist/engine/github-tools.js.map +0 -1
  178. package/dist/engine/github.js.map +0 -1
  179. package/dist/engine/llm.test.d.ts +0 -1
  180. package/dist/engine/llm.test.js +0 -189
  181. package/dist/engine/llm.test.js.map +0 -1
  182. package/dist/engine/message-batcher.js.map +0 -1
  183. package/dist/engine/message-batcher.test.d.ts +0 -1
  184. package/dist/engine/message-batcher.test.js +0 -109
  185. package/dist/engine/message-batcher.test.js.map +0 -1
  186. package/dist/engine/profiles.js.map +0 -1
  187. package/dist/engine/router.test.d.ts +0 -1
  188. package/dist/engine/router.test.js +0 -695
  189. package/dist/engine/router.test.js.map +0 -1
  190. package/dist/engine/screen.js.map +0 -1
  191. package/dist/engine/screen.test.d.ts +0 -1
  192. package/dist/engine/screen.test.js +0 -82
  193. package/dist/engine/screen.test.js.map +0 -1
  194. package/dist/fork-cli.js.map +0 -1
  195. package/dist/fork-cli.test.d.ts +0 -1
  196. package/dist/fork-cli.test.js +0 -109
  197. package/dist/fork-cli.test.js.map +0 -1
  198. package/dist/managed-repos.test.d.ts +0 -1
  199. package/dist/managed-repos.test.js +0 -39
  200. package/dist/managed-repos.test.js.map +0 -1
  201. package/dist/notify/model.test.d.ts +0 -1
  202. package/dist/notify/model.test.js +0 -109
  203. package/dist/notify/model.test.js.map +0 -1
  204. package/dist/notify/notifier.test.d.ts +0 -1
  205. package/dist/notify/notifier.test.js +0 -100
  206. package/dist/notify/notifier.test.js.map +0 -1
  207. package/dist/notify/render.test.d.ts +0 -1
  208. package/dist/notify/render.test.js +0 -65
  209. package/dist/notify/render.test.js.map +0 -1
  210. package/dist/notify/transports.test.d.ts +0 -1
  211. package/dist/notify/transports.test.js +0 -71
  212. package/dist/notify/transports.test.js.map +0 -1
  213. package/dist/overlay-assets.js.map +0 -1
  214. package/dist/overlay-assets.test.d.ts +0 -1
  215. package/dist/overlay-assets.test.js +0 -75
  216. package/dist/overlay-assets.test.js.map +0 -1
  217. package/dist/overlay-bootstrap.js.map +0 -1
  218. package/dist/overlay-bootstrap.test.d.ts +0 -1
  219. package/dist/overlay-bootstrap.test.js +0 -61
  220. package/dist/overlay-bootstrap.test.js.map +0 -1
  221. package/dist/sandbox/command-exec.integration.test.d.ts +0 -1
  222. package/dist/sandbox/command-exec.integration.test.js +0 -183
  223. package/dist/sandbox/command-exec.integration.test.js.map +0 -1
  224. package/dist/sandbox/docker-compose.test.d.ts +0 -1
  225. package/dist/sandbox/docker-compose.test.js +0 -128
  226. package/dist/sandbox/docker-compose.test.js.map +0 -1
  227. package/dist/sandbox/docker.test.d.ts +0 -1
  228. package/dist/sandbox/docker.test.js +0 -137
  229. package/dist/sandbox/docker.test.js.map +0 -1
  230. package/dist/sandbox/egress-allowlist.test.d.ts +0 -1
  231. package/dist/sandbox/egress-allowlist.test.js +0 -65
  232. package/dist/sandbox/egress-allowlist.test.js.map +0 -1
  233. package/dist/sandbox/egress-firewall-config.test.d.ts +0 -1
  234. package/dist/sandbox/egress-firewall-config.test.js +0 -244
  235. package/dist/sandbox/egress-firewall-config.test.js.map +0 -1
  236. package/dist/sandbox/index.test.d.ts +0 -1
  237. package/dist/sandbox/index.test.js +0 -157
  238. package/dist/sandbox/index.test.js.map +0 -1
  239. package/dist/sandbox/smol.integration.test.d.ts +0 -1
  240. package/dist/sandbox/smol.integration.test.js +0 -130
  241. package/dist/sandbox/smol.integration.test.js.map +0 -1
  242. package/dist/session-log.test.d.ts +0 -1
  243. package/dist/session-log.test.js +0 -85
  244. package/dist/session-log.test.js.map +0 -1
  245. package/dist/setup.js.map +0 -1
  246. package/dist/setup.test.d.ts +0 -1
  247. package/dist/setup.test.js +0 -281
  248. package/dist/setup.test.js.map +0 -1
  249. package/dist/skills-install.js.map +0 -1
  250. package/dist/state/build-assets.test.d.ts +0 -1
  251. package/dist/state/build-assets.test.js +0 -106
  252. package/dist/state/build-assets.test.js.map +0 -1
  253. package/dist/state/db.test.d.ts +0 -1
  254. package/dist/state/db.test.js +0 -484
  255. package/dist/state/db.test.js.map +0 -1
  256. package/dist/state/workflow-run-store.test.d.ts +0 -1
  257. package/dist/state/workflow-run-store.test.js +0 -264
  258. package/dist/state/workflow-run-store.test.js.map +0 -1
  259. package/dist/telemetry/index.test.d.ts +0 -1
  260. package/dist/telemetry/index.test.js +0 -73
  261. package/dist/telemetry/index.test.js.map +0 -1
  262. package/dist/telemetry/pi-events.test.d.ts +0 -1
  263. package/dist/telemetry/pi-events.test.js +0 -69
  264. package/dist/telemetry/pi-events.test.js.map +0 -1
  265. package/dist/workflows/dag.test.d.ts +0 -1
  266. package/dist/workflows/dag.test.js +0 -197
  267. package/dist/workflows/dag.test.js.map +0 -1
  268. package/dist/workflows/golden-build.test.d.ts +0 -1
  269. package/dist/workflows/golden-build.test.js +0 -45
  270. package/dist/workflows/golden-build.test.js.map +0 -1
  271. package/dist/workflows/loader-overlay.test.d.ts +0 -1
  272. package/dist/workflows/loader-overlay.test.js +0 -101
  273. package/dist/workflows/loader-overlay.test.js.map +0 -1
  274. package/dist/workflows/loader.test.d.ts +0 -1
  275. package/dist/workflows/loader.test.js +0 -375
  276. package/dist/workflows/loader.test.js.map +0 -1
  277. package/dist/workflows/loop-eval.test.d.ts +0 -1
  278. package/dist/workflows/loop-eval.test.js +0 -89
  279. package/dist/workflows/loop-eval.test.js.map +0 -1
  280. package/dist/workflows/phase-executor.test.d.ts +0 -1
  281. package/dist/workflows/phase-executor.test.js +0 -497
  282. package/dist/workflows/phase-executor.test.js.map +0 -1
  283. package/dist/workflows/phase-ref.test.d.ts +0 -1
  284. package/dist/workflows/phase-ref.test.js +0 -24
  285. package/dist/workflows/phase-ref.test.js.map +0 -1
  286. package/dist/workflows/runner.test.d.ts +0 -1
  287. package/dist/workflows/runner.test.js +0 -1174
  288. package/dist/workflows/runner.test.js.map +0 -1
  289. package/dist/workflows/simple.test.d.ts +0 -1
  290. package/dist/workflows/simple.test.js +0 -107
  291. package/dist/workflows/simple.test.js.map +0 -1
  292. package/dist/workflows/templates.test.d.ts +0 -1
  293. package/dist/workflows/templates.test.js +0 -258
  294. package/dist/workflows/templates.test.js.map +0 -1
  295. package/dist/workflows/verdict.test.d.ts +0 -1
  296. package/dist/workflows/verdict.test.js +0 -44
  297. package/dist/workflows/verdict.test.js.map +0 -1
  298. package/dist/worktree/manager.test.d.ts +0 -1
  299. package/dist/worktree/manager.test.js +0 -87
  300. package/dist/worktree/manager.test.js.map +0 -1
  301. /package/dist/{cli-config.d.ts → cli/cli-config.d.ts} +0 -0
  302. /package/dist/{cli-config.js → cli/cli-config.js} +0 -0
  303. /package/dist/{cli-format.d.ts → cli/cli-format.d.ts} +0 -0
  304. /package/dist/{cli-format.js → cli/cli-format.js} +0 -0
  305. /package/dist/{cli-timeline.d.ts → cli/cli-timeline.d.ts} +0 -0
  306. /package/dist/{cli-timeline.js → cli/cli-timeline.js} +0 -0
  307. /package/dist/{cli.d.ts → cli/cli.d.ts} +0 -0
  308. /package/dist/{setup.d.ts → cli/setup.d.ts} +0 -0
  309. /package/dist/{skills-install.d.ts → cli/skills-install.d.ts} +0 -0
  310. /package/dist/{config-resolve.d.ts → config/config-resolve.d.ts} +0 -0
  311. /package/dist/{config-resolve.js → config/config-resolve.js} +0 -0
  312. /package/dist/{config.d.ts → config/config.d.ts} +0 -0
  313. /package/dist/{overlay-assets.d.ts → config/overlay-assets.d.ts} +0 -0
  314. /package/dist/{overlay-assets.js → config/overlay-assets.js} +0 -0
  315. /package/dist/{overlay-bootstrap.d.ts → config/overlay-bootstrap.d.ts} +0 -0
  316. /package/dist/{overlay-bootstrap.js → config/overlay-bootstrap.js} +0 -0
  317. /package/dist/engine/{chat-skills.d.ts → chat/chat-skills.d.ts} +0 -0
  318. /package/dist/engine/{message-batcher.js → chat/message-batcher.js} +0 -0
  319. /package/dist/engine/{git-auth.d.ts → github/git-auth.d.ts} +0 -0
  320. /package/dist/engine/{git-auth.js → github/git-auth.js} +0 -0
  321. /package/dist/engine/{github-app-client.d.ts → github/github-app-client.d.ts} +0 -0
  322. /package/dist/engine/{github-app-client.js → github/github-app-client.js} +0 -0
  323. /package/dist/engine/{github-tools.d.ts → github/github-tools.d.ts} +0 -0
  324. /package/dist/engine/{github-tools.js → github/github-tools.js} +0 -0
  325. /package/dist/engine/{github.d.ts → github/github.d.ts} +0 -0
  326. /package/dist/engine/{github.js → github/github.js} +0 -0
@@ -1,877 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { Hono } from "hono";
6
- import { createAdminRoutes } from "./routes.js";
7
- import { mountAdmin } from "./index.js";
8
- import { BuildAssetStore } from "../state/build-assets.js";
9
- // Mock docker so tests don't need a running daemon
10
- vi.mock("./docker.js", () => ({
11
- listRunningContainers: vi.fn(async () => []),
12
- killContainer: vi.fn(async () => { }),
13
- getContainerStats: vi.fn(async () => []),
14
- }));
15
- // Mock arctic so we control OAuth flow without hitting Slack or GitHub
16
- vi.mock("arctic", () => {
17
- class Slack {
18
- createAuthorizationURL(_state, _scopes) {
19
- return new URL("https://slack.com/openid/connect/authorize?mocked=1");
20
- }
21
- async validateAuthorizationCode(_code) {
22
- return { accessToken: () => "mock-slack-access-token" };
23
- }
24
- }
25
- class GitHub {
26
- createAuthorizationURL(_state, _scopes) {
27
- return new URL("https://github.com/login/oauth/authorize?mocked=1");
28
- }
29
- async validateAuthorizationCode(_code) {
30
- return { accessToken: () => "mock-github-access-token" };
31
- }
32
- }
33
- return { Slack, GitHub };
34
- });
35
- // Minimal mocks
36
- const mockDb = {
37
- executions: {
38
- executionStats: vi.fn(() => ({ total: 0, running: 0, success: 0, failed: 0 })),
39
- dailyStats: vi.fn(() => []),
40
- hourlyStats: vi.fn(() => []),
41
- allExecutions: vi.fn(() => []),
42
- runningExecutions: vi.fn(() => []),
43
- },
44
- runs: {
45
- list: vi.fn(() => ({ runs: [], total: 0 })),
46
- distinctNames: vi.fn(() => []),
47
- getRun: vi.fn(() => null),
48
- },
49
- approvals: {
50
- listPending: vi.fn(() => []),
51
- },
52
- };
53
- const mockSessions = {
54
- listSessionIds: vi.fn(() => []),
55
- getSessionMeta: vi.fn(async () => null),
56
- exists: vi.fn(() => false),
57
- read: vi.fn(async () => []),
58
- getFilePath: vi.fn(() => null),
59
- normalizeRawLine: vi.fn((raw) => [raw]),
60
- };
61
- function makeConfig(overrides = {}) {
62
- return {
63
- stateDir: "/tmp",
64
- sessionsDir: "/tmp/sessions",
65
- adminPassword: "test-password",
66
- adminSecret: "test-secret",
67
- ...overrides,
68
- };
69
- }
70
- async function request(app, path, opts = {}) {
71
- const req = new Request(`http://localhost${path}`, opts);
72
- return app.fetch(req);
73
- }
74
- describe("GET /auth-required", () => {
75
- it("returns slackOAuth: false when not configured", async () => {
76
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig());
77
- const res = await request(app, "/auth-required");
78
- const body = await res.json();
79
- expect(res.status).toBe(200);
80
- expect(body.slackOAuth).toBe(false);
81
- expect(body.required).toBe(true);
82
- });
83
- it("returns slackOAuth: true when client ID and secret are configured", async () => {
84
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
85
- slackOAuthClientId: "C123",
86
- slackOAuthClientSecret: "secret",
87
- }));
88
- const res = await request(app, "/auth-required");
89
- const body = await res.json();
90
- expect(res.status).toBe(200);
91
- expect(body.slackOAuth).toBe(true);
92
- });
93
- it("returns slackOAuth: false when only clientId is set (secret missing)", async () => {
94
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
95
- slackOAuthClientId: "C123",
96
- }));
97
- const res = await request(app, "/auth-required");
98
- const body = await res.json();
99
- expect(body.slackOAuth).toBe(false);
100
- });
101
- });
102
- describe("GET /oauth/slack/authorize", () => {
103
- it("returns 404 when Slack OAuth not configured", async () => {
104
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig());
105
- const res = await request(app, "/oauth/slack/authorize");
106
- expect(res.status).toBe(404);
107
- });
108
- it("redirects to Slack when configured", async () => {
109
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
110
- slackOAuthClientId: "C123",
111
- slackOAuthClientSecret: "secret",
112
- slackOAuthRedirectUri: "http://localhost/callback",
113
- }));
114
- const res = await request(app, "/oauth/slack/authorize");
115
- expect(res.status).toBe(302);
116
- const location = res.headers.get("location") ?? "";
117
- expect(location).toContain("slack.com");
118
- });
119
- });
120
- describe("GET /oauth/slack/callback", () => {
121
- it("returns 404 when Slack OAuth not configured", async () => {
122
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig());
123
- const res = await request(app, "/oauth/slack/callback?code=abc&state=xyz");
124
- expect(res.status).toBe(404);
125
- });
126
- it("returns 400 when state is missing or mismatched", async () => {
127
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
128
- slackOAuthClientId: "C123",
129
- slackOAuthClientSecret: "secret",
130
- slackOAuthRedirectUri: "http://localhost/callback",
131
- }));
132
- // No cookie set → state mismatch
133
- const res = await request(app, "/oauth/slack/callback?code=abc&state=bad-state");
134
- expect(res.status).toBe(400);
135
- });
136
- it("returns 403 when workspace does not match", async () => {
137
- // Mock fetch to return a different team
138
- const originalFetch = global.fetch;
139
- global.fetch = vi.fn(async () => new Response(JSON.stringify({
140
- sub: "U99999",
141
- "https://slack.com/team_id": "T99999",
142
- "https://slack.com/team_domain": "other-team",
143
- }), { headers: { "Content-Type": "application/json" } }));
144
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
145
- slackOAuthClientId: "C123",
146
- slackOAuthClientSecret: "secret",
147
- slackOAuthRedirectUri: "http://localhost/callback",
148
- slackAllowedWorkspace: "T00001",
149
- }));
150
- // Simulate request with matching state cookie
151
- const state = "teststate123";
152
- const req = new Request(`http://localhost/oauth/slack/callback?code=abc&state=${state}`, {
153
- headers: { Cookie: `slack_oauth_state=${state}` },
154
- });
155
- const res = await app.fetch(req);
156
- expect(res.status).toBe(403);
157
- global.fetch = originalFetch;
158
- });
159
- it("redirects with token when workspace matches by team_id", async () => {
160
- const originalFetch = global.fetch;
161
- global.fetch = vi.fn(async () => new Response(JSON.stringify({
162
- sub: "U00001",
163
- "https://slack.com/team_id": "T00001",
164
- "https://slack.com/team_domain": "my-team",
165
- }), { headers: { "Content-Type": "application/json" } }));
166
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
167
- slackOAuthClientId: "C123",
168
- slackOAuthClientSecret: "secret",
169
- slackOAuthRedirectUri: "http://localhost/callback",
170
- slackAllowedWorkspace: "T00001",
171
- }));
172
- const state = "teststate456";
173
- const req = new Request(`http://localhost/oauth/slack/callback?code=abc&state=${state}`, {
174
- headers: { Cookie: `slack_oauth_state=${state}` },
175
- });
176
- const res = await app.fetch(req);
177
- expect(res.status).toBe(302);
178
- const location = res.headers.get("location") ?? "";
179
- expect(location).toContain("/admin/?token=");
180
- global.fetch = originalFetch;
181
- });
182
- it("redirects with token when no workspace restriction set", async () => {
183
- const originalFetch = global.fetch;
184
- global.fetch = vi.fn(async () => new Response(JSON.stringify({
185
- sub: "UANY",
186
- "https://slack.com/team_id": "TANY",
187
- "https://slack.com/team_domain": "any-team",
188
- }), { headers: { "Content-Type": "application/json" } }));
189
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
190
- slackOAuthClientId: "C123",
191
- slackOAuthClientSecret: "secret",
192
- slackOAuthRedirectUri: "http://localhost/callback",
193
- }));
194
- const state = "teststate789";
195
- const req = new Request(`http://localhost/oauth/slack/callback?code=abc&state=${state}`, {
196
- headers: { Cookie: `slack_oauth_state=${state}` },
197
- });
198
- const res = await app.fetch(req);
199
- expect(res.status).toBe(302);
200
- expect(res.headers.get("location")).toContain("/admin/?token=");
201
- global.fetch = originalFetch;
202
- });
203
- });
204
- describe("GET /auth-required (GitHub OAuth)", () => {
205
- it("returns githubOAuth: false when not configured", async () => {
206
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig());
207
- const res = await request(app, "/auth-required");
208
- const body = await res.json();
209
- expect(res.status).toBe(200);
210
- expect(body.githubOAuth).toBe(false);
211
- });
212
- it("returns githubOAuth: true when client ID, secret, and allowed org are configured", async () => {
213
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
214
- githubOAuthClientId: "GH_CLIENT",
215
- githubOAuthClientSecret: "GH_SECRET",
216
- githubAllowedOrg: "acme",
217
- }));
218
- const res = await request(app, "/auth-required");
219
- const body = await res.json();
220
- expect(res.status).toBe(200);
221
- expect(body.githubOAuth).toBe(true);
222
- });
223
- it("returns githubOAuth: true when allowed org is \"*\" (allow any user)", async () => {
224
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
225
- githubOAuthClientId: "GH_CLIENT",
226
- githubOAuthClientSecret: "GH_SECRET",
227
- githubAllowedOrg: "*",
228
- }));
229
- const res = await request(app, "/auth-required");
230
- const body = await res.json();
231
- expect(body.githubOAuth).toBe(true);
232
- });
233
- it("returns githubOAuth: false when only clientId is set (secret missing)", async () => {
234
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
235
- githubOAuthClientId: "GH_CLIENT",
236
- }));
237
- const res = await request(app, "/auth-required");
238
- const body = await res.json();
239
- expect(body.githubOAuth).toBe(false);
240
- });
241
- it("returns githubOAuth: false when id+secret set but allowed org is missing", async () => {
242
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
243
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
244
- githubOAuthClientId: "GH_CLIENT",
245
- githubOAuthClientSecret: "GH_SECRET",
246
- }));
247
- const res = await request(app, "/auth-required");
248
- const body = await res.json();
249
- expect(body.githubOAuth).toBe(false);
250
- expect(errSpy).toHaveBeenCalled();
251
- errSpy.mockRestore();
252
- });
253
- });
254
- describe("GET /oauth/github/authorize", () => {
255
- it("returns 404 when GitHub OAuth not configured", async () => {
256
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig());
257
- const res = await request(app, "/oauth/github/authorize");
258
- expect(res.status).toBe(404);
259
- });
260
- it("returns 404 when id+secret set but allowed org is missing", async () => {
261
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
262
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
263
- githubOAuthClientId: "GH_CLIENT",
264
- githubOAuthClientSecret: "GH_SECRET",
265
- }));
266
- const res = await request(app, "/oauth/github/authorize");
267
- expect(res.status).toBe(404);
268
- errSpy.mockRestore();
269
- });
270
- it("redirects to GitHub when configured with an allowed org", async () => {
271
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
272
- githubOAuthClientId: "GH_CLIENT",
273
- githubOAuthClientSecret: "GH_SECRET",
274
- githubOAuthRedirectUri: "http://localhost/callback",
275
- githubAllowedOrg: "acme",
276
- }));
277
- const res = await request(app, "/oauth/github/authorize");
278
- expect(res.status).toBe(302);
279
- const location = res.headers.get("location") ?? "";
280
- expect(location).toContain("github.com");
281
- });
282
- it("sets github_oauth_state cookie when configured", async () => {
283
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
284
- githubOAuthClientId: "GH_CLIENT",
285
- githubOAuthClientSecret: "GH_SECRET",
286
- githubOAuthRedirectUri: "http://localhost/callback",
287
- githubAllowedOrg: "acme",
288
- }));
289
- const res = await request(app, "/oauth/github/authorize");
290
- const setCookie = res.headers.get("set-cookie") ?? "";
291
- expect(setCookie).toContain("github_oauth_state=");
292
- expect(setCookie).toContain("HttpOnly");
293
- expect(setCookie).toContain("SameSite=Lax");
294
- expect(setCookie).toContain("Path=/");
295
- });
296
- });
297
- /** Helper: mock global.fetch routing /user and /orgs/... to different responses */
298
- function mockGithubFetch({ userLogin, orgStatus }) {
299
- return vi.fn(async (url) => {
300
- const urlStr = typeof url === "string" ? url : url instanceof URL ? url.href : url.url;
301
- if (urlStr.includes("/orgs/")) {
302
- return new Response(null, { status: orgStatus ?? 204 });
303
- }
304
- // Default: /user
305
- return new Response(JSON.stringify({ login: userLogin }), { headers: { "Content-Type": "application/json" } });
306
- });
307
- }
308
- describe("GET /oauth/github/callback", () => {
309
- it("returns 404 when GitHub OAuth not configured", async () => {
310
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig());
311
- const res = await request(app, "/oauth/github/callback?code=abc&state=xyz");
312
- expect(res.status).toBe(404);
313
- });
314
- it("returns 400 when state is missing or mismatched", async () => {
315
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
316
- githubOAuthClientId: "GH_CLIENT",
317
- githubOAuthClientSecret: "GH_SECRET",
318
- githubOAuthRedirectUri: "http://localhost/callback",
319
- githubAllowedOrg: "acme",
320
- }));
321
- // No cookie set → state mismatch
322
- const res = await request(app, "/oauth/github/callback?code=abc&state=bad-state");
323
- expect(res.status).toBe(302);
324
- expect(res.headers.get("location")).toBe("/admin/?error=oauth_state");
325
- });
326
- it("redirects to /admin/?error=oauth_code when code is missing", async () => {
327
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
328
- githubOAuthClientId: "GH_CLIENT",
329
- githubOAuthClientSecret: "GH_SECRET",
330
- githubAllowedOrg: "acme",
331
- }));
332
- const state = "teststate000";
333
- const req = new Request(`http://localhost/oauth/github/callback?state=${state}`, {
334
- headers: { Cookie: `github_oauth_state=${state}` },
335
- });
336
- const res = await app.fetch(req);
337
- expect(res.status).toBe(302);
338
- expect(res.headers.get("location")).toBe("/admin/?error=oauth_code");
339
- });
340
- it("redirects with token and skips org fetch when allowlist is \"*\"", async () => {
341
- const originalFetch = global.fetch;
342
- const fetchMock = mockGithubFetch({ userLogin: "alice" });
343
- global.fetch = fetchMock;
344
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
345
- githubOAuthClientId: "GH_CLIENT",
346
- githubOAuthClientSecret: "GH_SECRET",
347
- githubOAuthRedirectUri: "http://localhost/callback",
348
- githubAllowedOrg: "*",
349
- }));
350
- const state = "teststate111";
351
- const req = new Request(`http://localhost/oauth/github/callback?code=abc&state=${state}`, {
352
- headers: { Cookie: `github_oauth_state=${state}` },
353
- });
354
- const res = await app.fetch(req);
355
- expect(res.status).toBe(302);
356
- expect(res.headers.get("location")).toContain("/admin/?token=");
357
- // Must not have called the /orgs/ membership endpoint under "*"
358
- const calls = fetchMock.mock.calls.map((c) => {
359
- const u = c[0];
360
- return typeof u === "string" ? u : u instanceof URL ? u.href : u.url;
361
- });
362
- expect(calls.some((u) => u.includes("/orgs/"))).toBe(false);
363
- global.fetch = originalFetch;
364
- });
365
- it("redirects with token when org membership returns 204", async () => {
366
- const originalFetch = global.fetch;
367
- global.fetch = mockGithubFetch({ userLogin: "alice", orgStatus: 204 });
368
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
369
- githubOAuthClientId: "GH_CLIENT",
370
- githubOAuthClientSecret: "GH_SECRET",
371
- githubOAuthRedirectUri: "http://localhost/callback",
372
- githubAllowedOrg: "acme",
373
- }));
374
- const state = "teststate222";
375
- const req = new Request(`http://localhost/oauth/github/callback?code=abc&state=${state}`, {
376
- headers: { Cookie: `github_oauth_state=${state}` },
377
- });
378
- const res = await app.fetch(req);
379
- expect(res.status).toBe(302);
380
- expect(res.headers.get("location")).toContain("/admin/?token=");
381
- global.fetch = originalFetch;
382
- });
383
- it("redirects to /admin/?error=github_org when membership returns 404 (not a member)", async () => {
384
- const originalFetch = global.fetch;
385
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
386
- global.fetch = mockGithubFetch({ userLogin: "alice", orgStatus: 404 });
387
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
388
- githubOAuthClientId: "GH_CLIENT",
389
- githubOAuthClientSecret: "GH_SECRET",
390
- githubOAuthRedirectUri: "http://localhost/callback",
391
- githubAllowedOrg: "acme",
392
- }));
393
- const state = "teststate333";
394
- const req = new Request(`http://localhost/oauth/github/callback?code=abc&state=${state}`, {
395
- headers: { Cookie: `github_oauth_state=${state}` },
396
- });
397
- const res = await app.fetch(req);
398
- expect(res.status).toBe(302);
399
- expect(res.headers.get("location")).toBe("/admin/?error=github_org");
400
- global.fetch = originalFetch;
401
- warnSpy.mockRestore();
402
- });
403
- it("redirects to /admin/?error=github_org when membership returns 302 (insufficient visibility)", async () => {
404
- const originalFetch = global.fetch;
405
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
406
- global.fetch = mockGithubFetch({ userLogin: "alice", orgStatus: 302 });
407
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
408
- githubOAuthClientId: "GH_CLIENT",
409
- githubOAuthClientSecret: "GH_SECRET",
410
- githubOAuthRedirectUri: "http://localhost/callback",
411
- githubAllowedOrg: "acme",
412
- }));
413
- const state = "teststate444";
414
- const req = new Request(`http://localhost/oauth/github/callback?code=abc&state=${state}`, {
415
- headers: { Cookie: `github_oauth_state=${state}` },
416
- });
417
- const res = await app.fetch(req);
418
- expect(res.status).toBe(302);
419
- expect(res.headers.get("location")).toBe("/admin/?error=github_org");
420
- global.fetch = originalFetch;
421
- warnSpy.mockRestore();
422
- });
423
- });
424
- describe("POST /login (password)", () => {
425
- it("still works with correct password", async () => {
426
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
427
- adminPassword: "correct",
428
- }));
429
- const res = await request(app, "/login", {
430
- method: "POST",
431
- headers: { "Content-Type": "application/json" },
432
- body: JSON.stringify({ password: "correct" }),
433
- });
434
- expect(res.status).toBe(200);
435
- const body = await res.json();
436
- expect(typeof body.token).toBe("string");
437
- });
438
- it("rejects wrong password", async () => {
439
- const app = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({
440
- adminPassword: "correct",
441
- }));
442
- const res = await request(app, "/login", {
443
- method: "POST",
444
- headers: { "Content-Type": "application/json" },
445
- body: JSON.stringify({ password: "wrong" }),
446
- });
447
- expect(res.status).toBe(401);
448
- });
449
- });
450
- describe("POST /workflow-runs/:id/cancel", () => {
451
- // Helper to make a cancel-test db that returns a single run and
452
- // records calls to the mutating methods we care about.
453
- function makeCancelDb(opts) {
454
- const finishes = [];
455
- const cancels = [];
456
- const db = {
457
- ...mockDb,
458
- runs: {
459
- ...(mockDb.runs),
460
- getRun: vi.fn(() => opts.run.status === "cancelled" ? null : {
461
- id: opts.run.id,
462
- workflowName: "pr-review",
463
- triggerId: opts.run.triggerId,
464
- currentPhase: "review",
465
- phaseHistory: [],
466
- status: opts.run.status,
467
- context: opts.run.taskId ? { taskId: opts.run.taskId } : {},
468
- startedAt: "",
469
- updatedAt: "",
470
- }),
471
- cancelRun: vi.fn((id) => { cancels.push(id); }),
472
- },
473
- executions: {
474
- ...(mockDb.executions),
475
- runningExecutions: vi.fn(() => opts.runningExecutions ?? []),
476
- recordFinish: vi.fn((id, res) => { finishes.push({ id, error: res.error }); }),
477
- },
478
- };
479
- return { db, finishes, cancels };
480
- }
481
- it("returns 404 when run not found", async () => {
482
- const db = {
483
- ...mockDb,
484
- runs: {
485
- ...(mockDb.runs),
486
- getRun: vi.fn(() => null),
487
- },
488
- };
489
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
490
- const res = await request(app, "/workflow-runs/run-abc/cancel", { method: "POST" });
491
- expect(res.status).toBe(404);
492
- });
493
- it("returns 400 when run already in a terminal state", async () => {
494
- const { db } = makeCancelDb({ run: { id: "r1", status: "succeeded", triggerId: "t1" } });
495
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
496
- const res = await request(app, "/workflow-runs/r1/cancel", { method: "POST" });
497
- expect(res.status).toBe(400);
498
- });
499
- it("cancels and kills containers matching the run's taskId prefix", async () => {
500
- const dockerMod = await import("./docker.js");
501
- const listMock = vi.mocked(dockerMod.listRunningContainers);
502
- const killMock = vi.mocked(dockerMod.killContainer);
503
- listMock.mockResolvedValueOnce([
504
- { id: "c1", name: "lastlight-sandbox-task-xyz-aaaaaaaa", taskId: "task-xyz", status: "running", created: "", image: "" },
505
- { id: "c2", name: "lastlight-sandbox-task-xyz-review-bbbbbbbb", taskId: "task-xyz-review", status: "running", created: "", image: "" },
506
- { id: "c3", name: "lastlight-sandbox-other-cccccccc", taskId: "other", status: "running", created: "", image: "" },
507
- ]);
508
- const { db, finishes, cancels } = makeCancelDb({
509
- run: { id: "r1", status: "running", triggerId: "t1", taskId: "task-xyz" },
510
- runningExecutions: [
511
- { id: "e1", workflowRunId: "r1", triggerId: "t1" },
512
- { id: "e2", workflowRunId: "r1", triggerId: "t1" },
513
- ],
514
- });
515
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
516
- const res = await request(app, "/workflow-runs/r1/cancel", { method: "POST" });
517
- expect(res.status).toBe(200);
518
- expect(cancels).toEqual(["r1"]);
519
- expect(killMock).toHaveBeenCalledTimes(2);
520
- expect(killMock).toHaveBeenCalledWith("lastlight-sandbox-task-xyz-aaaaaaaa");
521
- expect(killMock).toHaveBeenCalledWith("lastlight-sandbox-task-xyz-review-bbbbbbbb");
522
- expect(killMock).not.toHaveBeenCalledWith("lastlight-sandbox-other-cccccccc");
523
- expect(finishes.map((f) => f.id).sort()).toEqual(["e1", "e2"]);
524
- expect(finishes.every((f) => f.error === "cancelled via admin dashboard")).toBe(true);
525
- });
526
- it("does NOT mark sibling-run executions as failed when cancelling a run with a shared triggerId", async () => {
527
- // Regression for the PR #38 review: matching by triggerId clobbered
528
- // execution rows belonging to a concurrent run on the same trigger.
529
- // Matching by workflowRunId must only finish rows for THIS run.
530
- const dockerMod = await import("./docker.js");
531
- vi.mocked(dockerMod.listRunningContainers).mockResolvedValueOnce([]);
532
- const { db, finishes } = makeCancelDb({
533
- run: { id: "r1", status: "running", triggerId: "shared-trigger", taskId: "task-r1" },
534
- runningExecutions: [
535
- { id: "e1", workflowRunId: "r1", triggerId: "shared-trigger" },
536
- { id: "e2", workflowRunId: "r2", triggerId: "shared-trigger" },
537
- { id: "e3", workflowRunId: undefined, triggerId: "shared-trigger" },
538
- ],
539
- });
540
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
541
- const res = await request(app, "/workflow-runs/r1/cancel", { method: "POST" });
542
- expect(res.status).toBe(200);
543
- expect(finishes.map((f) => f.id)).toEqual(["e1"]);
544
- });
545
- });
546
- describe("GET /workflow-runs/:id/approvals", () => {
547
- function makeApprovalsDb(run, approvals) {
548
- const listForWorkflow = vi.fn(() => approvals);
549
- const db = {
550
- ...mockDb,
551
- runs: {
552
- ...(mockDb.runs),
553
- getRun: vi.fn(() => run),
554
- },
555
- approvals: {
556
- ...(mockDb.approvals),
557
- listForWorkflow,
558
- },
559
- };
560
- return { db, listForWorkflow };
561
- }
562
- it("returns 404 when the run is not found", async () => {
563
- const { db } = makeApprovalsDb(null, []);
564
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
565
- const res = await request(app, "/workflow-runs/missing/approvals");
566
- expect(res.status).toBe(404);
567
- });
568
- it("returns every approval (all statuses) for the run, looked up by run id", async () => {
569
- const run = { id: "run-1", triggerId: "t1", workflowName: "build" };
570
- const approvals = [
571
- { id: "a1", workflowRunId: "run-1", gate: "post_architect", status: "approved", respondedBy: "alice", createdAt: "2026-01-01T00:00:00Z" },
572
- { id: "a2", workflowRunId: "run-1", gate: "pre_fix", status: "pending", createdAt: "2026-01-01T01:00:00Z" },
573
- ];
574
- const { db, listForWorkflow } = makeApprovalsDb(run, approvals);
575
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
576
- const res = await request(app, "/workflow-runs/run-1/approvals");
577
- expect(res.status).toBe(200);
578
- const body = await res.json();
579
- expect(body.approvals).toEqual(approvals);
580
- expect(listForWorkflow).toHaveBeenCalledWith("run-1");
581
- });
582
- });
583
- describe("GET /sessions — content filter + liveness", () => {
584
- const now = Date.now() / 1000;
585
- const meta = (over) => ({
586
- id: "x", source: "agent", sessionType: "agent", model: "m",
587
- started_at: now, last_message_at: now, message_count: 5,
588
- tool_call_count: 0, conversation_message_count: 5,
589
- last_assistant_content: null, agentIds: [], ...over,
590
- });
591
- function appWith(metas) {
592
- const sessions = {
593
- ...mockSessions,
594
- listSessionIds: vi.fn(() => metas.map((m) => m.id)),
595
- getSessionMeta: vi.fn(async (id) => metas.find((m) => m.id === id) ?? null),
596
- };
597
- return createAdminRoutes(mockDb, sessions, mockSessions, makeConfig({ adminPassword: "" }));
598
- }
599
- it("omits zero-message sessions (empty/aborted runs left behind)", async () => {
600
- const dockerMod = await import("./docker.js");
601
- vi.mocked(dockerMod.listRunningContainers).mockResolvedValueOnce([]);
602
- const app = appWith([
603
- meta({ id: "real", message_count: 3 }),
604
- meta({ id: "exec-drizzle-553-pr-review-e34282db", message_count: 0 }),
605
- ]);
606
- const res = await request(app, "/sessions");
607
- const body = await res.json();
608
- expect(body.sessions.map((s) => s.id)).toEqual(["real"]);
609
- });
610
- it("marks an exec-<taskId> session live only when its container is running", async () => {
611
- const dockerMod = await import("./docker.js");
612
- vi.mocked(dockerMod.listRunningContainers).mockResolvedValueOnce([
613
- { id: "c1", name: "lastlight-sandbox-task-xyz-aaaaaaaa", taskId: "task-xyz", status: "running", created: "", image: "" },
614
- ]);
615
- const app = appWith([
616
- meta({ id: "exec-task-xyz", message_count: 2 }), // matches running container
617
- meta({ id: "exec-task-other", message_count: 2 }), // no matching container
618
- ]);
619
- const res = await request(app, "/sessions");
620
- const body = await res.json();
621
- const byId = Object.fromEntries(body.sessions.map((s) => [s.id, s.live]));
622
- expect(byId["exec-task-xyz"]).toBe(true);
623
- expect(byId["exec-task-other"]).toBe(false);
624
- });
625
- });
626
- describe("POST /approvals/:id/respond", () => {
627
- // Build a db whose approval/run sub-stores record the lifecycle mutations
628
- // the approval route can issue, so a test can assert which ones fired.
629
- function makeApprovalDb(over = {}) {
630
- const calls = {
631
- respond: [],
632
- resolveGateAndResume: [],
633
- resolveGateAndFail: [],
634
- };
635
- const approval = "approval" in over ? over.approval : { id: "appr-1", status: "pending", workflowRunId: "run-1" };
636
- const run = "run" in over ? over.run : { id: "run-1", workflowName: "build", triggerId: "o/r#3", issueNumber: 3 };
637
- // respond() returns the compare-and-set row count; default 1 (won the CAS).
638
- const respondChanges = over.respondChanges ?? 1;
639
- const db = {
640
- ...mockDb,
641
- approvals: {
642
- ...(mockDb.approvals),
643
- getById: vi.fn(() => approval),
644
- respond: vi.fn((...args) => { calls.respond.push(args); return respondChanges; }),
645
- },
646
- runs: {
647
- ...(mockDb.runs),
648
- getRun: vi.fn(() => run),
649
- resolveGateAndResume: vi.fn((...args) => { calls.resolveGateAndResume.push(args); return run; }),
650
- resolveGateAndFail: vi.fn((...args) => { calls.resolveGateAndFail.push(args); return run; }),
651
- },
652
- };
653
- return { db, calls };
654
- }
655
- async function respond(db, id, payload, cfg = {}) {
656
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "", ...cfg }));
657
- return request(app, `/approvals/${id}/respond`, {
658
- method: "POST",
659
- headers: { "Content-Type": "application/json" },
660
- body: JSON.stringify(payload),
661
- });
662
- }
663
- it("404s when the approval is missing", async () => {
664
- const { db } = makeApprovalDb({ approval: null });
665
- const res = await respond(db, "nope", { decision: "approved" });
666
- expect(res.status).toBe(404);
667
- });
668
- it("400s when the approval is already resolved", async () => {
669
- const { db } = makeApprovalDb({ approval: { id: "appr-1", status: "approved", workflowRunId: "run-1" } });
670
- const res = await respond(db, "appr-1", { decision: "approved" });
671
- expect(res.status).toBe(400);
672
- });
673
- it("approves and dispatches a resume when resumeWorkflow is wired", async () => {
674
- const { db, calls } = makeApprovalDb();
675
- const resumeWorkflow = vi.fn(async () => { });
676
- const res = await respond(db, "appr-1", { decision: "approved" }, { resumeWorkflow });
677
- expect(res.status).toBe(200);
678
- // The approval is recorded and the resume helper is handed the run...
679
- expect(calls.respond).toEqual([["appr-1", "approved", "admin", undefined]]);
680
- expect(resumeWorkflow).toHaveBeenCalledWith(expect.objectContaining({ id: "run-1" }), "admin");
681
- // ...and the route does NOT pre-flip the run to running via the atomic op —
682
- // resumeWorkflow owns the status flip so it only happens with a dispatch.
683
- expect(calls.resolveGateAndResume).toEqual([]);
684
- });
685
- it("records the approval WITHOUT flipping the run to running when no resume can be dispatched", async () => {
686
- // Regression (PR #105 review): the atomic approve+resume flipped the run to
687
- // `running` even when no worker would be dispatched (no resumeWorkflow
688
- // wired, App down, or a non-issue trigger), orphaning the row. The approval
689
- // must be recorded, but the run must NOT become `running`.
690
- const { db, calls } = makeApprovalDb();
691
- const res = await respond(db, "appr-1", { decision: "approved" }); // no resumeWorkflow
692
- expect(res.status).toBe(200);
693
- expect(calls.respond).toEqual([["appr-1", "approved", "admin", undefined]]);
694
- expect(calls.resolveGateAndResume).toEqual([]);
695
- });
696
- it("409s and does NOT resume when the approval CAS loses a race", async () => {
697
- // The status check above is a TOCTOU read; if a concurrent responder wins,
698
- // respond() changes 0 rows. The loser must 409 and never hand the run to
699
- // resumeWorkflow for a second dispatch.
700
- const { db, calls } = makeApprovalDb({ respondChanges: 0 });
701
- const resumeWorkflow = vi.fn(async () => { });
702
- const res = await respond(db, "appr-1", { decision: "approved" }, { resumeWorkflow });
703
- expect(res.status).toBe(409);
704
- expect(resumeWorkflow).not.toHaveBeenCalled();
705
- expect(calls.resolveGateAndResume).toEqual([]);
706
- });
707
- it("rejects by failing the run atomically", async () => {
708
- const { db, calls } = makeApprovalDb();
709
- const res = await respond(db, "appr-1", { decision: "rejected", reason: "too risky" });
710
- expect(res.status).toBe(200);
711
- expect(calls.resolveGateAndFail).toEqual([["appr-1", "admin", "too risky"]]);
712
- expect(calls.respond).toEqual([]);
713
- });
714
- });
715
- describe("GET /approvals/:id (focused approval enrichment)", () => {
716
- function makeDb(approval, run) {
717
- return {
718
- ...mockDb,
719
- approvals: {
720
- ...(mockDb.approvals),
721
- getById: vi.fn(() => approval),
722
- },
723
- runs: {
724
- ...(mockDb.runs),
725
- getRun: vi.fn(() => run),
726
- },
727
- };
728
- }
729
- async function getApproval(db, id, cfg = {}) {
730
- const app = createAdminRoutes(db, mockSessions, mockSessions, makeConfig({ adminPassword: "", ...cfg }));
731
- return request(app, `/approvals/${id}`);
732
- }
733
- // Production shape: workflow_runs.repo is the BARE name; owner lives in
734
- // context (matching src/workflows/simple.ts). The Slack-invoked build flow
735
- // uses the same createRun path, so this also covers it.
736
- const run = {
737
- id: "run-1",
738
- workflowName: "build",
739
- triggerId: "acme/widget#3",
740
- repo: "widget",
741
- issueNumber: 3,
742
- context: { owner: "acme", branch: "lastlight/issue-3", issueDir: ".lastlight/issue-3" },
743
- };
744
- it("404s when the approval is missing", async () => {
745
- const res = await getApproval(makeDb(null, run), "nope");
746
- expect(res.status).toBe(404);
747
- });
748
- it("returns a server-mode artifactRef (editable store doc)", async () => {
749
- const approval = { id: "a1", status: "pending", workflowRunId: "run-1", artifact: "architect-plan.md" };
750
- const res = await getApproval(makeDb(approval, run), "a1", { buildAssets: "server" });
751
- const body = await res.json();
752
- expect(res.status).toBe(200);
753
- expect(body.artifactRef).toMatchObject({
754
- mode: "server",
755
- owner: "acme",
756
- repo: "widget",
757
- issueKey: "issue-3",
758
- doc: "architect-plan.md",
759
- });
760
- expect(body.artifactRef.githubUrl).toBeUndefined();
761
- });
762
- it("returns a repo-mode artifactRef with a GitHub blob URL", async () => {
763
- const approval = { id: "a1", status: "pending", workflowRunId: "run-1", artifact: "architect-plan.md" };
764
- const res = await getApproval(makeDb(approval, run), "a1", { buildAssets: "repo" });
765
- const body = await res.json();
766
- expect(res.status).toBe(200);
767
- expect(body.artifactRef.mode).toBe("repo");
768
- expect(body.artifactRef.githubUrl).toBe("https://github.com/acme/widget/blob/lastlight%2Fissue-3/.lastlight/issue-3/architect-plan.md");
769
- });
770
- it("returns artifactRef: null when the gate carries no artifact", async () => {
771
- const approval = { id: "a1", status: "pending", workflowRunId: "run-1" };
772
- const res = await getApproval(makeDb(approval, run), "a1", { buildAssets: "server" });
773
- const body = await res.json();
774
- expect(res.status).toBe(200);
775
- expect(body.artifactRef).toBeNull();
776
- });
777
- });
778
- describe("artifacts endpoints (server-mode build assets)", () => {
779
- let dir;
780
- // Auth disabled (empty password) so the round-trip test doesn't need a token.
781
- const app = () => createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({ adminPassword: "", buildAssetsDir: dir }));
782
- beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ll-routes-assets-")); });
783
- afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
784
- it("PUT then GET round-trips a doc and lists keys/files", async () => {
785
- const a = app();
786
- const put = await request(a, "/artifacts/acme/widget/issue-42/architect-plan.md", {
787
- method: "PUT",
788
- headers: { "Content-Type": "text/plain" },
789
- body: "# Plan\n",
790
- });
791
- expect(put.status).toBe(200);
792
- expect(await put.json()).toEqual({ ok: true });
793
- const keys = await request(a, "/artifacts?repo=acme/widget");
794
- expect(await keys.json()).toEqual({ keys: ["issue-42"] });
795
- const files = await request(a, "/artifacts/acme/widget/issue-42");
796
- expect(await files.json()).toEqual({ files: ["architect-plan.md"] });
797
- const doc = await request(a, "/artifacts/acme/widget/issue-42/architect-plan.md");
798
- expect(doc.status).toBe(200);
799
- expect(await doc.text()).toBe("# Plan\n");
800
- });
801
- it("400s on a traversal attempt in the doc name", async () => {
802
- const res = await request(app(), "/artifacts/acme/widget/issue-42/..%2f..%2fsecret", {
803
- method: "PUT",
804
- body: "x",
805
- });
806
- expect(res.status).toBe(400);
807
- });
808
- it("404 on an unknown doc; empty lists for an unknown repo", async () => {
809
- const a = app();
810
- const missing = await request(a, "/artifacts/acme/widget/issue-1/nope.md");
811
- expect(missing.status).toBe(404);
812
- const keys = await request(a, "/artifacts?repo=nobody/nothing");
813
- expect(await keys.json()).toEqual({ keys: [] });
814
- });
815
- it("reports empty when no store dir is configured", async () => {
816
- const a = createAdminRoutes(mockDb, mockSessions, mockSessions, makeConfig({ adminPassword: "" }));
817
- const keys = await request(a, "/artifacts?repo=acme/widget");
818
- expect(await keys.json()).toEqual({ keys: [] });
819
- });
820
- });
821
- describe("public artifact image route (mountAdmin carve-out)", () => {
822
- let dir;
823
- // Auth ENABLED — the public image route must still be reachable without a
824
- // token (it's registered on the parent app, before the auth-guarded
825
- // /admin/api sub-app), while the auth-gated route is not.
826
- const mount = (buildAssetsDir) => {
827
- const app = new Hono();
828
- mountAdmin(app, mockDb, makeConfig({ adminPassword: "secret", buildAssetsDir }));
829
- return app;
830
- };
831
- const get = (app, path) => app.fetch(new Request(`http://localhost${path}`));
832
- beforeEach(() => {
833
- dir = mkdtempSync(join(tmpdir(), "ll-public-assets-"));
834
- new BuildAssetStore(dir).write({ owner: "acme", repo: "widget", issueKey: "issue-1" }, "home.png", "PNG-BYTES");
835
- new BuildAssetStore(dir).write({ owner: "acme", repo: "widget", issueKey: "issue-1" }, "plan.md", "# secret plan\n");
836
- });
837
- afterEach(() => rmSync(dir, { recursive: true, force: true }));
838
- it("serves a PNG as image/png with NO auth token", async () => {
839
- const res = await get(mount(dir), "/admin/api/public/artifacts/acme/widget/issue-1/home.png");
840
- expect(res.status).toBe(200);
841
- expect(res.headers.get("Content-Type")).toBe("image/png");
842
- expect(await res.text()).toBe("PNG-BYTES");
843
- });
844
- it("serves an mp4 as video/mp4 with Range support and NO auth token", async () => {
845
- new BuildAssetStore(dir).write({ owner: "acme", repo: "widget", issueKey: "issue-1" }, "demo.mp4", "MP4-BYTES-0123456789");
846
- const app = mount(dir);
847
- const full = await get(app, "/admin/api/public/artifacts/acme/widget/issue-1/demo.mp4");
848
- expect(full.status).toBe(200);
849
- expect(full.headers.get("Content-Type")).toBe("video/mp4");
850
- expect(full.headers.get("Accept-Ranges")).toBe("bytes");
851
- // A Range request (what a <video> element / GitHub player sends) must
852
- // return 206 Partial Content with a Content-Range so seeking works.
853
- const ranged = await app.fetch(new Request("http://localhost/admin/api/public/artifacts/acme/widget/issue-1/demo.mp4", {
854
- headers: { Range: "bytes=0-3" },
855
- }));
856
- expect(ranged.status).toBe(206);
857
- expect(ranged.headers.get("Content-Range")).toBe("bytes 0-3/20");
858
- expect(await ranged.text()).toBe("MP4-");
859
- });
860
- it("404s on a non-media doc (media-only gate keeps text docs private)", async () => {
861
- const res = await get(mount(dir), "/admin/api/public/artifacts/acme/widget/issue-1/plan.md");
862
- expect(res.status).toBe(404);
863
- });
864
- it("404s for a missing image", async () => {
865
- const res = await get(mount(dir), "/admin/api/public/artifacts/acme/widget/issue-1/nope.png");
866
- expect(res.status).toBe(404);
867
- });
868
- it("404s when the build-assets store is not configured", async () => {
869
- const res = await get(mount(undefined), "/admin/api/public/artifacts/acme/widget/issue-1/home.png");
870
- expect(res.status).toBe(404);
871
- });
872
- it("the auth-gated artifacts route still requires a token (contrast)", async () => {
873
- const res = await get(mount(dir), "/admin/api/artifacts/acme/widget/issue-1/plan.md");
874
- expect(res.status).toBe(401);
875
- });
876
- });
877
- //# sourceMappingURL=routes.test.js.map